From 409f911a57d2dae62b80b1cc30d0e7eb62f61866 Mon Sep 17 00:00:00 2001 From: Bilal Atique Date: Thu, 5 Nov 2026 18:53:02 +0500 Subject: [PATCH] fix(app): run the row side panel query once per opened row Opening a row ran the same SELECT * query twice. DBRowSidePanel shows a loader until its useRowData lookup settles, then mounts the Overview or Column Values tab, which calls useRowData with the same query key. The app's QueryClient uses staleTime 0, so the tab's new observer refetched the row that had just loaded. The row lookups now keep a found row fresh, so the tab and later tab switches read it from the cache. A different row, source or query settings is a different key and still runs. A row that was not found stays stale, so the next lookup retries it. Closes #1400 --- .changeset/row-side-panel-single-fetch.md | 11 ++ .../app/src/components/DBRowDataPanel.tsx | 12 ++ .../DBRowDataPanel.rowQuery.test.tsx | 166 ++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 .changeset/row-side-panel-single-fetch.md create mode 100644 packages/app/src/components/__tests__/DBRowDataPanel.rowQuery.test.tsx diff --git a/.changeset/row-side-panel-single-fetch.md b/.changeset/row-side-panel-single-fetch.md new file mode 100644 index 0000000000..581ad0c286 --- /dev/null +++ b/.changeset/row-side-panel-single-fetch.md @@ -0,0 +1,11 @@ +--- +'@hyperdx/app': patch +--- + +fix: run the row details query once when opening a row + +The side panel ran the same `SELECT *` row query twice: once for the panel, and +again when its Overview or Column Values tab mounted. A row that was found is +now reused from the cache, so the tab and later tab switches do not query it +again. A different row or source still runs the query, and a row that was not +found is looked up again. diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx index 35053b2b2f..5a3803b327 100644 --- a/packages/app/src/components/DBRowDataPanel.tsx +++ b/packages/app/src/components/DBRowDataPanel.tsx @@ -9,6 +9,7 @@ import { TSource, } from '@hyperdx/common-utils/dist/types'; import { Box } from '@mantine/core'; +import type { Query } from '@tanstack/react-query'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { @@ -23,6 +24,7 @@ import { getDurationMsExpression, getEventBody, } from '@/source'; +import type { ChartQueryResult } from '@/types'; import { getSelectExpressionsForHighlightedAttributes } from '@/utils/highlightedAttributes'; import { getTimestampValueSelects } from '@/utils/rowTimestamps'; @@ -48,6 +50,14 @@ export enum ROW_DATA_ALIASES { EVENT_NAME = '__hdx_event_name', } +// The side panel mounts its tabs once its own lookup settles, and each tab +// looks the same row up again. Those lookups reuse a found row instead of +// running `SELECT *` again. A miss stays stale so the next lookup retries it: +// the row may not have been ingested yet. +function rowLookupStaleTime(query: Query) { + return query.state.data?.data.length ? Infinity : 0; +} + export function useRowData({ source, rowId, @@ -289,6 +299,7 @@ export function useRowData({ queryKey: [...baseQueryKey, dateRange], enabled: rowId != null && hasWindow && !isEventNameColumnPending, additionalQuerySettings, + staleTime: rowLookupStaleTime, }, ); @@ -310,6 +321,7 @@ export function useRowData({ queryKey: [...baseQueryKey, undefined], enabled: rowId != null && isFallbackActive && !isEventNameColumnPending, additionalQuerySettings, + staleTime: rowLookupStaleTime, }); const queryResult = isFallbackActive ? fallbackResult : boundedResult; diff --git a/packages/app/src/components/__tests__/DBRowDataPanel.rowQuery.test.tsx b/packages/app/src/components/__tests__/DBRowDataPanel.rowQuery.test.tsx new file mode 100644 index 0000000000..cff83b9b58 --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowDataPanel.rowQuery.test.tsx @@ -0,0 +1,166 @@ +import type { ResponseJSON } from '@hyperdx/common-utils/dist/clickhouse'; +import { SourceKind, TLogSource } from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; + +import { RowDataPanel, useRowData } from '@/components/DBRowDataPanel'; + +const mockQueryChartConfig = jest.fn(); +jest.mock('@/clickhouse', () => ({ + useClickhouseClient: () => ({ queryChartConfig: mockQueryChartConfig }), +})); +jest.mock('@/hooks/useMetadata', () => ({ + useColumns: () => ({ data: undefined, isLoading: false }), + useMetadataWithSettings: () => ({}), +})); +jest.mock('@/hooks/useMVOptimizationExplanation', () => ({ + useMVOptimizationExplanation: () => ({ data: undefined, isLoading: false }), +})); +jest.mock('@/source', () => ({ + ...jest.requireActual('@/source'), + useSource: () => ({ data: undefined, isLoading: false }), +})); +// Records the row the tab renders. +const mockJsonViewerData = jest.fn(); +jest.mock('@/components/DBRowJsonViewer', () => ({ + DBRowJsonViewer: ({ data }: { data: unknown }) => { + mockJsonViewerData(data); + return null; + }, +})); + +const source: TLogSource = { + id: 'source-id', + kind: SourceKind.Log, + name: 'logs', + connection: 'conn-id', + from: { databaseName: 'default', tableName: 'logs' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, Body', + bodyExpression: 'Body', +}; + +const DATE_RANGE: [Date, Date] = [ + new Date('2024-01-01T00:00:00Z'), + new Date('2024-01-01T02:00:00Z'), +]; + +const found: ResponseJSON> = { + data: [{ Body: 'hello' }], + meta: [{ name: 'Body', type: 'String' }], + rows: 1, +}; + +type PanelProps = { + source: TLogSource; + rowId: string; + dateRange?: [Date, Date]; +}; + +// Mirrors DBRowSidePanel: the panel shows "Loading..." until its own lookup of +// the row settles, then mounts the tab, which looks up the same row again. +function SidePanel(props: PanelProps) { + const { isLoading } = useRowData(props); + if (isLoading) { + return
Loading...
; + } + return ; +} + +describe('row lookup', () => { + // The app's QueryClient uses React Query's defaults (pages/_app.tsx). + let queryClient: QueryClient; + + function renderPanel(props: PanelProps) { + const tree = (panelProps: PanelProps) => ( + + + + + + ); + const result = render(tree(props)); + return { + rerender: (next: PanelProps) => result.rerender(tree(next)), + unmount: result.unmount, + }; + } + + async function settled() { + await screen.findByTestId('tab'); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + } + + beforeEach(() => { + queryClient = new QueryClient(); + mockQueryChartConfig.mockReset().mockResolvedValue(found); + mockJsonViewerData.mockReset(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it.each<{ lookup: string; dateRange?: [Date, Date] }>([ + { lookup: 'unbounded' }, + { lookup: 'bounded', dateRange: DATE_RANGE }, + ])( + 'runs the $lookup lookup once for the panel and its tab', + async ({ dateRange }) => { + renderPanel({ source, rowId: "id='a'", dateRange }); + await settled(); + + expect(mockQueryChartConfig).toHaveBeenCalledTimes(1); + expect(mockJsonViewerData).toHaveBeenLastCalledWith({ Body: 'hello' }); + }, + ); + + it('reuses a found row when the panel opens it again', async () => { + const first = renderPanel({ source, rowId: "id='a'" }); + await settled(); + first.unmount(); + + renderPanel({ source, rowId: "id='a'" }); + await settled(); + + expect(mockQueryChartConfig).toHaveBeenCalledTimes(1); + expect(mockJsonViewerData).toHaveBeenLastCalledWith({ Body: 'hello' }); + }); + + it('runs again for another row', async () => { + const { rerender } = renderPanel({ source, rowId: "id='a'" }); + await settled(); + rerender({ source, rowId: "id='b'" }); + await settled(); + + expect( + mockQueryChartConfig.mock.calls.map(([{ config }]) => config.where), + ).toEqual(["id='a'", "id='b'"]); + }); + + it('runs again for another source', async () => { + const { rerender } = renderPanel({ source, rowId: "id='a'" }); + await settled(); + rerender({ + source: { ...source, from: { ...source.from, tableName: 'logs_v2' } }, + rowId: "id='a'", + }); + await settled(); + + expect( + mockQueryChartConfig.mock.calls.map( + ([{ config }]) => config.from.tableName, + ), + ).toEqual(['logs', 'logs_v2']); + }); + + it('runs again for a row it did not find', async () => { + mockQueryChartConfig.mockResolvedValue({ data: [], meta: [], rows: 0 }); + + renderPanel({ source, rowId: "id='a'" }); + await settled(); + + expect(mockQueryChartConfig).toHaveBeenCalledTimes(2); + }); +});