From d5c334a5573e32eb87323769ae2b9e43ef659f55 Mon Sep 17 00:00:00 2001 From: Harshul Rathod Date: Fri, 25 Sep 2026 23:09:56 +0530 Subject: [PATCH 1/3] fix(app): keep heatmap tiles on screen while a dashboard refreshes --- .changeset/heatmap-tile-refresh-pulse.md | 9 + .../app/src/components/DBHeatmapChart.tsx | 36 +++- .../__tests__/DBHeatmapChart.render.test.tsx | 156 ++++++++++++++++++ 3 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 .changeset/heatmap-tile-refresh-pulse.md create mode 100644 packages/app/src/components/__tests__/DBHeatmapChart.render.test.tsx diff --git a/.changeset/heatmap-tile-refresh-pulse.md b/.changeset/heatmap-tile-refresh-pulse.md new file mode 100644 index 0000000000..a2a76ee8de --- /dev/null +++ b/.changeset/heatmap-tile-refresh-pulse.md @@ -0,0 +1,9 @@ +--- +'@hyperdx/app': patch +--- + +fix: keep heatmap tiles on screen while a dashboard refreshes + +During a refresh, heatmap tiles replaced the chart with a "Loading..." message +until the new data arrived. They now keep the current heatmap on screen and +pulse while the refetch runs, like the other dashboard tiles. diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index a752a45db0..cff3ead960 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; +import cx from 'classnames'; import type { Plugin } from 'uplot'; import uPlot from 'uplot'; import UplotReact from 'uplot-react'; @@ -756,10 +757,12 @@ function HeatmapContainer({ const { data: minMaxData, isLoading: isMinMaxLoading, + isPlaceholderData: isMinMaxPlaceholderData, error: minMaxError, } = useQueriedChartConfig(minMaxConfig, { queryKey: ['heatmap', minMaxConfig], enabled: enabled, + placeholderData: (prev: any) => prev, }); // UInt64 are returned as strings; quantile returns floats @@ -781,10 +784,23 @@ function HeatmapContainer({ nBuckets, }); - const { data, isLoading, error } = useQueriedChartConfig(bucketConfig, { - queryKey: ['heatmap_bucket', bucketConfig], - enabled: !!minMaxData && bucketConfig != null && max > effectiveMin, - }); + const { data, isLoading, isPlaceholderData, error } = useQueriedChartConfig( + bucketConfig, + { + queryKey: ['heatmap_bucket', bucketConfig], + // Wait for fresh bounds, so a refresh doesn't also query the new range + // bucketed with the previous range's min/max. + enabled: + !!minMaxData && + !isMinMaxPlaceholderData && + bucketConfig != null && + max > effectiveMin, + placeholderData: (prev: any) => prev, + }, + ); + // A refresh keeps the previous heatmap on screen and pulses until both + // queries have fresh data. + const isRefreshing = isMinMaxPlaceholderData || isPlaceholderData; // Memoize so timeBucketByGranularity's fresh Date[] doesn't defeat // the heatmapData memoization downstream. dateRange itself may be a @@ -855,13 +871,19 @@ function HeatmapContainer({ ) : _error ? ( ) : time.length < 2 || generatedTsBuckets?.length < 2 ? ( - + Not enough data points to render heatmap. Try expanding your search criteria. ) : ( void; @@ -1299,7 +1323,7 @@ function Heatmap({ return (
{ // Chromium fires a click event on mouseup even after a drag. diff --git a/packages/app/src/components/__tests__/DBHeatmapChart.render.test.tsx b/packages/app/src/components/__tests__/DBHeatmapChart.render.test.tsx new file mode 100644 index 0000000000..1d57acdd4d --- /dev/null +++ b/packages/app/src/components/__tests__/DBHeatmapChart.render.test.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from 'react'; +import { DisplayType } from '@hyperdx/common-utils/dist/types'; +import { screen, waitFor } from '@testing-library/react'; + +import DBHeatmapChart from '@/components/DBHeatmapChart'; + +// DBHeatmapChart's default export is a `dynamic()` wrapper; render its inner +// component once the loader resolves. +jest.mock('next/dynamic', () => ({ + __esModule: true, + default: (loader: () => Promise) => { + const Wrapped = (props: any) => { + const [Component, setComponent] = useState( + null, + ); + useEffect(() => { + let active = true; + loader().then((mod: any) => { + if (active) setComponent(() => mod?.default ?? mod); + }); + return () => { + active = false; + }; + }, []); + return Component ? : null; + }; + return Wrapped; + }, +})); + +jest.mock('uplot-react', () => ({ + __esModule: true, + default: () =>
, +})); + +const mockUseQueriedChartConfig = jest.fn(); +jest.mock('@/hooks/useChartConfig', () => ({ + useQueriedChartConfig: (...args: unknown[]) => + mockUseQueriedChartConfig(...args), +})); + +const T0 = new Date('2026-07-06T00:00:00Z'); +const T1 = new Date('2026-07-06T02:00:00Z'); + +const config: React.ComponentProps['config'] = { + displayType: DisplayType.Heatmap, + select: [{ aggFn: 'heatmap', valueExpression: 'Duration' }], + from: { databaseName: 'default', tableName: 'otel_traces' }, + where: '', + dateRange: [T0, T1], + granularity: 'auto', + timestampValueExpression: 'Timestamp', + connection: 'test-connection', +}; + +const bucketData = { + meta: [ + { name: '__hdx_time_bucket', type: 'DateTime' }, + { name: 'x_bucket', type: 'UInt32' }, + { name: 'count', type: 'UInt64' }, + ], + data: [ + { __hdx_time_bucket: '2026-07-06T00:00:00Z', x_bucket: 1, count: '5' }, + { __hdx_time_bucket: '2026-07-06T01:00:00Z', x_bucket: 2, count: '3' }, + ], +}; + +// The heatmap runs two queries: the bounds (`heatmap`) and the buckets +// (`heatmap_bucket`). Route each call to its own result. +function mockQueries({ + boundsPlaceholder, + bucketsPlaceholder, +}: { + boundsPlaceholder: boolean; + bucketsPlaceholder: boolean; +}) { + mockUseQueriedChartConfig.mockImplementation((_config, options) => + options?.queryKey?.includes('heatmap') + ? { + data: { data: [{ min: '1', max: '100' }], meta: [] }, + isLoading: false, + isPlaceholderData: boundsPlaceholder, + error: null, + } + : { + data: bucketData, + isLoading: false, + isPlaceholderData: bucketsPlaceholder, + error: null, + }, + ); +} + +function optionsFor(queryName: string) { + const call = mockUseQueriedChartConfig.mock.calls.find(([, options]) => + options?.queryKey?.includes(queryName), + ); + return call?.[1]; +} + +describe('DBHeatmapChart refresh', () => { + beforeEach(() => { + mockUseQueriedChartConfig.mockReset(); + }); + + it('keeps both queries on their previous data while a refresh loads', async () => { + mockQueries({ boundsPlaceholder: false, bucketsPlaceholder: false }); + + renderWithMantine(); + await screen.findByTestId('heatmap-plot'); + + const previous = { data: [] }; + expect(optionsFor('heatmap')?.placeholderData?.(previous)).toBe(previous); + expect(optionsFor('heatmap_bucket')?.placeholderData?.(previous)).toBe( + previous, + ); + }); + + it('keeps the previous heatmap on screen and pulses while a refresh loads', async () => { + mockQueries({ boundsPlaceholder: true, bucketsPlaceholder: true }); + + renderWithMantine(); + + const plot = await screen.findByTestId('heatmap-plot'); + expect(plot.closest('.heatmap-selection-container')).toHaveClass( + 'effect-pulse', + ); + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + it('does not pulse once fresh data has loaded', async () => { + mockQueries({ boundsPlaceholder: false, bucketsPlaceholder: false }); + + renderWithMantine(); + + const plot = await screen.findByTestId('heatmap-plot'); + expect(plot.closest('.heatmap-selection-container')).not.toHaveClass( + 'effect-pulse', + ); + }); + + it('holds the bucket query until the refreshed bounds arrive', async () => { + mockQueries({ boundsPlaceholder: true, bucketsPlaceholder: true }); + const { unmount } = renderWithMantine(); + await screen.findByTestId('heatmap-plot'); + expect(optionsFor('heatmap_bucket')?.enabled).toBe(false); + unmount(); + + mockUseQueriedChartConfig.mockReset(); + mockQueries({ boundsPlaceholder: false, bucketsPlaceholder: true }); + renderWithMantine(); + await waitFor(() => + expect(optionsFor('heatmap_bucket')?.enabled).toBe(true), + ); + }); +}); From bc462fca890f678ec73bd2705da931ce6b11e815 Mon Sep 17 00:00:00 2001 From: Harshul Rathod Date: Fri, 25 Sep 2026 23:53:18 +0530 Subject: [PATCH 2/3] fix(app): keep the settled heatmap on screen while it refreshes --- .../app/src/components/DBHeatmapChart.tsx | 37 ++-- .../__tests__/DBHeatmapChart.render.test.tsx | 175 ++++++++++++------ 2 files changed, 149 insertions(+), 63 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index cff3ead960..9ed302d58b 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -762,7 +762,7 @@ function HeatmapContainer({ } = useQueriedChartConfig(minMaxConfig, { queryKey: ['heatmap', minMaxConfig], enabled: enabled, - placeholderData: (prev: any) => prev, + placeholderData: prev => prev, }); // UInt64 are returned as strings; quantile returns floats @@ -784,18 +784,19 @@ function HeatmapContainer({ nBuckets, }); + const canQueryBuckets = + !!minMaxData && bucketConfig != null && max > effectiveMin; const { data, isLoading, isPlaceholderData, error } = useQueriedChartConfig( bucketConfig, { queryKey: ['heatmap_bucket', bucketConfig], // Wait for fresh bounds, so a refresh doesn't also query the new range // bucketed with the previous range's min/max. - enabled: - !!minMaxData && - !isMinMaxPlaceholderData && - bucketConfig != null && - max > effectiveMin, - placeholderData: (prev: any) => prev, + enabled: canQueryBuckets && !isMinMaxPlaceholderData, + // Only keep the previous buckets while this query can still run, so an + // empty refreshed range shows "Not enough data points" instead of + // pulsing on stale buckets. + placeholderData: canQueryBuckets ? prev => prev : undefined, }, ); // A refresh keeps the previous heatmap on screen and pulses until both @@ -834,7 +835,21 @@ function HeatmapContainer({ }); }, [data, generatedTsBuckets, scaleType, effectiveMin, max, nBuckets]); - const time = heatmapData[0]; + // While refreshing, keep drawing the last settled heatmap. The previous + // bucket rows only line up with the time buckets and bounds they were + // queried with, so re-plotting them on the new range would draw a blank or + // mis-scaled grid. + const currentView = useMemo( + () => ({ heatmapData, generatedTsBuckets, effectiveMin }), + [heatmapData, generatedTsBuckets, effectiveMin], + ); + const [settledView, setSettledView] = useState(currentView); + if (!isRefreshing && settledView !== currentView) { + setSettledView(currentView); + } + const view = isRefreshing ? settledView : currentView; + + const time = view.heatmapData[0]; const toolbarItemsMemo = useMemo(() => { const allToolbarItems: React.ReactNode[] = []; @@ -870,7 +885,7 @@ function HeatmapContainer({ ) : _error ? ( - ) : time.length < 2 || generatedTsBuckets?.length < 2 ? ( + ) : time.length < 2 || view.generatedTsBuckets.length < 2 ? ( ; + // DBHeatmapChart's default export is a `dynamic()` wrapper; render its inner // component once the loader resolves. jest.mock('next/dynamic', () => ({ __esModule: true, - default: (loader: () => Promise) => { - const Wrapped = (props: any) => { - const [Component, setComponent] = useState( - null, - ); + default: (loader: () => Promise>) => { + const Wrapped = (props: Props) => { + const [Component, setComponent] = + useState | null>(null); useEffect(() => { let active = true; - loader().then((mod: any) => { - if (active) setComponent(() => mod?.default ?? mod); + loader().then(mod => { + if (active) setComponent(() => mod); }); return () => { active = false; @@ -28,9 +30,14 @@ jest.mock('next/dynamic', () => ({ }, })); +// Heatmap passes uPlot `[[], [time, bucket, count]]`; record what it plots. +const mockPlot = jest.fn(); jest.mock('uplot-react', () => ({ __esModule: true, - default: () =>
, + default: ({ data }: { data: [unknown, number[][]] }) => { + mockPlot(data[1]); + return
; + }, })); const mockUseQueriedChartConfig = jest.fn(); @@ -39,20 +46,24 @@ jest.mock('@/hooks/useChartConfig', () => ({ mockUseQueriedChartConfig(...args), })); -const T0 = new Date('2026-07-06T00:00:00Z'); -const T1 = new Date('2026-07-06T02:00:00Z'); - -const config: React.ComponentProps['config'] = { - displayType: DisplayType.Heatmap, - select: [{ aggFn: 'heatmap', valueExpression: 'Duration' }], - from: { databaseName: 'default', tableName: 'otel_traces' }, - where: '', - dateRange: [T0, T1], - granularity: 'auto', - timestampValueExpression: 'Timestamp', - connection: 'test-connection', -}; +const HOUR = 60 * 60 * 1000; +const T0 = new Date('2026-07-06T00:00:00Z').getTime(); + +function configFor(fromMs: number) { + const config: React.ComponentProps['config'] = { + displayType: DisplayType.Heatmap, + select: [{ aggFn: 'heatmap', valueExpression: 'Duration' }], + from: { databaseName: 'default', tableName: 'otel_traces' }, + where: '', + dateRange: [new Date(fromMs), new Date(fromMs + 2 * HOUR)], + granularity: 'auto', + timestampValueExpression: 'Timestamp', + connection: 'test-connection', + }; + return config; +} +// Bucket rows that line up with the time buckets of `configFor(T0)`. const bucketData = { meta: [ { name: '__hdx_time_bucket', type: 'DateTime' }, @@ -60,30 +71,39 @@ const bucketData = { { name: 'count', type: 'UInt64' }, ], data: [ - { __hdx_time_bucket: '2026-07-06T00:00:00Z', x_bucket: 1, count: '5' }, - { __hdx_time_bucket: '2026-07-06T01:00:00Z', x_bucket: 2, count: '3' }, + { __hdx_time_bucket: new Date(T0).toISOString(), x_bucket: 1, count: '5' }, + { + __hdx_time_bucket: new Date(T0 + HOUR).toISOString(), + x_bucket: 2, + count: '3', + }, ], }; // The heatmap runs two queries: the bounds (`heatmap`) and the buckets -// (`heatmap_bucket`). Route each call to its own result. +// (`heatmap_bucket`). Route each call to its own result, the way react-query +// would report it. function mockQueries({ - boundsPlaceholder, - bucketsPlaceholder, + boundsPlaceholder = false, + bucketsPlaceholder = false, + bounds = { min: '1', max: '100' }, + buckets = bucketData, }: { - boundsPlaceholder: boolean; - bucketsPlaceholder: boolean; + boundsPlaceholder?: boolean; + bucketsPlaceholder?: boolean; + bounds?: { min: string; max: string }; + buckets?: typeof bucketData | null; }) { mockUseQueriedChartConfig.mockImplementation((_config, options) => options?.queryKey?.includes('heatmap') ? { - data: { data: [{ min: '1', max: '100' }], meta: [] }, + data: { data: [bounds], meta: [] }, isLoading: false, isPlaceholderData: boundsPlaceholder, error: null, } : { - data: bucketData, + data: buckets ?? undefined, isLoading: false, isPlaceholderData: bucketsPlaceholder, error: null, @@ -91,47 +111,89 @@ function mockQueries({ ); } -function optionsFor(queryName: string) { - const call = mockUseQueriedChartConfig.mock.calls.find(([, options]) => +function lastOptionsFor(queryName: string) { + const calls = mockUseQueriedChartConfig.mock.calls.filter(([, options]) => options?.queryKey?.includes(queryName), ); - return call?.[1]; + return calls.at(-1)?.[1]; } +const lastPlottedCounts = () => mockPlot.mock.calls.at(-1)?.[0]?.[2]; + +// A wrapper (rather than renderWithMantine) keeps the provider in place across +// rerenders, so the chart keeps its state when the range changes. +const renderChart = (fromMs: number) => + render(, { + wrapper: MantineProvider, + }); + describe('DBHeatmapChart refresh', () => { beforeEach(() => { mockUseQueriedChartConfig.mockReset(); + mockPlot.mockReset(); }); it('keeps both queries on their previous data while a refresh loads', async () => { - mockQueries({ boundsPlaceholder: false, bucketsPlaceholder: false }); + mockQueries({}); - renderWithMantine(); + renderChart(T0); await screen.findByTestId('heatmap-plot'); const previous = { data: [] }; - expect(optionsFor('heatmap')?.placeholderData?.(previous)).toBe(previous); - expect(optionsFor('heatmap_bucket')?.placeholderData?.(previous)).toBe( + expect(lastOptionsFor('heatmap')?.placeholderData?.(previous)).toBe( + previous, + ); + expect(lastOptionsFor('heatmap_bucket')?.placeholderData?.(previous)).toBe( previous, ); }); - it('keeps the previous heatmap on screen and pulses while a refresh loads', async () => { - mockQueries({ boundsPlaceholder: true, bucketsPlaceholder: true }); + it('keeps plotting the previous heatmap, pulsing, after the range moves', async () => { + mockQueries({}); + const { rerender } = renderChart(T0); + await screen.findByTestId('heatmap-plot'); + const settledCounts = lastPlottedCounts(); + expect(settledCounts).toContain(5); - renderWithMantine(); + // A refresh moves to a later, non-overlapping range; both queries are + // still showing the previous range's results. + mockQueries({ boundsPlaceholder: true, bucketsPlaceholder: true }); + rerender(); const plot = await screen.findByTestId('heatmap-plot'); + expect(lastPlottedCounts()).toEqual(settledCounts); expect(plot.closest('.heatmap-selection-container')).toHaveClass( 'effect-pulse', ); expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); }); + it('pulses while only the bucket query is still refreshing', async () => { + mockQueries({ bucketsPlaceholder: true }); + + renderChart(T0); + + const plot = await screen.findByTestId('heatmap-plot'); + expect(plot.closest('.heatmap-selection-container')).toHaveClass( + 'effect-pulse', + ); + }); + + it('pulses while only the bounds query is still refreshing', async () => { + mockQueries({ boundsPlaceholder: true }); + + renderChart(T0); + + const plot = await screen.findByTestId('heatmap-plot'); + expect(plot.closest('.heatmap-selection-container')).toHaveClass( + 'effect-pulse', + ); + }); + it('does not pulse once fresh data has loaded', async () => { - mockQueries({ boundsPlaceholder: false, bucketsPlaceholder: false }); + mockQueries({}); - renderWithMantine(); + renderChart(T0); const plot = await screen.findByTestId('heatmap-plot'); expect(plot.closest('.heatmap-selection-container')).not.toHaveClass( @@ -141,16 +203,25 @@ describe('DBHeatmapChart refresh', () => { it('holds the bucket query until the refreshed bounds arrive', async () => { mockQueries({ boundsPlaceholder: true, bucketsPlaceholder: true }); - const { unmount } = renderWithMantine(); + const { rerender } = renderChart(T0); await screen.findByTestId('heatmap-plot'); - expect(optionsFor('heatmap_bucket')?.enabled).toBe(false); - unmount(); + expect(lastOptionsFor('heatmap_bucket')?.enabled).toBe(false); - mockUseQueriedChartConfig.mockReset(); - mockQueries({ boundsPlaceholder: false, bucketsPlaceholder: true }); - renderWithMantine(); - await waitFor(() => - expect(optionsFor('heatmap_bucket')?.enabled).toBe(true), - ); + mockQueries({ bucketsPlaceholder: true }); + rerender(); + expect(lastOptionsFor('heatmap_bucket')?.enabled).toBe(true); + }); + + it('shows the empty state instead of pulsing when the refreshed range has no data', async () => { + // Fresh bounds of an empty range can't bucket anything, so the bucket + // query must not keep the previous rows as a placeholder. + mockQueries({ bounds: { min: '0', max: '0' }, buckets: null }); + + renderChart(T0); + + const empty = await screen.findByText(/Not enough data points/); + expect(lastOptionsFor('heatmap_bucket')?.placeholderData).toBeUndefined(); + expect(lastOptionsFor('heatmap_bucket')?.enabled).toBe(false); + expect(empty).not.toHaveClass('effect-pulse'); }); }); From 7c2f39f3b3b385e473c9c5c02c8e6a3226308386 Mon Sep 17 00:00:00 2001 From: Harshul Rathod Date: Sat, 26 Sep 2026 00:05:51 +0530 Subject: [PATCH 3/3] fix(app): keep the settled heatmap's scale while it refreshes --- .../app/src/components/DBHeatmapChart.tsx | 10 +++--- .../__tests__/DBHeatmapChart.render.test.tsx | 33 +++++++++++++++++-- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 9ed302d58b..02a26f0a48 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -836,12 +836,12 @@ function HeatmapContainer({ }, [data, generatedTsBuckets, scaleType, effectiveMin, max, nBuckets]); // While refreshing, keep drawing the last settled heatmap. The previous - // bucket rows only line up with the time buckets and bounds they were + // bucket rows only line up with the time buckets, bounds and scale they were // queried with, so re-plotting them on the new range would draw a blank or // mis-scaled grid. const currentView = useMemo( - () => ({ heatmapData, generatedTsBuckets, effectiveMin }), - [heatmapData, generatedTsBuckets, effectiveMin], + () => ({ heatmapData, generatedTsBuckets, effectiveMin, scaleType }), + [heatmapData, generatedTsBuckets, effectiveMin, scaleType], ); const [settledView, setSettledView] = useState(currentView); if (!isRefreshing && settledView !== currentView) { @@ -911,7 +911,7 @@ function HeatmapContainer({ // The 1.1x threshold adds 10% headroom to account for // floating-point rounding in the bucket boundary. const adjustedYMin = - scaleType === 'log' && yMin <= view.effectiveMin * 1.1 + view.scaleType === 'log' && yMin <= view.effectiveMin * 1.1 ? 0 : yMin; onFilter(xMin, xMax, adjustedYMin, yMax); @@ -919,7 +919,7 @@ function HeatmapContainer({ : undefined } onClearFilter={onClearFilter} - scaleType={scaleType} + scaleType={view.scaleType} palette={palette} selectionBounds={selectionBounds} /> diff --git a/packages/app/src/components/__tests__/DBHeatmapChart.render.test.tsx b/packages/app/src/components/__tests__/DBHeatmapChart.render.test.tsx index 809dbdfd2a..203d394154 100644 --- a/packages/app/src/components/__tests__/DBHeatmapChart.render.test.tsx +++ b/packages/app/src/components/__tests__/DBHeatmapChart.render.test.tsx @@ -30,12 +30,19 @@ jest.mock('next/dynamic', () => ({ }, })); -// Heatmap passes uPlot `[[], [time, bucket, count]]`; record what it plots. +// Heatmap passes uPlot `[[], [time, bucket, count]]` plus its options; record +// what it plots and with which axes. const mockPlot = jest.fn(); jest.mock('uplot-react', () => ({ __esModule: true, - default: ({ data }: { data: [unknown, number[][]] }) => { - mockPlot(data[1]); + default: ({ + data, + options, + }: { + data: [unknown, number[][]]; + options: { axes?: { splits?: unknown }[] }; + }) => { + mockPlot(data[1], options); return
; }, })); @@ -119,6 +126,9 @@ function lastOptionsFor(queryName: string) { } const lastPlottedCounts = () => mockPlot.mock.calls.at(-1)?.[0]?.[2]; +// Only the log scale adds custom y-axis splits (ticks at powers of 10). +const lastYAxisIsLog = () => + mockPlot.mock.calls.at(-1)?.[1]?.axes?.[1]?.splits != null; // A wrapper (rather than renderWithMantine) keeps the provider in place across // rerenders, so the chart keeps its state when the range changes. @@ -168,6 +178,23 @@ describe('DBHeatmapChart refresh', () => { expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); }); + it('keeps the previous scale with the previous heatmap while a scale switch refreshes', async () => { + mockQueries({}); + const { rerender } = renderChart(T0); + await screen.findByTestId('heatmap-plot'); + expect(lastYAxisIsLog()).toBe(true); + const settledCounts = lastPlottedCounts(); + + // Switching to linear refetches both queries; until they return, the + // chart still shows log-scale data, so it must keep the log axis. + mockQueries({ boundsPlaceholder: true, bucketsPlaceholder: true }); + rerender(); + + await screen.findByTestId('heatmap-plot'); + expect(lastPlottedCounts()).toEqual(settledCounts); + expect(lastYAxisIsLog()).toBe(true); + }); + it('pulses while only the bucket query is still refreshing', async () => { mockQueries({ bucketsPlaceholder: true });