From fb9b5742d629e2736b9ba06084ed6cbec9f0b4b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 24 May 2026 17:32:08 +0000 Subject: [PATCH 1/2] fix(app): prevent crash when source DB/table doesn't exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sources can point at databases or tables that don't exist on the current ClickHouse server (e.g. a stale config or freshly imported source). Previously, navigating around the app while such a source was selected caused a hard 'Maximum update depth exceeded' crash that originated from schema-discovery hooks. Two underlying bugs: 1. `useTableMetadata`'s queryFn could resolve to `undefined` when the table is missing in `system.tables`. React Query v5 treats `undefined` as an invalid query result and throws 'Query data cannot be undefined'. That synthetic error then propagated through the page and contributed to render loops in consumers that observed the query result. 2. The schema-discovery hooks (`useColumns`, `useJsonColumns`, `useTableMetadata`) used the default 3-retry policy. 'Database does not exist' is deterministic — retrying just amplifies the spurious re-renders that triggered the crash, and hammers the database with useless requests. Additionally, `useFieldExpressionGenerator` returned a fresh `getFieldExpression` closure on every render, which destabilized downstream useMemo dependencies that themselves observe schema state. Memoize the closure so consumers only recompute when `jsonColumns` actually changes. Co-authored-by: Mike Shi --- .../src/components/DBSearchPageFilters.tsx | 5 ++- .../src/hooks/useFieldExpressionGenerator.tsx | 43 ++++++++++--------- packages/app/src/hooks/useMetadata.tsx | 24 +++++++++-- 3 files changed, 48 insertions(+), 24 deletions(-) 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/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, From 854b54b9bf31969f942b0427fbd704dd7e26f5d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 24 May 2026 17:56:32 +0000 Subject: [PATCH 2/2] fix(app): stabilize SearchInputV2 explanation effect against unstable tableConnection refs Callers like the dashboard / sessions / services pages pass an inline `tcFromSource(source)` for `tableConnection`, producing a fresh object reference every parent render. The lucene-explanation effect in `SearchInputV2` previously depended on that reference and re-ran every render, calling `setParsedEnglishQuery` in a tight loop. When the underlying source's database doesn't exist, the schema lookups inside `genEnglishExplanation` reject; the state update was wrapped in a `.then` with no error handler, so the rejection bubbled as an unhandled promise rejection and tripped the dev-mode error overlay during the render-loop storm. Stabilize the connection by memoizing on a content-derived string key, swallow the schema-lookup error to a console warning, and add a cancel flag so a stale resolution doesn't update state after the connection changed. Co-authored-by: Mike Shi --- .../components/SearchInput/SearchInputV2.tsx | 50 +++++++++++++++---- 1 file changed, 40 insertions(+), 10 deletions(-) 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'],