From 028c021ae5ed28751c822547c73f17c4f15b00fe Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 5 May 2026 04:37:16 +0000 Subject: [PATCH 01/10] fix(heatmap): persist drag-select rectangle across re-renders The dashed selection rectangle on the Event Deltas heatmap collapses to a 2x2 px box the instant the user releases the mouse. The filter is applied correctly (URL params, comparison legend, bar charts all update), but visually you can no longer see what region you picked. Root cause: uplot-react's dataMatch deep-compares the data prop and calls chart.setData(data, true) when refs differ. setData resets uPlot's internal u.select rectangle. HeatmapContainer rebuilt the [time, bucket, count] arrays in its render body, so on every parent re-render those entries had new references with identical values, and dataMatch returned false. Memoize the data array with stable refs so dataMatch returns true, setData is skipped, and u.select survives the re-render that follows a drag (the URL param write triggers). Also memoize generatedTsBuckets so its fresh-Date-array each render doesn't propagate into the heatmapData useMemo deps. HDX-4147 --- .../app/src/components/DBHeatmapChart.tsx | 88 +++++++++++++------ 1 file changed, 60 insertions(+), 28 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index c4009144fe..677e39bef4 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -600,39 +600,59 @@ function HeatmapContainer({ enabled: !!minMaxData && bucketConfig != null && max > effectiveMin, }); - const generatedTsBuckets = timeBucketByGranularity( - dateRange[0], - dateRange[1], - granularity, + // Memoize so the array refs are stable across re-renders that don't + // change the underlying time range or granularity. Without this, + // timeBucketByGranularity returns a fresh Date[] each render, which + // would propagate into the heatmapData useMemo deps and defeat its + // own memoization. + const fromMs = dateRange[0]?.getTime() ?? 0; + const toMs = dateRange[1]?.getTime() ?? 0; + const generatedTsBuckets = useMemo( + () => timeBucketByGranularity(dateRange[0], dateRange[1], granularity), + // dateRange itself may be a fresh array each render; depend on the + // primitive timestamps + granularity instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + [fromMs, toMs, granularity], ); const timestampColumn = inferTimestampColumn(data?.meta ?? []); - // Compute the y-axis value for a given bucket index. - // For log scale we store values in log space so that bins are uniformly - // spaced on the linear uPlot y-axis. The heatmapPaths renderer assumes - // uniform increments (yBinIncr = ys[1] - ys[0]) to compute tile height; - // with actual log-spaced values the first increment is tiny relative to the - // full range and tiles render at ~0px height (invisible). - const bucketToYValue = (j: number) => { - if (scaleType === 'log' && effectiveMin > 0 && max > effectiveMin) { - // Return the natural-log of the actual bucket boundary so that the - // y-values are uniformly spaced. Tick labels are exponentiated back - // via the tickFormatter below. - const actualValue = - effectiveMin * Math.pow(max / effectiveMin, j / nBuckets); - return Math.log(actualValue); + // Build the [time, bucket, count] arrays that feed uPlot. Memoized so the + // inner references stay stable when only the URL filter params change + // (drag-select writes xMin/xMax/yMin/yMax). uplot-react's dataMatch + // compares each series entry with === ; if the inner refs change, it calls + // setData(data, true), which resets uPlot's u.select rectangle. Stable + // refs here keep dataMatch happy and the dashed selection rectangle + // visible after the user releases the mouse. + const heatmapData = useMemo(() => { + const time: number[] = []; // x values + const bucket: number[] = []; // y value series 1 + const count: number[] = []; // y value series 2 + + if (data == null || timestampColumn == null) { + return [time, bucket, count]; } - // Linear: min + j * step - return effectiveMin + j * ((max - effectiveMin) / nBuckets); - }; - const time: number[] = []; // x values - const bucket: number[] = []; // y value series 1 - const count: number[] = []; // y value series 2 - if (data != null && timestampColumn != null) { - let dataIndex = 0; + // Compute the y-axis value for a given bucket index. + // For log scale we store values in log space so that bins are uniformly + // spaced on the linear uPlot y-axis. The heatmapPaths renderer assumes + // uniform increments (yBinIncr = ys[1] - ys[0]) to compute tile height; + // with actual log-spaced values the first increment is tiny relative to + // the full range and tiles render at ~0px height (invisible). + const bucketToYValue = (j: number) => { + if (scaleType === 'log' && effectiveMin > 0 && max > effectiveMin) { + // Return the natural-log of the actual bucket boundary so that the + // y-values are uniformly spaced. Tick labels are exponentiated back + // via the tickFormatter below. + const actualValue = + effectiveMin * Math.pow(max / effectiveMin, j / nBuckets); + return Math.log(actualValue); + } + // Linear: min + j * step + return effectiveMin + j * ((max - effectiveMin) / nBuckets); + }; + let dataIndex = 0; for (let i = 0; i < generatedTsBuckets.length; i++) { const generatedTs = generatedTsBuckets[i].getTime(); @@ -657,7 +677,19 @@ function HeatmapContainer({ } } } - } + + return [time, bucket, count]; + }, [ + data, + timestampColumn?.name, + generatedTsBuckets, + scaleType, + effectiveMin, + max, + nBuckets, + ]); + + const [time, bucket, count] = heatmapData; const toolbarItemsMemo = useMemo(() => { const allToolbarItems: React.ReactNode[] = []; @@ -743,7 +775,7 @@ function HeatmapContainer({ ) : ( Date: Tue, 5 May 2026 04:46:21 +0000 Subject: [PATCH 02/10] fix(heatmap): satisfy exhaustive-deps and add changeset Lint feedback from CI: - timestampColumn?.name dep is wrong since the closure uses the full timestampColumn object; pass the object instead. The ref is stable when data is stable, so this doesn't reintroduce the original bug. - Stop destructuring bucket/count when only time is needed for the not-enough-data short-circuit. Add changeset (patch on @hyperdx/app). --- .changeset/heatmap-select-persist.md | 5 +++++ packages/app/src/components/DBHeatmapChart.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/heatmap-select-persist.md diff --git a/.changeset/heatmap-select-persist.md b/.changeset/heatmap-select-persist.md new file mode 100644 index 0000000000..bf29e58b2c --- /dev/null +++ b/.changeset/heatmap-select-persist.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +fix: Persist heatmap drag-select rectangle on Event Deltas and Search heatmaps so the dashed selection stays visible after mouseup diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 677e39bef4..6cd5e3af91 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -681,7 +681,7 @@ function HeatmapContainer({ return [time, bucket, count]; }, [ data, - timestampColumn?.name, + timestampColumn, generatedTsBuckets, scaleType, effectiveMin, @@ -689,7 +689,7 @@ function HeatmapContainer({ nBuckets, ]); - const [time, bucket, count] = heatmapData; + const time = heatmapData[0]; const toolbarItemsMemo = useMemo(() => { const allToolbarItems: React.ReactNode[] = []; From 3d04cba1bf7220f15a6fedc4eb4ab7403dcaefb0 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 5 May 2026 04:51:15 +0000 Subject: [PATCH 03/10] fix(heatmap): inline timestampColumn into useMemo, drop trivial labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot review feedback: timestampColumn was a non-primitive useMemo dep derived entirely from data?.meta. Move the call inside the useMemo so data is the only relevant dep, which is the cleanest way to satisfy both exhaustive-deps and the ref-stability the fix relies on. Also drop the // x values / // y value series 1 / // y value series 2 labels — they describe what the line is rather than why. --- .../app/src/components/DBHeatmapChart.tsx | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 6cd5e3af91..c14607362a 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -615,8 +615,6 @@ function HeatmapContainer({ [fromMs, toMs, granularity], ); - const timestampColumn = inferTimestampColumn(data?.meta ?? []); - // Build the [time, bucket, count] arrays that feed uPlot. Memoized so the // inner references stay stable when only the URL filter params change // (drag-select writes xMin/xMax/yMin/yMax). uplot-react's dataMatch @@ -625,9 +623,13 @@ function HeatmapContainer({ // refs here keep dataMatch happy and the dashed selection rectangle // visible after the user releases the mouse. const heatmapData = useMemo(() => { - const time: number[] = []; // x values - const bucket: number[] = []; // y value series 1 - const count: number[] = []; // y value series 2 + const time: number[] = []; + const bucket: number[] = []; + const count: number[] = []; + + // timestampColumn is fully derived from data.meta, so it doesn't need + // to be a separate hook dep — having data as a dep is sufficient. + const timestampColumn = inferTimestampColumn(data?.meta ?? []); if (data == null || timestampColumn == null) { return [time, bucket, count]; @@ -679,15 +681,7 @@ function HeatmapContainer({ } return [time, bucket, count]; - }, [ - data, - timestampColumn, - generatedTsBuckets, - scaleType, - effectiveMin, - max, - nBuckets, - ]); + }, [data, generatedTsBuckets, scaleType, effectiveMin, max, nBuckets]); const time = heatmapData[0]; From 618c50f336cf04156af4e3a986cecc9e7de1d652 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 5 May 2026 21:34:17 +0000 Subject: [PATCH 04/10] fix(heatmap): drop em-dash from timestampColumn comment prose-lint flags U+2014 anywhere; tighten the comment instead. --- packages/app/src/components/DBHeatmapChart.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index c14607362a..d61c2059d7 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -627,8 +627,7 @@ function HeatmapContainer({ const bucket: number[] = []; const count: number[] = []; - // timestampColumn is fully derived from data.meta, so it doesn't need - // to be a separate hook dep — having data as a dep is sufficient. + // timestampColumn is derived from data.meta, so data covers the dep. const timestampColumn = inferTimestampColumn(data?.meta ?? []); if (data == null || timestampColumn == null) { From c7ee6cdc61374b212e29b55fcecd94f4ba7180c9 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 5 May 2026 22:12:16 +0000 Subject: [PATCH 05/10] fix(heatmap): tighten memoization comments Drop the dataMatch description (it was element-wise === per series, not top-level === as written) and consolidate to the actionable mechanism: fresh data ref drives uplot-react to call setData(data, true) which wipes u.select. Add HDX-4147 ticket reference. --- .../app/src/components/DBHeatmapChart.tsx | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index d61c2059d7..48aaf764dd 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -600,28 +600,21 @@ function HeatmapContainer({ enabled: !!minMaxData && bucketConfig != null && max > effectiveMin, }); - // Memoize so the array refs are stable across re-renders that don't - // change the underlying time range or granularity. Without this, - // timeBucketByGranularity returns a fresh Date[] each render, which - // would propagate into the heatmapData useMemo deps and defeat its - // own memoization. + // Memoize so timeBucketByGranularity's fresh Date[] doesn't defeat + // the heatmapData memoization downstream. dateRange itself may be a + // fresh array each render, so depend on primitive ms + granularity. const fromMs = dateRange[0]?.getTime() ?? 0; const toMs = dateRange[1]?.getTime() ?? 0; const generatedTsBuckets = useMemo( () => timeBucketByGranularity(dateRange[0], dateRange[1], granularity), - // dateRange itself may be a fresh array each render; depend on the - // primitive timestamps + granularity instead. // eslint-disable-next-line react-hooks/exhaustive-deps [fromMs, toMs, granularity], ); - // Build the [time, bucket, count] arrays that feed uPlot. Memoized so the - // inner references stay stable when only the URL filter params change - // (drag-select writes xMin/xMax/yMin/yMax). uplot-react's dataMatch - // compares each series entry with === ; if the inner refs change, it calls - // setData(data, true), which resets uPlot's u.select rectangle. Stable - // refs here keep dataMatch happy and the dashed selection rectangle - // visible after the user releases the mouse. + // Memoize: URL-filter-only re-renders (drag-select writes xMin/xMax/ + // yMin/yMax to the URL) must not change the data ref, or uplot-react + // calls setData(data, true), which wipes uPlot's u.select rectangle. + // Stable refs keep the dashed selection visible after mouseup. (HDX-4147) const heatmapData = useMemo(() => { const time: number[] = []; const bucket: number[] = []; From aaf132b5843c7be34d4cd5739c1749a12f7643a0 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 6 May 2026 14:56:07 +0000 Subject: [PATCH 06/10] fix(heatmap): mirror selection from URL on every uPlot create The previous memoization fix was insufficient. The actual cause is that uplot-react destroys and recreates the chart whenever its `options` prop ref changes. `tickFormatter` was rebuilt on every render because `numberFormat` is a fresh object reference from callers (e.g. DBSearchHeatmapChart constructs `{output: 'duration', factor: 0.001}` inline). Rebuilt tickFormatter cascaded into the `options` useMemo, optionsUpdateState detected new top-level keys (series, axes, cursor, plugins are new array/object literals on each recompute), classified the change as 'create', and the chart was destroyed and recreated, wiping `u.select`. Two layers: 1. Stabilize `tickFormatter`'s dep on `numberFormat` content via JSON.stringify, so callers passing a fresh-each-render `numberFormat` no longer cause chart recreation. 2. Pass the URL-backed selection into `Heatmap` as a `selectionBounds` prop and reapply via `setSelect` on chart create + on the prop changing. This makes the URL the source of truth and survives any future cause of chart recreation (theme switch, resize bounce, etc.). --- .../app/src/components/DBHeatmapChart.tsx | 129 +++++++++++++++++- .../Search/DBSearchHeatmapChart.tsx | 24 ++++ 2 files changed, 147 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 48aaf764dd..a2ca131091 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -35,6 +35,81 @@ import { SQLPreview } from './ChartSQLPreview'; type Mode2DataArray = [number[], number[], number[]]; +/** + * Drag-select bounds in data space: x in seconds (URL convention), y in + * the y-axis's natural unit (NOT log-space; callers pass the actual value + * users would expect to see, e.g. ms latency). yMin may be 0 when the + * selection touched the bottom bucket; the renderer clamps to the chart's + * visible y-axis floor. + */ +export type SelectionBounds = { + xMin: number; + xMax: number; + yMin: number; + yMax: number; +}; + +/** + * Reapply a persisted selection rectangle to a uPlot instance. Called on + * chart create and whenever the bounds prop changes; uPlot's `u.select` + * is owned by the chart, so it gets wiped on any chart recreation. We use + * the URL-backed bounds as the source of truth and mirror them onto the + * chart imperatively. fireHook=false avoids re-entering the setSelect hook. + */ +function applySelectionToChart( + u: uPlot, + bounds: SelectionBounds | null | undefined, + scaleType: HeatmapScaleType, +) { + if (bounds == null) { + u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false); + return; + } + + const { xMin, xMax, yMin, yMax } = bounds; + + // x is in seconds in the URL; uPlot's x-axis is configured ms:1 so + // values are stored as ms. + const xMinPx = u.valToPos(xMin * 1000, 'x'); + const xMaxPx = u.valToPos(xMax * 1000, 'x'); + + const yScaleMin = u.scales.y?.min; + const yScaleMax = u.scales.y?.max; + if (yScaleMin == null || yScaleMax == null) { + return; + } + + // For log scale, y-axis values are stored in log-space. Convert and + // clamp to the visible axis: yMin may be 0 (bottom-bucket adjustment in + // HeatmapContainer's onFilter wrapper); yMax may exceed the axis. + let yLowPlot: number; + let yHighPlot: number; + if (scaleType === 'log') { + yHighPlot = yMax > 0 ? Math.min(Math.log(yMax), yScaleMax) : yScaleMax; + yLowPlot = yMin > 0 ? Math.max(Math.log(yMin), yScaleMin) : yScaleMin; + } else { + yHighPlot = Math.min(yMax, yScaleMax); + yLowPlot = Math.max(yMin, yScaleMin); + } + + // uPlot's y-axis: high data values map to small pixel y (top of chart). + const yHighPx = u.valToPos(yHighPlot, 'y'); + const yLowPx = u.valToPos(yLowPlot, 'y'); + + const left = Math.min(xMinPx, xMaxPx); + const right = Math.max(xMinPx, xMaxPx); + + u.setSelect( + { + left, + top: yHighPx, + width: right - left, + height: Math.max(0, yLowPx - yHighPx), + }, + false, + ); +} + // From: https://github.com/leeoniya/uPlot/blob/a4edb297a9b80baf781f4d05a40fb52fae737bff/demos/latency-heatmap.html#L436 function heatmapPaths(opts: { disp: { fill: { lookup: string[]; values: any } }; @@ -534,6 +609,7 @@ function HeatmapContainer({ enabled = true, onFilter, onClearFilter, + selectionBounds, title, toolbarPrefix, toolbarSuffix, @@ -544,6 +620,14 @@ function HeatmapContainer({ enabled?: boolean; onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; onClearFilter?: () => void; + /** + * The currently-applied drag-select bounds. When provided, the heatmap + * draws the dashed selection rectangle and reapplies it after any uPlot + * recreation (theme switch, prop change, resize) so the user always sees + * which slice they filtered. Caller owns the URL/query-state plumbing; + * this is purely a visual mirror of that state. + */ + selectionBounds?: SelectionBounds | null; title?: React.ReactNode; toolbarPrefix?: React.ReactNode[]; toolbarSuffix?: React.ReactNode[]; @@ -611,10 +695,11 @@ function HeatmapContainer({ [fromMs, toMs, granularity], ); - // Memoize: URL-filter-only re-renders (drag-select writes xMin/xMax/ - // yMin/yMax to the URL) must not change the data ref, or uplot-react - // calls setData(data, true), which wipes uPlot's u.select rectangle. - // Stable refs keep the dashed selection visible after mouseup. (HDX-4147) + // Stable [time, bucket, count] arrays let uplot-react skip its setData + // path when only URL-state (xMin/xMax/yMin/yMax) changed. Pairs with the + // selectionBounds prop on Heatmap below: the prop reapplies u.select on + // any chart recreation, this memo prevents the recreation in the + // common case. const heatmapData = useMemo(() => { const time: number[] = []; const bucket: number[] = []; @@ -770,7 +855,7 @@ function HeatmapContainer({ // clamped by greatest(value, effectiveMin). If the // selection touches that bucket, widen yMin to 0 so // the downstream SQL filter captures all those spans. - // The 1.1× threshold adds 10% headroom to account for + // The 1.1x threshold adds 10% headroom to account for // floating-point rounding in the bucket boundary. const adjustedYMin = scaleType === 'log' && yMin <= effectiveMin * 1.1 @@ -783,6 +868,7 @@ function HeatmapContainer({ onClearFilter={onClearFilter} scaleType={scaleType} palette={palette} + selectionBounds={selectionBounds} /> )} @@ -893,6 +979,7 @@ function Heatmap({ onClearFilter, scaleType = 'linear', palette, + selectionBounds, }: { data: Mode2DataArray; numberFormat?: NumberFormat; @@ -900,6 +987,7 @@ function Heatmap({ onClearFilter?: () => void; scaleType?: HeatmapScaleType; palette: string[]; + selectionBounds?: SelectionBounds | null; }) { const [highlightedPoint, setHighlightedPoint] = useState< | { @@ -937,6 +1025,17 @@ function Heatmap({ // persisted u.select rectangle (which is owned by uPlot, not React). const uplotRef = useRef(null); + // Reapply the URL-backed selection whenever the bounds prop changes + // (e.g. a fresh drag-select arrives via the round-trip through the + // parent's URL state, or the parent clears the filter). This complements + // the onCreate path: that handles chart creation, this handles bounds + // changes against an existing chart. + useEffect(() => { + if (uplotRef.current) { + applySelectionToChart(uplotRef.current, selectionBounds, scaleType); + } + }, [selectionBounds, scaleType]); + // Timestamp of the most recent drag-end. Guards the container's onClick // handler from clearing the selection when the synthetic click event // that fires on mouseup-after-drag arrives. @@ -944,6 +1043,17 @@ function Heatmap({ const { ref, width, height } = useElementSize(); + // Stabilize on numberFormat content, not reference. Callers (e.g. + // DBSearchHeatmapChart) build a fresh `numberFormat` object on every + // render; depending on its identity would rebuild tickFormatter, then + // the options memo, then uplot-react would see new top-level keys via + // optionsUpdateState and treat the change as 'create', destroying the + // chart and wiping u.select. Hashing the contents lets the memo skip + // when the actual format is unchanged. (HDX-4147) + const numberFormatKey = useMemo( + () => (numberFormat ? JSON.stringify(numberFormat) : ''), + [numberFormat], + ); const tickFormatter = useCallback( (value: number) => { // y-values are stored in log space for log scale; exponentiate back @@ -969,7 +1079,8 @@ function Heatmap({ compactDisplay: 'short', }).format(actualValue); }, - [numberFormat, scaleType], + // eslint-disable-next-line react-hooks/exhaustive-deps + [numberFormatKey, scaleType], ); const options: uPlot.Options = useMemo(() => { @@ -1161,6 +1272,12 @@ function Heatmap({ resetScales={true} onCreate={chart => { uplotRef.current = chart; + // Reapply the persisted selection on every uPlot construction. + // uplot-react destroys+recreates the chart whenever options + // identity changes (and various deps make options unstable in + // practice), wiping u.select. Re-mirroring from the URL-backed + // bounds is the source-of-truth fix. (HDX-4147) + applySelectionToChart(chart, selectionBounds, scaleType); }} onDelete={() => { uplotRef.current = null; diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index e618526abb..e6d2ea5813 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -26,6 +26,7 @@ import DBHeatmapChart, { darkPalette, type HeatmapScaleType, lightPalette, + type SelectionBounds, toHeatmapChartConfig, } from '../DBHeatmapChart'; @@ -93,6 +94,28 @@ export function DBSearchHeatmapChart({ prevDateRangeRef.current = key; }, [fromMs, toMs, setFields]); + // Mirror the URL's selection coordinates onto the heatmap so the dashed + // rectangle stays in sync after any uPlot recreation. Memoize on the + // primitive coords so the prop's identity is stable when the URL hasn't + // changed; otherwise the downstream useEffect would re-fire on every + // render. (HDX-4147) + const selectionBounds: SelectionBounds | null = useMemo(() => { + if ( + fields.xMin == null || + fields.xMax == null || + fields.yMin == null || + fields.yMax == null + ) { + return null; + } + return { + xMin: fields.xMin, + xMax: fields.xMax, + yMin: fields.yMin, + yMax: fields.yMax, + }; + }, [fields.xMin, fields.xMax, fields.yMin, fields.yMax]); + return ( { setFields({ xMin, xMax, yMin, yMax }); }} From ef3ece0e31208b124a233356ffae4880291a1168 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 6 May 2026 15:05:25 +0000 Subject: [PATCH 07/10] fix(heatmap): apply selection from uPlot's ready hook Page reload with a URL-encoded selection wasn't drawing the rectangle. Root cause: at onCreate time, uPlot has constructed but not yet completed its initial layout for mode-2 facet data. u.scales.y.min/max can still be undefined, so applySelectionToChart's early-out triggered and setSelect never got called. Move the apply call to uPlot's `ready` hook, which fires once after the initial draw completes. Hold selectionBounds and scaleType in refs so the hook (captured inside the options useMemo) reads the latest values without requiring memo dep churn. --- .../app/src/components/DBHeatmapChart.tsx | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index a2ca131091..0d6da8c52f 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -1025,11 +1025,24 @@ function Heatmap({ // persisted u.select rectangle (which is owned by uPlot, not React). const uplotRef = useRef(null); + // Hold selectionBounds and scaleType in refs so the uPlot `ready` hook + // (captured inside the options useMemo via closure) always sees the + // latest values without needing them in the memo's dep array. Mutating + // a ref doesn't trigger re-renders, so the options identity stays + // stable across selection/scale changes. + const selectionBoundsRef = useRef(selectionBounds); + const scaleTypeRef = useRef(scaleType); + useEffect(() => { + selectionBoundsRef.current = selectionBounds; + scaleTypeRef.current = scaleType; + }); + // Reapply the URL-backed selection whenever the bounds prop changes // (e.g. a fresh drag-select arrives via the round-trip through the // parent's URL state, or the parent clears the filter). This complements - // the onCreate path: that handles chart creation, this handles bounds - // changes against an existing chart. + // the uPlot `ready` hook below: that handles initial chart creation + // (and any recreation), this handles bounds changes against an existing + // chart. useEffect(() => { if (uplotRef.current) { applySelectionToChart(uplotRef.current, selectionBounds, scaleType); @@ -1209,6 +1222,17 @@ function Heatmap({ }), { hooks: { + // Fires once after uPlot finishes initial layout/draw. At + // onCreate time scales aren't reliably populated for mode-2 + // facet data, so reapplying the URL-backed selection from + // here ensures valToPos has the bounds it needs. (HDX-4147) + ready: u => { + applySelectionToChart( + u, + selectionBoundsRef.current, + scaleTypeRef.current, + ); + }, setSelect: u => { // Ignore zero-size selections (e.g. single-click) if (u.select.width <= 0 || u.select.height <= 0) { @@ -1272,12 +1296,6 @@ function Heatmap({ resetScales={true} onCreate={chart => { uplotRef.current = chart; - // Reapply the persisted selection on every uPlot construction. - // uplot-react destroys+recreates the chart whenever options - // identity changes (and various deps make options unstable in - // practice), wiping u.select. Re-mirroring from the URL-backed - // bounds is the source-of-truth fix. (HDX-4147) - applySelectionToChart(chart, selectionBounds, scaleType); }} onDelete={() => { uplotRef.current = null; From 76d28029a7784895a08a64fd1f31acd8bd69e59c Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 6 May 2026 16:31:16 +0000 Subject: [PATCH 08/10] test(e2e): cover heatmap drag-select persistence (HDX-4147) Adds Playwright regression coverage for the bug fixed in PR #2189: the dashed drag-select rectangle on the Event Deltas heatmap collapsed to a 2x2 px residue after mouseup unless u.select is mirrored from the URL state. Three scenarios in one spec: 1. Drag-select draws a persistent rectangle and writes URL state. Asserts URL gains xMin/xMax/yMin/yMax AND the .u-select element has width/height > 20 px, well above the 2x2 px collapse signature. 2. Reloading the page restores the rectangle from URL state. This is what specifically catches the ready-vs-onCreate timing issue: at onCreate u.scales.y.min/max aren't populated for mode-2 facet data, so the apply must run from uPlot's ready hook. The test reloads after a drag and asserts the rectangle returns with the same dimensions. 3. Clicking off the rectangle clears both URL state and the rectangle (collapses below 5 px on each axis). New SearchPage methods: openEventDeltasMode, getHeatmap, getHeatmapSelectionRect, dragHeatmapSelection. The drag helper spans both axes so the resulting selection has non-zero width AND height (uPlot treats single-axis drags as zero-size and skips the hook). --- .../search/event-deltas-heatmap.spec.ts | 117 ++++++++++++++++++ .../app/tests/e2e/page-objects/SearchPage.ts | 62 ++++++++++ 2 files changed, 179 insertions(+) create mode 100644 packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts diff --git a/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts b/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts new file mode 100644 index 0000000000..c90f7bbb03 --- /dev/null +++ b/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts @@ -0,0 +1,117 @@ +import { SearchPage } from '../../page-objects/SearchPage'; +import { DEFAULT_TRACES_SOURCE_NAME } from '../../utils/constants'; +import { expect, test } from '../../utils/base-test'; + +/** + * Regression coverage for HDX-4147 (PR #2189): the dashed drag-select + * rectangle on the Event Deltas heatmap collapses to a 2x2 px residue + * after mouseup unless u.select is mirrored from the URL state. These + * tests assert the visible rectangle survives the round-trip through + * the URL (drag, reload, click-to-clear). + */ +test.describe('Event Deltas heatmap drag-select', { tag: '@search' }, () => { + let searchPage: SearchPage; + + test.beforeEach(async ({ page }) => { + searchPage = new SearchPage(page); + await searchPage.goto(); + await searchPage.selectSource(DEFAULT_TRACES_SOURCE_NAME); + await searchPage.openEventDeltasMode(); + }); + + test('drag-select draws a persistent rectangle and writes URL state', async () => { + await searchPage.dragHeatmapSelection(); + + // URL gains the four selection params nuqs writes via setFields. + const url = searchPage.page.url(); + expect(url, 'URL should carry xMin').toContain('xMin='); + expect(url, 'URL should carry xMax').toContain('xMax='); + expect(url, 'URL should carry yMin').toContain('yMin='); + expect(url, 'URL should carry yMax').toContain('yMax='); + + // The dashed rectangle stays visible after mouseup. The bug shipped + // a 2x2 px collapse pinned to (0, 0); a healthy selection has both + // dimensions well above that residue. + const rect = await searchPage.getHeatmapSelectionRect().boundingBox(); + expect(rect, 'selection element should be in the DOM').not.toBeNull(); + expect(rect!.width, 'selection width should reflect the drag').toBeGreaterThan(20); + expect(rect!.height, 'selection height should reflect the drag').toBeGreaterThan(20); + }); + + test('reloading the page restores the rectangle from URL state', async () => { + await searchPage.dragHeatmapSelection(); + + // Capture the rectangle right after the drag for cross-check. + const beforeReload = await searchPage + .getHeatmapSelectionRect() + .boundingBox(); + expect(beforeReload).not.toBeNull(); + expect(beforeReload!.width).toBeGreaterThan(20); + + // Round-trip through the URL: a fresh page load goes through the + // uPlot ready hook path, which is what the on-create path got wrong + // before the fix (scales aren't populated for mode-2 facet data + // until the first draw). + await searchPage.page.reload(); + await searchPage.getHeatmap().waitFor({ state: 'visible' }); + + const afterReload = await searchPage + .getHeatmapSelectionRect() + .boundingBox(); + expect(afterReload, 'selection element should be in the DOM after reload').not.toBeNull(); + expect( + afterReload!.width, + 'rectangle width should be restored from URL on reload', + ).toBeGreaterThan(20); + expect( + afterReload!.height, + 'rectangle height should be restored from URL on reload', + ).toBeGreaterThan(20); + + // Coordinates round-trip with sub-pixel accuracy (small rounding + // from log-space conversion is acceptable). + expect(afterReload!.width).toBeCloseTo(beforeReload!.width, 0); + expect(afterReload!.height).toBeCloseTo(beforeReload!.height, 0); + }); + + test('clicking off the rectangle clears both URL state and the rectangle', async () => { + await searchPage.dragHeatmapSelection(); + + // Sanity: the drag set the URL state. + expect(searchPage.page.url()).toContain('xMin='); + + // Click somewhere on the chart canvas that isn't inside the + // selection. Top edge of the canvas is far enough from the mid-band + // selection drawn by dragHeatmapSelection() defaults. + const heatmapBox = await searchPage.getHeatmap().boundingBox(); + if (!heatmapBox) { + throw new Error('Heatmap not found'); + } + await searchPage.page.mouse.click( + heatmapBox.x + heatmapBox.width * 0.9, + heatmapBox.y + heatmapBox.height * 0.05, + ); + + // URL params drop out (nuqs serializes a null value to no key). + await expect + .poll(() => searchPage.page.url(), { + message: 'xMin should be cleared from URL', + }) + .not.toContain('xMin='); + + const afterClear = await searchPage + .getHeatmapSelectionRect() + .boundingBox(); + expect(afterClear, 'selection element should still be in the DOM').not.toBeNull(); + // uPlot resets u.select to width=0/height=0; the element collapses + // to its 1 px border on each side (2x2 with the border included). + expect( + afterClear!.width, + 'rectangle width should collapse on clear', + ).toBeLessThan(5); + expect( + afterClear!.height, + 'rectangle height should collapse on clear', + ).toBeLessThan(5); + }); +}); diff --git a/packages/app/tests/e2e/page-objects/SearchPage.ts b/packages/app/tests/e2e/page-objects/SearchPage.ts index 60c4d93a00..e979442f4f 100644 --- a/packages/app/tests/e2e/page-objects/SearchPage.ts +++ b/packages/app/tests/e2e/page-objects/SearchPage.ts @@ -295,6 +295,68 @@ export class SearchPage { await this.page.mouse.up(); } + /** + * Switch the search page into Event Deltas mode and wait for the + * heatmap chart to render. Event Deltas is the analysis-mode tab + * that puts the latency-vs-time heatmap on screen. + */ + async openEventDeltasMode() { + const tab = this.page.getByRole('tab', { name: 'Event Deltas' }); + await tab.click(); + await this.getHeatmap().waitFor({ + state: 'visible', + timeout: this.defaultTimeout * 2, + }); + } + + /** + * Get the uPlot heatmap canvas wrapper. The Event Deltas page renders + * a single uPlot chart; first match disambiguates from any other charts + * that might appear on the page. + */ + getHeatmap() { + return this.page.locator('.uplot').first(); + } + + /** + * Get the dashed selection rectangle inside the heatmap. uPlot writes + * `left`/`top`/`width`/`height` into its inline style; tests assert + * against the bounding box. + */ + getHeatmapSelectionRect() { + return this.getHeatmap().locator('.u-select'); + } + + /** + * Drag a region on the heatmap canvas. Coordinates are percentages of + * the canvas (0-1). The drag spans both axes so the resulting selection + * has non-zero width AND height (zero on either axis is treated as a + * single-click by uPlot and produces no selection). + */ + async dragHeatmapSelection( + startXPercent: number = 0.25, + startYPercent: number = 0.3, + endXPercent: number = 0.7, + endYPercent: number = 0.7, + ) { + const heatmap = this.getHeatmap(); + const box = await heatmap.boundingBox(); + + if (!box) { + throw new Error('Heatmap not found'); + } + + const startX = box.x + box.width * startXPercent; + const startY = box.y + box.height * startYPercent; + const endX = box.x + box.width * endXPercent; + const endY = box.y + box.height * endYPercent; + + await this.page.mouse.move(startX, startY); + await this.page.mouse.down(); + await this.page.mouse.move(endX, endY, { steps: 10 }); + await this.page.mouse.up(); + } + // Getters for assertions in spec files get form() { From e151602b06a0345a3724cc009c44e2b215d702f6 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 6 May 2026 16:35:00 +0000 Subject: [PATCH 09/10] ci: retrigger workflow on retargeted PR From ff2157d959762e531741f79057afda1979356036 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 6 May 2026 16:56:12 +0000 Subject: [PATCH 10/10] test(e2e): poll URL and bounding-box reads in heatmap drag-select spec nuqs setFields and uPlot's ready hook both flush asynchronously after mouseup and after page reload. The synchronous reads at line 27 (URL params) and line 35/64 (post-reload boundingBox) raced with the flush and failed in CI shard 2 across all 3 retries: the URL never carried xMin= within the test window. Wrap each URL read in expect.poll(...) per param and gate the rect dimension reads on a polled width > 20 (or < 5 for the post-clear collapse). Capture rect once after polling settles, then assert the remaining dimensions and the cross-check round-trip. Also fixes lint: simple-import-sort/imports, prettier multi-line arg formatting on assertion messages. --- .../search/event-deltas-heatmap.spec.ts | 83 ++++++++++++++----- 1 file changed, 60 insertions(+), 23 deletions(-) diff --git a/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts b/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts index c90f7bbb03..eb91a16af6 100644 --- a/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts +++ b/packages/app/tests/e2e/features/search/event-deltas-heatmap.spec.ts @@ -1,6 +1,6 @@ import { SearchPage } from '../../page-objects/SearchPage'; -import { DEFAULT_TRACES_SOURCE_NAME } from '../../utils/constants'; import { expect, test } from '../../utils/base-test'; +import { DEFAULT_TRACES_SOURCE_NAME } from '../../utils/constants'; /** * Regression coverage for HDX-4147 (PR #2189): the dashed drag-select @@ -8,6 +8,10 @@ import { expect, test } from '../../utils/base-test'; * after mouseup unless u.select is mirrored from the URL state. These * tests assert the visible rectangle survives the round-trip through * the URL (drag, reload, click-to-clear). + * + * nuqs setFields and uPlot's ready hook both flush asynchronously, so + * URL and bounding-box reads use expect.poll rather than one-shot + * reads. */ test.describe('Event Deltas heatmap drag-select', { tag: '@search' }, () => { let searchPage: SearchPage; @@ -23,30 +27,48 @@ test.describe('Event Deltas heatmap drag-select', { tag: '@search' }, () => { await searchPage.dragHeatmapSelection(); // URL gains the four selection params nuqs writes via setFields. - const url = searchPage.page.url(); - expect(url, 'URL should carry xMin').toContain('xMin='); - expect(url, 'URL should carry xMax').toContain('xMax='); - expect(url, 'URL should carry yMin').toContain('yMin='); - expect(url, 'URL should carry yMax').toContain('yMax='); + for (const param of ['xMin=', 'xMax=', 'yMin=', 'yMax=']) { + await expect + .poll(() => searchPage.page.url(), { + message: `URL should carry ${param.slice(0, -1)}`, + }) + .toContain(param); + } // The dashed rectangle stays visible after mouseup. The bug shipped // a 2x2 px collapse pinned to (0, 0); a healthy selection has both // dimensions well above that residue. + await expect + .poll( + async () => + (await searchPage.getHeatmapSelectionRect().boundingBox())?.width ?? + 0, + { message: 'selection width should reflect the drag' }, + ) + .toBeGreaterThan(20); const rect = await searchPage.getHeatmapSelectionRect().boundingBox(); expect(rect, 'selection element should be in the DOM').not.toBeNull(); - expect(rect!.width, 'selection width should reflect the drag').toBeGreaterThan(20); - expect(rect!.height, 'selection height should reflect the drag').toBeGreaterThan(20); + expect( + rect!.height, + 'selection height should reflect the drag', + ).toBeGreaterThan(20); }); test('reloading the page restores the rectangle from URL state', async () => { await searchPage.dragHeatmapSelection(); - // Capture the rectangle right after the drag for cross-check. + // Wait for the rectangle to settle, then capture it for cross-check. + await expect + .poll( + async () => + (await searchPage.getHeatmapSelectionRect().boundingBox())?.width ?? + 0, + ) + .toBeGreaterThan(20); const beforeReload = await searchPage .getHeatmapSelectionRect() .boundingBox(); expect(beforeReload).not.toBeNull(); - expect(beforeReload!.width).toBeGreaterThan(20); // Round-trip through the URL: a fresh page load goes through the // uPlot ready hook path, which is what the on-create path got wrong @@ -55,14 +77,23 @@ test.describe('Event Deltas heatmap drag-select', { tag: '@search' }, () => { await searchPage.page.reload(); await searchPage.getHeatmap().waitFor({ state: 'visible' }); + // The ready hook fires after the first draw, which is async relative + // to the canvas being visible; poll until the rectangle re-renders. + await expect + .poll( + async () => + (await searchPage.getHeatmapSelectionRect().boundingBox())?.width ?? + 0, + { message: 'rectangle width should be restored from URL on reload' }, + ) + .toBeGreaterThan(20); const afterReload = await searchPage .getHeatmapSelectionRect() .boundingBox(); - expect(afterReload, 'selection element should be in the DOM after reload').not.toBeNull(); expect( - afterReload!.width, - 'rectangle width should be restored from URL on reload', - ).toBeGreaterThan(20); + afterReload, + 'selection element should be in the DOM after reload', + ).not.toBeNull(); expect( afterReload!.height, 'rectangle height should be restored from URL on reload', @@ -77,8 +108,8 @@ test.describe('Event Deltas heatmap drag-select', { tag: '@search' }, () => { test('clicking off the rectangle clears both URL state and the rectangle', async () => { await searchPage.dragHeatmapSelection(); - // Sanity: the drag set the URL state. - expect(searchPage.page.url()).toContain('xMin='); + // Sanity: the drag set the URL state. nuqs flushes async, so poll. + await expect.poll(() => searchPage.page.url()).toContain('xMin='); // Click somewhere on the chart canvas that isn't inside the // selection. Top edge of the canvas is far enough from the mid-band @@ -99,16 +130,22 @@ test.describe('Event Deltas heatmap drag-select', { tag: '@search' }, () => { }) .not.toContain('xMin='); - const afterClear = await searchPage - .getHeatmapSelectionRect() - .boundingBox(); - expect(afterClear, 'selection element should still be in the DOM').not.toBeNull(); // uPlot resets u.select to width=0/height=0; the element collapses // to its 1 px border on each side (2x2 with the border included). + // The collapse goes through React state, so poll. + await expect + .poll( + async () => + (await searchPage.getHeatmapSelectionRect().boundingBox())?.width ?? + 0, + { message: 'rectangle width should collapse on clear' }, + ) + .toBeLessThan(5); + const afterClear = await searchPage.getHeatmapSelectionRect().boundingBox(); expect( - afterClear!.width, - 'rectangle width should collapse on clear', - ).toBeLessThan(5); + afterClear, + 'selection element should still be in the DOM', + ).not.toBeNull(); expect( afterClear!.height, 'rectangle height should collapse on clear',