diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index ba5de2b41c..fbc32d2e84 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -2079,7 +2079,10 @@ const DBSearchPageFiltersComponent = ({ ); }; -function isFieldPrimary(tableMetadata: TableMetadata | undefined, key: string) { +function isFieldPrimary( + tableMetadata: TableMetadata | null | undefined, + key: string, +) { return tableMetadata?.primary_key?.includes(key); } export const DBSearchPageFilters = memo(DBSearchPageFiltersComponent); diff --git a/packages/app/src/components/SearchInput/SearchInputV2.tsx b/packages/app/src/components/SearchInput/SearchInputV2.tsx index 904b281f32..5b793df778 100644 --- a/packages/app/src/components/SearchInput/SearchInputV2.tsx +++ b/packages/app/src/components/SearchInput/SearchInputV2.tsx @@ -1,8 +1,9 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useController, UseControllerProps } from 'react-hook-form'; import { useHotkeys } from 'react-hotkeys-hook'; import { Field, + TableConnection, TableConnectionChoice, } from '@hyperdx/common-utils/dist/core/metadata'; import { genEnglishExplanation } from '@hyperdx/common-utils/dist/queryParser'; @@ -32,6 +33,12 @@ export class LuceneLanguageFormatter implements ILanguageFormatter { } const luceneLanguageFormatter = new LuceneLanguageFormatter(); + +function stableTableConnectionToKey(tc: TableConnection | undefined): string { + if (!tc) return ''; + return `${tc.connectionId}|${tc.databaseName}|${tc.tableName}`; +} + export default function SearchInputV2({ tableConnection, tableConnections, @@ -87,17 +94,40 @@ export default function SearchInputV2({ }, ); + // Callers commonly pass an inline `tcFromSource(source)` for `tableConnection`, + // which produces a fresh object reference on every parent render. Without + // stabilizing here, the explanation effect below would re-run continuously, + // re-triggering schema queries and (when the underlying ClickHouse database + // doesn't exist) setting the same string state in a tight loop, eventually + // tripping React's "Maximum update depth exceeded" guard. + const stableTableConnectionKey = stableTableConnectionToKey(tableConnection); + const stableTableConnection = useMemo( + () => tableConnection, + // eslint-disable-next-line react-hooks/exhaustive-deps + [stableTableConnectionKey], + ); + useEffect(() => { - if (tableConnection) { - genEnglishExplanation({ - query: value, - tableConnection, - metadata, - }).then(q => { - setParsedEnglishQuery(q); + if (!stableTableConnection) return; + let cancelled = false; + genEnglishExplanation({ + query: value, + tableConnection: stableTableConnection, + metadata, + }) + .then(q => { + if (!cancelled) setParsedEnglishQuery(q); + }) + .catch(err => { + // Schema lookups can fail for sources whose database/table no longer + // exists. Swallow these so a stale source doesn't surface as an + // unhandled rejection that contributes to render-loop crashes. + console.warn('Failed to compute lucene query explanation:', err); }); - } - }, [value, tableConnection, metadata]); + return () => { + cancelled = true; + }; + }, [value, stableTableConnection, metadata]); useHotkeys( ['/', 's'], diff --git a/packages/app/src/hooks/useFieldExpressionGenerator.tsx b/packages/app/src/hooks/useFieldExpressionGenerator.tsx index a68f4c45e1..7ec7e02611 100644 --- a/packages/app/src/hooks/useFieldExpressionGenerator.tsx +++ b/packages/app/src/hooks/useFieldExpressionGenerator.tsx @@ -1,3 +1,4 @@ +import { useCallback, useMemo } from 'react'; import SqlString from 'sqlstring'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { TSource } from '@hyperdx/common-utils/dist/types'; @@ -24,24 +25,26 @@ export default function useFieldExpressionGenerator( tcFromSource(source), ); - if (source && !isLoadingJsonColumns) { - return { - isLoading: false, - getFieldExpression: ( - column: string, - key: string, - convertFn: string = 'toString', - ) => { - const isJson = jsonColumns?.includes(column); - return isJson - ? SqlString.format(`${convertFn}(??.??)`, [column, key]) - : SqlString.format('??[?]', [column, key]); - }, - }; - } else { - return { - isLoading: isLoadingJsonColumns, - getFieldExpression: undefined, - }; - } + // Memoize the generator so it has a stable reference across renders when + // its inputs haven't changed. Several call sites pass the returned function + // into `useMemo` / `useEffect` dependency arrays, and a fresh closure on + // every render previously caused expensive recomputation cascades that + // amplified render-loop bugs when the underlying schema query failed. + const getFieldExpression = useCallback( + (column, key, convertFn = 'toString') => { + const isJson = jsonColumns?.includes(column); + return isJson + ? SqlString.format(`${convertFn}(??.??)`, [column, key]) + : SqlString.format('??[?]', [column, key]); + }, + [jsonColumns], + ); + + return useMemo( + () => + source && !isLoadingJsonColumns + ? { isLoading: false, getFieldExpression } + : { isLoading: isLoadingJsonColumns, getFieldExpression: undefined }, + [source, isLoadingJsonColumns, getFieldExpression], + ); } diff --git a/packages/app/src/hooks/useMetadata.tsx b/packages/app/src/hooks/useMetadata.tsx index 2f170d980f..d75360e769 100644 --- a/packages/app/src/hooks/useMetadata.tsx +++ b/packages/app/src/hooks/useMetadata.tsx @@ -74,6 +74,13 @@ export function useMetadataWithSettings() { return metadata; } +// Schema-discovery queries (DESCRIBE / system.tables) deterministically fail +// when the source's database or table doesn't exist on the current ClickHouse +// server. There's nothing useful to gain from retrying those failures, and the +// retries amplify spurious re-renders that have caused render-loop crashes +// (e.g. "Maximum update depth exceeded") in pages that observe these hooks. +const SCHEMA_QUERY_RETRY = 0; + export function useColumns( { databaseName, @@ -96,6 +103,7 @@ export function useColumns( connectionId, }); }, + retry: SCHEMA_QUERY_RETRY, enabled: !!databaseName && !!tableName && !!connectionId, ...options, }); @@ -117,8 +125,9 @@ export function useJsonColumns( ) ?? [] ); }, + retry: SCHEMA_QUERY_RETRY, enabled: - tableConnection && + !!tableConnection && !!tableConnection.databaseName && !!tableConnection.tableName && !!tableConnection.connectionId, @@ -208,15 +217,24 @@ export function useTableMetadata( options?: Omit, 'queryKey'>, ) { const metadata = useMetadataWithSettings(); - return useQuery({ + return useQuery({ queryKey: ['useMetadata.useTableMetadata', { databaseName, tableName }], queryFn: async () => { - return await metadata.getTableMetadata({ + // `getTableMetadata` resolves to `undefined` when the table doesn't + // exist (e.g. the source points at a database that's missing on the + // current ClickHouse server). React Query v5 forbids `undefined` query + // results and throws "Query data cannot be undefined", which previously + // surfaced as a hard render-loop crash from consumers of this hook. + // Normalize the missing case to `null` so the absence is still + // observable but the query resolves cleanly. + const tableMetadata = await metadata.getTableMetadata({ databaseName, tableName, connectionId, }); + return tableMetadata ?? null; }, + retry: SCHEMA_QUERY_RETRY, staleTime: 1000 * 60 * 5, // Cache every 5 min enabled: !!databaseName && !!tableName && !!connectionId, ...options,