fix(heatmap): persist drag-select rectangle across re-renders#2189
fix(heatmap): persist drag-select rectangle across re-renders#2189alex-fedotyev wants to merge 8 commits intomainfrom
Conversation
🦋 Changeset detectedLatest commit: 41b87cc The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🟡 Tier 3 — StandardIntroduces new logic, modifies core functionality, or touches areas with non-trivial risk. Why this tier:
Review process: Full human review — logic, architecture, edge cases. Stats
|
PR Review✅ No critical issues found. The fix is well-reasoned and correctly addresses the two real failure modes — chart recreation wiping Strengths
Minor (non-blocking) observations
Test plan in the PR description is thorough (drag-select persistence, URL reload restore, click-to-clear, full jest + tsc). Looks ready to merge. |
E2E Test Results✅ All tests passed • 166 passed • 3 skipped • 1101s
Tests ran across 4 shards in parallel. |
b7962df to
3d04cba
Compare
Compound Engineering Review✅ No critical issues found. This is a well-engineered fix for the heatmap drag-select rectangle persistence bug. The imperative P2 P2 P2 P2 P2 P2 Skipped as not actionable: the simplicity reviewer's suggestion to delete the memos on |
Three small comment additions in DBHeatmapChart.tsx (compound review on #2189 flagged these for the next reader): - `applySelectionToChart`: note the bounds-null clear path runs BEFORE the y-scale-not-populated guard so a future refactor doesn't swap the ordering and silently suppress clears on first paint. - The ref-mirroring `useEffect` deliberately has no deps array; add a trailing comment so it doesn't read as "missing deps" at a glance. - `numberFormatKey`: document the dependency on NumberFormat being JSON-serializable, with a switching note in case the type ever grows a function-valued field.
|
P3 nits addressed in 287183d (clarifying comments only, no behavioural change):
|
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
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).
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.
prose-lint flags U+2014 anywhere; tighten the comment instead.
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.
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.).
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.
Three small comment additions in DBHeatmapChart.tsx (compound review on #2189 flagged these for the next reader): - `applySelectionToChart`: note the bounds-null clear path runs BEFORE the y-scale-not-populated guard so a future refactor doesn't swap the ordering and silently suppress clears on first paint. - The ref-mirroring `useEffect` deliberately has no deps array; add a trailing comment so it doesn't read as "missing deps" at a glance. - `numberFormatKey`: document the dependency on NumberFormat being JSON-serializable, with a switching note in case the type ever grows a function-valued field.
287183d to
41b87cc
Compare
Deep Review🔴 P0/P1 -- must fix
🟡 P2 -- recommended
🔵 P3 nitpicks (8)
Reviewers (9): correctness, testing, maintainability, project-standards, kieran-typescript, julik-frontend-races, performance, agent-native, learnings-researcher. Testing gaps:
|
Summary
The dashed selection rectangle on the Event Deltas heatmap collapses to a 2x2 px box the instant the user releases the mouse. The filter itself is applied correctly (URL gets
xMin/xMax/yMin/yMax, the comparison legend shows "Selection" vs "Background", and the bar charts below split into two colors), but visually the user has no idea what slice of the heatmap they picked.Repro on play-clickstack: open the Demo Traces source, switch to the Event Deltas tab, drag any region. Inspect the
.u-selectelement after mouse-up: width=2 px, height=2 px, position pinned to top-left of the canvas.Same component is used in the Search heatmap (
DBSearchHeatmapChart), so this fix applies there too. Dashboard tile heatmaps don't passonFilter, so they're not affected.Linked: HDX-4147.
Root cause
uplot-reactdestroys and recreates the chart whenever itsoptionsprop reference changes andoptionsUpdateStateclassifies the change as'create'(any non-width/height top-level key is notObject.is-equal). InHeatmapContainer'sHeatmap:tickFormatterwas auseCallbackkeyed onnumberFormatreference. Callers (e.g.DBSearchHeatmapChart) build{output: 'duration', factor: 0.001}inline on every render, sonumberFormatis a fresh ref each time.tickFormatterref makes theoptionsuseMemorecompute, returning a fresh object whoseseries,axes,cursor, andpluginsare all new array/object literals.uplot-reactsees a newoptionsref, walks the keys, classifies as'create', destroys the old chart, builds a new one. uPlot'su.selectlives on the chart instance, so it gets wiped along with the canvas.The earlier
[time, bucket, count]memoization stabilized thedataprop but left theoptionsrecreation cycle untouched, so the symptom persisted.Fix
Two layers, one targeted at the recreation cycle, one accepting that recreation can happen for any reason and recovering from it.
Stabilize
tickFormatterby hashingnumberFormatwithJSON.stringifyinstead of depending on its reference. Same content -> same hash -> sametickFormatter-> sameoptions-> no recreate. The hashed-content pattern is correct here because the formatter only reads top-level scalar fields offnumberFormat.Treat the URL as the source of truth for the selection rectangle.
DBSearchHeatmapChartalready writesxMin/xMax/yMin/yMaxto the URL via nuqs. I plumb those back down through a newselectionBoundsprop onDBHeatmapChart->Heatmap, and reapply viasetSelectfrom uPlot'sreadyhook on every chart construction plus auseEffectfor in-place bounds changes. Layer 2 is what actually keeps the rectangle alive on page reload, theme switch, resize bounce, or any future cause of recreation. Thereadyhook is needed (rather thanonCreate) because mode-2 facet data leavesu.scales.y.min/maxunpopulated until the first draw lands; reading them earlier returns undefined and the apply silently no-ops.The bottom-bucket adjustment (
yMin = 0when the drag touched the floor of a log axis) round-trips correctly:applySelectionToChartclampsMath.log(yMin)to the chart'su.scales.y.minwhenyMin <= 0.Test plan
.u-selectcollapse to 2x2 px at the canvas origin).left: 65px; top: 69px; width: 183px; height: 133pxafter mouseup, and stays visible.xMin/xMax/yMin/yMaxin the URL restores the rectangle to the same coordinates the user dragged. uPlot's inline style is set via thereadyhook.left: 0; top: 0; width: 0; height: 0).yarn workspace @hyperdx/app jest --testPathPatterns heatmappasses (1549 tests).yarn workspace @hyperdx/app run tsc --noEmitintroduces no new errors in this diff (the 8 pre-existing errors inKubernetesDashboardPage,ServicesDashboardPage,SessionsPage,SourceForm,SourcesList,SourceSelectare also present onmain).