From 9871679c9c5d537cd2c1c7db959b8f73957100cd Mon Sep 17 00:00:00 2001 From: Harshul Rathod Date: Fri, 25 Sep 2026 23:44:17 +0530 Subject: [PATCH 1/4] fix(app): keep search tile rows on screen while a dashboard refreshes --- .changeset/search-tile-refresh-pulse.md | 9 ++ packages/app/src/DBDashboardPage.tsx | 1 + packages/app/src/components/DBRowTable.tsx | 44 ++++-- .../components/DBSqlRowTableWithSidebar.tsx | 4 + .../__tests__/DBSqlRowTable.test.tsx | 127 ++++++++++++++++++ 5 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 .changeset/search-tile-refresh-pulse.md create mode 100644 packages/app/src/components/__tests__/DBSqlRowTable.test.tsx diff --git a/.changeset/search-tile-refresh-pulse.md b/.changeset/search-tile-refresh-pulse.md new file mode 100644 index 0000000000..c968e791d2 --- /dev/null +++ b/.changeset/search-tile-refresh-pulse.md @@ -0,0 +1,9 @@ +--- +'@hyperdx/app': patch +--- + +fix: keep search tile rows on screen while a dashboard refreshes + +During a refresh, search tiles dropped their rows and showed a loading state +until the new results arrived. They now keep the current rows on screen and +pulse while the refetch runs, like the other dashboard tiles. diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 81441a79f5..f27c714139 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -1416,6 +1416,7 @@ const Tile = ({ queryKeyPrefix={'search'} variant="default" errorVariant="collapsible" + keepPreviousData /> )} diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index 292ac9da58..9f44fc9a54 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -378,6 +378,7 @@ export const RawLogTable = memo( getRowWhere, variant = 'default', onRemoveColumn, + className, }: { wrapLines?: boolean; displayedColumns: string[]; @@ -421,6 +422,9 @@ export const RawLogTable = memo( getRowWhere?: (row: Record) => RowWhereResult; variant?: DBRowTableVariant; onRemoveColumn?: (column: string) => void; + // Extra classes for the scroll container, e.g. `effect-pulse` while the + // tile is refreshing. + className?: string; }) => { const dedupedRows = useMemo(() => { const lIds = new Set(); @@ -986,9 +990,13 @@ export const RawLogTable = memo( />
{ fetchMoreOnBottomReached(e.target as HTMLDivElement); @@ -1580,6 +1588,7 @@ function DBSqlRowTableComponent({ tableId, errorVariant, onResolvedColumnsChange, + keepPreviousData, }: { config: BuilderChartConfigWithDateRange; sourceId?: string; @@ -1613,6 +1622,8 @@ function DBSqlRowTableComponent({ tableId?: string; errorVariant?: ChartErrorStateVariant; onResolvedColumnsChange?: (meta: ColumnMetaType[]) => void; + /** Keep the current rows on screen while a refresh loads a new date range */ + keepPreviousData?: boolean; }) { const { data: me } = api.useMe(); const { toggleColumn, displayedColumns: contextDisplayedColumns } = @@ -1672,14 +1683,22 @@ function DBSqlRowTableComponent({ const mergedConfig = useConfigWithAdditionalSelect(mergedConfigObj, sourceId); - const { data, fetchNextPage, hasNextPage, isFetching, isError, error } = - useOffsetPaginatedQuery(mergedConfig ?? config, { - enabled: - enabled && mergedConfig != null && getSelectLength(config.select) > 0, - isLive, - queryKeyPrefix, - enableSmallFirstWindow, - }); + const { + data, + fetchNextPage, + hasNextPage, + isFetching, + isError, + error, + isPlaceholderData, + } = useOffsetPaginatedQuery(mergedConfig ?? config, { + enabled: + enabled && mergedConfig != null && getSelectLength(config.select) > 0, + isLive, + queryKeyPrefix, + enableSmallFirstWindow, + keepPreviousData, + }); // The first N columns are the select columns from the user // We can't use names as CH may rewrite the names @@ -1934,6 +1953,9 @@ function DBSqlRowTableComponent({ variant={variant} onRemoveColumn={toggleColumn ? onRemoveColumnFromTable : undefined} tableId={tableId} + className={ + keepPreviousData && isPlaceholderData ? 'effect-pulse' : undefined + } /> ); diff --git a/packages/app/src/components/DBSqlRowTableWithSidebar.tsx b/packages/app/src/components/DBSqlRowTableWithSidebar.tsx index 342e87e2ff..64e46a99ac 100644 --- a/packages/app/src/components/DBSqlRowTableWithSidebar.tsx +++ b/packages/app/src/components/DBSqlRowTableWithSidebar.tsx @@ -50,6 +50,8 @@ interface Props { selectionResetKey?: string; onSelectedRowsChange?: (hasSelectedRows: boolean) => void; onResolvedColumnsChange?: (meta: ColumnMetaType[]) => void; + /** Keep the current rows on screen while a refresh loads a new date range */ + keepPreviousData?: boolean; // Clicking outside the row side panel (and outside `keepOpenSelector`) closes // it. Enabled by default; pass `false` to opt out. closeOnClickOutside?: boolean; @@ -82,6 +84,7 @@ export default function DBSqlRowTableWithSideBar({ selectionResetKey, onSelectedRowsChange, onResolvedColumnsChange, + keepPreviousData, closeOnClickOutside = true, keepOpenSelector = DEFAULT_KEEP_OPEN_SELECTOR, }: Props) { @@ -156,6 +159,7 @@ export default function DBSqlRowTableWithSideBar({ selectionResetKey={selectionResetKey} onSelectedRowsChange={onSelectedRowsChange} onResolvedColumnsChange={onResolvedColumnsChange} + keepPreviousData={keepPreviousData} /> ); diff --git a/packages/app/src/components/__tests__/DBSqlRowTable.test.tsx b/packages/app/src/components/__tests__/DBSqlRowTable.test.tsx new file mode 100644 index 0000000000..fc989c07cf --- /dev/null +++ b/packages/app/src/components/__tests__/DBSqlRowTable.test.tsx @@ -0,0 +1,127 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { screen } from '@testing-library/react'; + +import { DBSqlRowTable } from '@/components/DBRowTable'; + +const mockUseOffsetPaginatedQuery = jest.fn(); +jest.mock('@/hooks/useOffsetPaginatedQuery', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseOffsetPaginatedQuery(...args), +})); + +jest.mock('@/api', () => ({ + __esModule: true, + default: { useMe: () => ({ data: { team: {} } }) }, +})); + +jest.mock('@/hooks/useMetadata', () => ({ + ...jest.requireActual('@/hooks/useMetadata'), + useColumns: () => ({ data: undefined }), + useTableMetadata: () => ({ + data: { primary_key: 'Timestamp', partition_key: 'toDate(Timestamp)' }, + }), +})); + +jest.mock('@/hooks/usePatterns', () => ({ + ...jest.requireActual('@/hooks/usePatterns'), + useGroupedPatterns: () => ({ data: {}, isLoading: false, miner: null }), +})); + +jest.mock('@/source', () => ({ + ...jest.requireActual('@/source'), + useSource: () => ({ data: undefined }), +})); + +jest.mock('@/hooks/useChartConfig', () => ({ + ...jest.requireActual('@/hooks/useChartConfig'), + useAliasMapFromChartConfig: () => ({ data: {}, isLoading: false }), + useRenderedSqlChartConfig: () => ({ data: '', isLoading: false }), +})); + +const config: React.ComponentProps['config'] = { + select: 'Body', + from: { databaseName: 'default', tableName: 'otel_logs' }, + where: '', + timestampValueExpression: 'Timestamp', + connection: 'test-connection', + dateRange: [ + new Date('2026-07-06T00:00:00Z'), + new Date('2026-07-06T01:00:00Z'), + ], +}; + +function mockQueryResult({ + isPlaceholderData, +}: { + isPlaceholderData: boolean; +}) { + mockUseOffsetPaginatedQuery.mockReturnValue({ + data: { + data: [{ Body: 'previous row' }], + meta: [{ name: 'Body', type: 'String' }], + window: undefined, + }, + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetching: isPlaceholderData, + isError: false, + error: null, + isPlaceholderData, + }); +} + +function renderTable( + props: Partial>, +) { + return renderWithMantine( + + + , + ); +} + +describe('DBSqlRowTable refresh', () => { + beforeEach(() => { + mockUseOffsetPaginatedQuery.mockReset(); + }); + + it('asks the query to keep the previous rows when keepPreviousData is set', () => { + mockQueryResult({ isPlaceholderData: false }); + + renderTable({ keepPreviousData: true }); + + expect(mockUseOffsetPaginatedQuery.mock.calls[0][1]).toEqual( + expect.objectContaining({ keepPreviousData: true }), + ); + }); + + it('pulses the previous rows while a refresh loads', () => { + mockQueryResult({ isPlaceholderData: true }); + + renderTable({ keepPreviousData: true }); + + expect(screen.getByTestId('search-results-table')).toHaveClass( + 'effect-pulse', + ); + }); + + it('does not pulse once fresh rows have loaded', () => { + mockQueryResult({ isPlaceholderData: false }); + + renderTable({ keepPreviousData: true }); + + expect(screen.getByTestId('search-results-table')).not.toHaveClass( + 'effect-pulse', + ); + }); + + it('does not pulse without keepPreviousData (e.g. live tail on the search page)', () => { + mockQueryResult({ isPlaceholderData: true }); + + renderTable({ isLive: true }); + + expect(screen.getByTestId('search-results-table')).not.toHaveClass( + 'effect-pulse', + ); + }); +}); From bb922e49b34070e55ae8244b5b3c56f69d91ea2d Mon Sep 17 00:00:00 2001 From: Harshul Rathod Date: Sat, 26 Sep 2026 02:45:47 +0530 Subject: [PATCH 2/4] fix(app): keep pattern tiles on screen while a dashboard refreshes --- .changeset/pattern-tile-refresh-pulse.md | 10 + packages/app/src/DBDashboardPage.tsx | 1 + packages/app/src/components/PatternTable.tsx | 10 + .../__tests__/PatternTable.test.tsx | 112 +++++++++++ .../src/hooks/__tests__/usePatterns.test.tsx | 176 ++++++++++++++++++ packages/app/src/hooks/usePatterns.tsx | 45 ++++- 6 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 .changeset/pattern-tile-refresh-pulse.md create mode 100644 packages/app/src/components/__tests__/PatternTable.test.tsx create mode 100644 packages/app/src/hooks/__tests__/usePatterns.test.tsx diff --git a/.changeset/pattern-tile-refresh-pulse.md b/.changeset/pattern-tile-refresh-pulse.md new file mode 100644 index 0000000000..88789d75b7 --- /dev/null +++ b/.changeset/pattern-tile-refresh-pulse.md @@ -0,0 +1,10 @@ +--- +'@hyperdx/app': patch +--- + +fix: keep pattern tiles on screen while a dashboard refreshes + +During a refresh, event pattern tiles dropped their patterns and showed a +loading state until the new sample was mined. They now keep the current +patterns on screen and pulse while the refetch runs, like the other dashboard +tiles. diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index f27c714139..6675cb5c30 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -1468,6 +1468,7 @@ const Tile = ({ granularity: undefined, }} totalCountQueryKeyPrefix={`dashboard-patterns-${chart.id}`} + keepPreviousData /> )} diff --git a/packages/app/src/components/PatternTable.tsx b/packages/app/src/components/PatternTable.tsx index bbecce94ab..5ea9e39e25 100644 --- a/packages/app/src/components/PatternTable.tsx +++ b/packages/app/src/components/PatternTable.tsx @@ -31,6 +31,7 @@ export default function PatternTable({ onDraftPatternColumnChange: externalOnDraftPatternColumnChange, onSubmit: externalOnSubmit, source, + keepPreviousData, }: { config: BuilderChartConfigWithDateRange; totalCountConfig: BuilderChartConfigWithDateRange; @@ -41,6 +42,8 @@ export default function PatternTable({ onSubmit?: () => void; totalCountQueryKeyPrefix: string; source?: TSource; + /** Keep the current patterns on screen while a refresh loads a new date range */ + keepPreviousData?: boolean; }) { const SAMPLES = 10_000; @@ -69,6 +72,7 @@ export default function PatternTable({ const { data: groupedResults, isLoading: isGroupedPatternsLoading, + isPlaceholderData: isPatternsPlaceholderData, error: groupedPatternsError, patternQueryConfig, } = useGroupedPatterns({ @@ -81,6 +85,7 @@ export default function PatternTable({ source.serviceNameExpression) || '', totalCount, + keepPreviousData, }); const isLoading = @@ -143,6 +148,11 @@ export default function PatternTable({ isLive={false} wrapLines={true} isLoading={isLoading} + className={ + keepPreviousData && isPatternsPlaceholderData + ? 'effect-pulse' + : undefined + } rows={sortedGroupedResults ?? []} displayedColumns={[ '__hdx_pattern_trend', diff --git a/packages/app/src/components/__tests__/PatternTable.test.tsx b/packages/app/src/components/__tests__/PatternTable.test.tsx new file mode 100644 index 0000000000..bc6ac570ae --- /dev/null +++ b/packages/app/src/components/__tests__/PatternTable.test.tsx @@ -0,0 +1,112 @@ +import { screen } from '@testing-library/react'; + +import PatternTable from '@/components/PatternTable'; + +const mockUseGroupedPatterns = jest.fn(); +jest.mock('@/hooks/usePatterns', () => ({ + ...jest.requireActual('@/hooks/usePatterns'), + useGroupedPatterns: (args: unknown) => mockUseGroupedPatterns(args), +})); + +jest.mock('@/components/SearchTotalCountChart', () => ({ + ...jest.requireActual('@/components/SearchTotalCountChart'), + useSearchTotalCount: () => ({ + isLoading: false, + isTotalCountComplete: true, + totalCount: 5, + error: null, + }), +})); + +jest.mock('@/hooks/useChartConfig', () => ({ + ...jest.requireActual('@/hooks/useChartConfig'), + useAliasMapFromChartConfig: () => ({ data: {}, isLoading: false }), +})); + +const config: React.ComponentProps['config'] = { + select: '', + from: { databaseName: 'default', tableName: 'otel_logs' }, + where: '', + timestampValueExpression: 'Timestamp', + connection: 'test-connection', + dateRange: [ + new Date('2026-07-06T00:00:00Z'), + new Date('2026-07-06T01:00:00Z'), + ], +}; + +function mockPatterns({ isPlaceholderData }: { isPlaceholderData: boolean }) { + mockUseGroupedPatterns.mockReturnValue({ + data: { + '1': { + id: '1', + pattern: 'GET <*> 200', + count: 5, + countStr: '~5', + samples: [], + }, + }, + isLoading: false, + isPlaceholderData, + error: null, + patternQueryConfig: undefined, + }); +} + +function renderTable(props: { keepPreviousData?: boolean }) { + return renderWithMantine( + , + ); +} + +describe('PatternTable refresh', () => { + beforeEach(() => { + mockUseGroupedPatterns.mockReset(); + }); + + it('asks for the previous patterns to be kept when keepPreviousData is set', () => { + mockPatterns({ isPlaceholderData: false }); + + renderTable({ keepPreviousData: true }); + + expect(mockUseGroupedPatterns.mock.calls[0][0]).toEqual( + expect.objectContaining({ keepPreviousData: true }), + ); + }); + + it('pulses the previous patterns while a refresh loads', () => { + mockPatterns({ isPlaceholderData: true }); + + renderTable({ keepPreviousData: true }); + + expect(screen.getByTestId('search-results-table')).toHaveClass( + 'effect-pulse', + ); + }); + + it('does not pulse once fresh patterns have loaded', () => { + mockPatterns({ isPlaceholderData: false }); + + renderTable({ keepPreviousData: true }); + + expect(screen.getByTestId('search-results-table')).not.toHaveClass( + 'effect-pulse', + ); + }); + + it('does not pulse without keepPreviousData (e.g. the search page)', () => { + mockPatterns({ isPlaceholderData: true }); + + renderTable({}); + + expect(screen.getByTestId('search-results-table')).not.toHaveClass( + 'effect-pulse', + ); + }); +}); diff --git a/packages/app/src/hooks/__tests__/usePatterns.test.tsx b/packages/app/src/hooks/__tests__/usePatterns.test.tsx new file mode 100644 index 0000000000..e165e4bc79 --- /dev/null +++ b/packages/app/src/hooks/__tests__/usePatterns.test.tsx @@ -0,0 +1,176 @@ +import { renderHook } from '@testing-library/react'; + +import { + differsOnlyInDateRange, + PATTERN_COLUMN_ALIAS, + TIMESTAMP_COLUMN_ALIAS, + useGroupedPatterns, +} from '@/hooks/usePatterns'; + +// Pyodide and the pattern miner run through react-query; route each query by +// its key so the hook sees the results a real refresh would produce. +const mockUseQuery = jest.fn(); +jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + useQuery: (options: unknown) => mockUseQuery(options), +})); + +jest.mock('@/hooks/useChartConfig', () => ({ + useQueriedChartConfig: () => ({ + data: { data: [], meta: [] }, + isLoading: false, + error: null, + }), +})); + +jest.mock('@/components/DBRowTable', () => ({ + useConfigWithAdditionalSelect: (config: unknown) => config, +})); + +const HOUR = 60 * 60 * 1000; +const T0 = new Date('2026-07-06T00:00:00Z').getTime(); + +function configFor(fromMs: number, where = '') { + const config: Parameters[0]['config'] = { + select: '', + from: { databaseName: 'default', tableName: 'otel_logs' }, + where, + timestampValueExpression: 'Timestamp', + connection: 'test-connection', + dateRange: [new Date(fromMs), new Date(fromMs + 2 * HOUR)], + }; + return config; +} + +// Mined sample rows from the first range. +const minedResults = { + data: [ + { + [PATTERN_COLUMN_ALIAS]: 'GET /api/users 200', + [TIMESTAMP_COLUMN_ALIAS]: new Date(T0 + 10 * 60 * 1000).toISOString(), + __hdx_patternId: 1, + __hdx_pattern: 'GET <*> 200', + }, + ], + miner: null, +}; + +function mockMining({ isPlaceholderData }: { isPlaceholderData: boolean }) { + mockUseQuery.mockImplementation( + (options: { queryKey: readonly unknown[] }) => + options.queryKey[0] === 'patterns' + ? { + data: minedResults, + isLoading: false, + isPlaceholderData, + error: null, + } + : { data: {}, isLoading: false, error: null }, + ); +} + +function patternsQueryOptions() { + return mockUseQuery.mock.calls + .map(([options]) => options) + .filter(options => options.queryKey[0] === 'patterns') + .at(-1); +} + +const baseArgs = { + samples: 100, + bodyValueExpression: 'Body', + totalCount: 1, +}; + +describe('differsOnlyInDateRange', () => { + const key = ['patterns', configFor(T0), 'Body']; + + it('is true when only the date range changed', () => { + expect( + differsOnlyInDateRange(['patterns', configFor(T0 + HOUR), 'Body'], key), + ).toBe(true); + }); + + it('is false when anything else changed', () => { + expect( + differsOnlyInDateRange( + ['patterns', configFor(T0, 'level:error'), 'Body'], + key, + ), + ).toBe(false); + expect( + differsOnlyInDateRange(['patterns', configFor(T0), 'Message'], key), + ).toBe(false); + }); + + it('is false without a previous key', () => { + expect(differsOnlyInDateRange(undefined, key)).toBe(false); + }); +}); + +describe('useGroupedPatterns keepPreviousData', () => { + beforeEach(() => { + mockUseQuery.mockReset(); + }); + + it('keeps the previous patterns only across a date-range change', () => { + mockMining({ isPlaceholderData: false }); + renderHook(() => + useGroupedPatterns({ + ...baseArgs, + config: configFor(T0), + keepPreviousData: true, + }), + ); + + const { placeholderData } = patternsQueryOptions(); + const previous = { data: [] }; + const previousQuery = (config: ReturnType) => ({ + queryKey: ['patterns', config, 'Body'], + }); + expect(placeholderData(previous, previousQuery(configFor(T0 - HOUR)))).toBe( + previous, + ); + expect( + placeholderData(previous, previousQuery(configFor(T0, 'level:error'))), + ).toBeUndefined(); + }); + + it('does not keep previous patterns unless asked', () => { + mockMining({ isPlaceholderData: false }); + renderHook(() => + useGroupedPatterns({ ...baseArgs, config: configFor(T0) }), + ); + + const { placeholderData } = patternsQueryOptions(); + expect( + placeholderData( + { data: [] }, + { queryKey: ['patterns', configFor(T0 - HOUR), 'Body'] }, + ), + ).toBeUndefined(); + }); + + it('keeps the groups from the previous range while its patterns are a placeholder', () => { + mockMining({ isPlaceholderData: false }); + const { result, rerender } = renderHook( + ({ fromMs }) => + useGroupedPatterns({ + ...baseArgs, + config: configFor(fromMs), + keepPreviousData: true, + }), + { initialProps: { fromMs: T0 } }, + ); + const settled = result.current.data; + expect(Object.keys(settled)).toHaveLength(1); + + // The refresh moves to a later range while mining still shows the + // previous range's patterns. + mockMining({ isPlaceholderData: true }); + rerender({ fromMs: T0 + 3 * HOUR }); + + expect(result.current.isPlaceholderData).toBe(true); + expect(result.current.data).toBe(settled); + }); +}); diff --git a/packages/app/src/hooks/usePatterns.tsx b/packages/app/src/hooks/usePatterns.tsx index ece24e70cd..3ebd29217d 100644 --- a/packages/app/src/hooks/usePatterns.tsx +++ b/packages/app/src/hooks/usePatterns.tsx @@ -1,4 +1,5 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; +import { isEqual, omit } from 'lodash'; import stripAnsi from 'strip-ansi'; import { convertDateRangeToGranularityString } from '@hyperdx/common-utils/dist/core/utils'; import { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; @@ -125,6 +126,20 @@ export type Pattern = { samples: SampleLog[]; }; +const withoutDateRange = (part: unknown) => + typeof part === 'object' && part !== null ? omit(part, ['dateRange']) : part; + +/** Whether two query keys differ in nothing but their config's date range. */ +export function differsOnlyInDateRange( + prevKey: readonly unknown[] | undefined, + key: readonly unknown[], +) { + return ( + prevKey != null && + isEqual(prevKey.map(withoutDateRange), key.map(withoutDateRange)) + ); +} + function usePatterns({ config, samples, @@ -132,6 +147,7 @@ function usePatterns({ levelExpression, serviceNameExpression, enabled = true, + keepPreviousData = false, }: { config: BuilderChartConfigWithDateRange; samples: number; @@ -139,6 +155,8 @@ function usePatterns({ levelExpression?: string; serviceNameExpression?: string; enabled?: boolean; + /** Keep showing the previous patterns while the same query loads a new date range */ + keepPreviousData?: boolean; }) { const configWithPrimaryAndPartitionKey = useConfigWithAdditionalSelect({ ...config, @@ -173,8 +191,15 @@ function usePatterns({ error: pyodideError, } = usePyodide({ enabled }); + const queryKey = ['patterns', config, bodyValueExpression]; const query = useQuery({ - queryKey: ['patterns', config, bodyValueExpression], + queryKey, + // Mining waits for the new sample (see `enabled`), so a refresh never + // caches the previous range's patterns under the new key. + placeholderData: (prev, prevQuery) => + keepPreviousData && differsOnlyInDateRange(prevQuery?.queryKey, queryKey) + ? prev + : undefined, queryFn: () => { if (configWithPrimaryAndPartitionKey == null) { throw new Error('Unexpected configWithPrimaryAndPartitionKey is null'); @@ -228,6 +253,7 @@ export function useGroupedPatterns({ serviceNameExpression, totalCount, enabled = true, + keepPreviousData = false, }: { config: BuilderChartConfigWithDateRange; samples: number; @@ -236,10 +262,13 @@ export function useGroupedPatterns({ serviceNameExpression?: string; totalCount?: number; enabled?: boolean; + /** Keep showing the previous patterns while the same query loads a new date range */ + keepPreviousData?: boolean; }) { const { data: results, isLoading, + isPlaceholderData, error, patternQueryConfig, } = usePatterns({ @@ -249,6 +278,7 @@ export function useGroupedPatterns({ levelExpression, serviceNameExpression, enabled, + keepPreviousData, }); const sampledRowCount = results?.data.length; @@ -330,9 +360,18 @@ export function useGroupedPatterns({ config.dateRange, ]); + // While the previous patterns are a placeholder, keep the groups as they + // were last computed. Regrouping them on the new range would redraw each + // trend against time buckets its samples weren't drawn from. + const [settledGroups, setSettledGroups] = useState(groupedResults); + if (!isPlaceholderData && settledGroups !== groupedResults) { + setSettledGroups(groupedResults); + } + return { - data: groupedResults, + data: isPlaceholderData ? settledGroups : groupedResults, isLoading, + isPlaceholderData, error, miner: results?.miner, sampledRowCount, From f16d03dd83b8002ca00413f063d4a51bcf849810 Mon Sep 17 00:00:00 2001 From: Harshul Rathod Date: Sat, 26 Sep 2026 03:06:26 +0530 Subject: [PATCH 3/4] fix(app): keep pattern groups stable and hide the loading footer while refreshing --- packages/app/src/components/PatternTable.tsx | 10 +++--- .../__tests__/PatternTable.test.tsx | 27 ++++++++++++++-- .../src/hooks/__tests__/usePatterns.test.tsx | 19 +++++++++++ packages/app/src/hooks/usePatterns.tsx | 32 +++++++++++-------- 4 files changed, 66 insertions(+), 22 deletions(-) diff --git a/packages/app/src/components/PatternTable.tsx b/packages/app/src/components/PatternTable.tsx index 5ea9e39e25..eefb675dac 100644 --- a/packages/app/src/components/PatternTable.tsx +++ b/packages/app/src/components/PatternTable.tsx @@ -90,6 +90,7 @@ export default function PatternTable({ const isLoading = isTotalCountLoading || !isTotalCountComplete || isGroupedPatternsLoading; + const isRefreshing = keepPreviousData && isPatternsPlaceholderData; const error = totalCountError || groupedPatternsError; @@ -147,12 +148,9 @@ export default function PatternTable({ ['config'] = { ], }; -function mockPatterns({ isPlaceholderData }: { isPlaceholderData: boolean }) { +function mockPatterns({ + isPlaceholderData, + isLoading = false, +}: { + isPlaceholderData: boolean; + isLoading?: boolean; +}) { mockUseGroupedPatterns.mockReturnValue({ data: { '1': { @@ -46,7 +52,7 @@ function mockPatterns({ isPlaceholderData }: { isPlaceholderData: boolean }) { samples: [], }, }, - isLoading: false, + isLoading, isPlaceholderData, error: null, patternQueryConfig: undefined, @@ -90,6 +96,23 @@ describe('PatternTable refresh', () => { ); }); + it('does not show the loading footer under the previous patterns', () => { + // The new sample is still loading behind the placeholder patterns. + mockPatterns({ isPlaceholderData: true, isLoading: true }); + + renderTable({ keepPreviousData: true }); + + expect(screen.queryByText(/Loading results/)).not.toBeInTheDocument(); + }); + + it('shows the loading footer while patterns load without a placeholder', () => { + mockPatterns({ isPlaceholderData: false, isLoading: true }); + + renderTable({ keepPreviousData: true }); + + expect(screen.getByText(/Loading results/)).toBeInTheDocument(); + }); + it('does not pulse once fresh patterns have loaded', () => { mockPatterns({ isPlaceholderData: false }); diff --git a/packages/app/src/hooks/__tests__/usePatterns.test.tsx b/packages/app/src/hooks/__tests__/usePatterns.test.tsx index e165e4bc79..5a311a14e6 100644 --- a/packages/app/src/hooks/__tests__/usePatterns.test.tsx +++ b/packages/app/src/hooks/__tests__/usePatterns.test.tsx @@ -173,4 +173,23 @@ describe('useGroupedPatterns keepPreviousData', () => { expect(result.current.isPlaceholderData).toBe(true); expect(result.current.data).toBe(settled); }); + + it('keeps the same groups when re-rendered with an equal config', () => { + mockMining({ isPlaceholderData: false }); + const { result, rerender } = renderHook( + ({ fromMs }) => + useGroupedPatterns({ + ...baseArgs, + config: configFor(fromMs), + keepPreviousData: true, + }), + { initialProps: { fromMs: T0 } }, + ); + const groups = result.current.data; + + // A parent re-render builds the same range from new Date objects. + rerender({ fromMs: T0 }); + + expect(result.current.data).toBe(groups); + }); }); diff --git a/packages/app/src/hooks/usePatterns.tsx b/packages/app/src/hooks/usePatterns.tsx index 3ebd29217d..1eccd0106b 100644 --- a/packages/app/src/hooks/usePatterns.tsx +++ b/packages/app/src/hooks/usePatterns.tsx @@ -129,7 +129,10 @@ export type Pattern = { const withoutDateRange = (part: unknown) => typeof part === 'object' && part !== null ? omit(part, ['dateRange']) : part; -/** Whether two query keys differ in nothing but their config's date range. */ +/** + * Whether two query keys differ in nothing but their config's date range. + * Only a top-level `dateRange` on each key part is ignored. + */ export function differsOnlyInDateRange( prevKey: readonly unknown[] | undefined, key: readonly unknown[], @@ -286,11 +289,18 @@ export function useGroupedPatterns({ return totalCount && sampledRowCount ? totalCount / sampledRowCount : 1; }, [totalCount, sampledRowCount]); - const granularity = convertDateRangeToGranularityString(config.dateRange, 24); - const timeRangeBuckets = timeBucketByGranularity( - config.dateRange[0], - config.dateRange[1], - granularity, + // Keyed on the range's times, so an equal range built from new Date objects + // keeps `groupedResults` stable and the settled groups below don't loop. + const startTime = config.dateRange[0].getTime(); + const endTime = config.dateRange[1].getTime(); + const dateRange = useMemo<[Date, Date]>( + () => [new Date(startTime), new Date(endTime)], + [startTime, endTime], + ); + const granularity = convertDateRangeToGranularityString(dateRange, 24); + const timeRangeBuckets = useMemo( + () => timeBucketByGranularity(dateRange[0], dateRange[1], granularity), + [dateRange, granularity], ); // TODO: Group by pattern and other select attributes @@ -346,19 +356,13 @@ export function useGroupedPatterns({ count: Math.round(count * sampleMultiplier), })), granularity, - dateRange: config.dateRange, + dateRange, }, }; }); return fullPatternGroups; - }, [ - results, - granularity, - sampleMultiplier, - timeRangeBuckets, - config.dateRange, - ]); + }, [results, granularity, sampleMultiplier, timeRangeBuckets, dateRange]); // While the previous patterns are a placeholder, keep the groups as they // were last computed. Regrouping them on the new range would redraw each From eb8f771ad3cda6a6aa2434d201f9252d6d121e92 Mon Sep 17 00:00:00 2001 From: Harshul Rathod Date: Sat, 26 Sep 2026 03:21:06 +0530 Subject: [PATCH 4/4] fix(app): drop the redundant keepPreviousData check in the pattern table --- packages/app/src/components/PatternTable.tsx | 4 ++-- .../app/src/components/__tests__/PatternTable.test.tsx | 10 ---------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/PatternTable.tsx b/packages/app/src/components/PatternTable.tsx index eefb675dac..c320da22b8 100644 --- a/packages/app/src/components/PatternTable.tsx +++ b/packages/app/src/components/PatternTable.tsx @@ -72,7 +72,8 @@ export default function PatternTable({ const { data: groupedResults, isLoading: isGroupedPatternsLoading, - isPlaceholderData: isPatternsPlaceholderData, + // Only true with `keepPreviousData`, while the previous patterns are shown + isPlaceholderData: isRefreshing, error: groupedPatternsError, patternQueryConfig, } = useGroupedPatterns({ @@ -90,7 +91,6 @@ export default function PatternTable({ const isLoading = isTotalCountLoading || !isTotalCountComplete || isGroupedPatternsLoading; - const isRefreshing = keepPreviousData && isPatternsPlaceholderData; const error = totalCountError || groupedPatternsError; diff --git a/packages/app/src/components/__tests__/PatternTable.test.tsx b/packages/app/src/components/__tests__/PatternTable.test.tsx index f366b67875..ca52965349 100644 --- a/packages/app/src/components/__tests__/PatternTable.test.tsx +++ b/packages/app/src/components/__tests__/PatternTable.test.tsx @@ -122,14 +122,4 @@ describe('PatternTable refresh', () => { 'effect-pulse', ); }); - - it('does not pulse without keepPreviousData (e.g. the search page)', () => { - mockPatterns({ isPlaceholderData: true }); - - renderTable({}); - - expect(screen.getByTestId('search-results-table')).not.toHaveClass( - 'effect-pulse', - ); - }); });