Skip to content

fix(heatmap): persist drag-select rectangle across re-renders#2189

Open
alex-fedotyev wants to merge 8 commits intomainfrom
alex/HDX-4147-heatmap-select-persist
Open

fix(heatmap): persist drag-select rectangle across re-renders#2189
alex-fedotyev wants to merge 8 commits intomainfrom
alex/HDX-4147-heatmap-select-persist

Conversation

@alex-fedotyev
Copy link
Copy Markdown
Contributor

@alex-fedotyev alex-fedotyev commented May 5, 2026

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.

image

Repro on play-clickstack: open the Demo Traces source, switch to the Event Deltas tab, drag any region. Inspect the .u-select element 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 pass onFilter, so they're not affected.

Linked: HDX-4147.

Root cause

uplot-react destroys and recreates the chart whenever its options prop reference changes and optionsUpdateState classifies the change as 'create' (any non-width/height top-level key is not Object.is-equal). In HeatmapContainer's Heatmap:

  1. tickFormatter was a useCallback keyed on numberFormat reference. Callers (e.g. DBSearchHeatmapChart) build {output: 'duration', factor: 0.001} inline on every render, so numberFormat is a fresh ref each time.
  2. New tickFormatter ref makes the options useMemo recompute, returning a fresh object whose series, axes, cursor, and plugins are all new array/object literals.
  3. uplot-react sees a new options ref, walks the keys, classifies as 'create', destroys the old chart, builds a new one. uPlot's u.select lives on the chart instance, so it gets wiped along with the canvas.
  4. The 2x2 px residue is uPlot's default zero-size selection element with its 1 px border on each side.

The earlier [time, bucket, count] memoization stabilized the data prop but left the options recreation 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.

  1. Stabilize tickFormatter by hashing numberFormat with JSON.stringify instead of depending on its reference. Same content -> same hash -> same tickFormatter -> same options -> no recreate. The hashed-content pattern is correct here because the formatter only reads top-level scalar fields off numberFormat.

  2. Treat the URL as the source of truth for the selection rectangle. DBSearchHeatmapChart already writes xMin/xMax/yMin/yMax to the URL via nuqs. I plumb those back down through a new selectionBounds prop on DBHeatmapChart -> Heatmap, and reapply via setSelect from uPlot's ready hook on every chart construction plus a useEffect for 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. The ready hook is needed (rather than onCreate) because mode-2 facet data leaves u.scales.y.min/max unpopulated until the first draw lands; reading them earlier returns undefined and the apply silently no-ops.

The bottom-bucket adjustment (yMin = 0 when the drag touched the floor of a log axis) round-trips correctly: applySelectionToChart clamps Math.log(yMin) to the chart's u.scales.y.min when yMin <= 0.

Test plan

  • Bug reproduced on the Vercel preview build for this branch (drag-select on Demo Traces > Event Deltas, observed .u-select collapse to 2x2 px at the canvas origin).
  • After the fix on a fresh Vercel build:
    • Drag-select renders a persistent dashed red rectangle: inline style left: 65px; top: 69px; width: 183px; height: 133px after mouseup, and stays visible.
    • Reloading the page with xMin/xMax/yMin/yMax in the URL restores the rectangle to the same coordinates the user dragged. uPlot's inline style is set via the ready hook.
    • Clicking on the chart away from the rectangle clears both the URL state and the rectangle (inline style goes to left: 0; top: 0; width: 0; height: 0).
  • yarn workspace @hyperdx/app jest --testPathPatterns heatmap passes (1549 tests).
  • yarn workspace @hyperdx/app run tsc --noEmit introduces no new errors in this diff (the 8 pre-existing errors in KubernetesDashboardPage, ServicesDashboardPage, SessionsPage, SourceForm, SourcesList, SourceSelect are also present on main).

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 5, 2026

🦋 Changeset detected

Latest commit: 41b87cc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/app Patch
@hyperdx/api Patch
@hyperdx/otel-collector Patch

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

@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment May 8, 2026 0:32am

Request Review

@github-actions github-actions Bot added the review/tier-2 Low risk — AI review + quick human skim label May 5, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

🟡 Tier 3 — Standard

Introduces new logic, modifies core functionality, or touches areas with non-trivial risk.

Why this tier:

  • Diff size: 254 production lines changed (Tier 2 max: < 250)

Review process: Full human review — logic, architecture, edge cases.
SLA: First-pass feedback within 1 business day.

Stats
  • Production files changed: 2
  • Production lines changed: 254
  • Branch: alex/HDX-4147-heatmap-select-persist
  • Author: alex-fedotyev

To override this classification, remove the review/tier-3 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

PR Review

✅ No critical issues found.

The fix is well-reasoned and correctly addresses the two real failure modes — chart recreation wiping u.select, and the selection rectangle being purely chart-local state with no source of truth.

Strengths

  • Root-cause analysis is precise: tickFormatter reference instability driving optionsUpdateState'create' → uPlot teardown is the actual bug; the JSON-fingerprint memo on numberFormat addresses it directly.
  • The ready-hook + useEffect pair correctly handles both first-paint (mode-2 facet scales aren't populated until first draw) and in-place bounds changes against an existing chart.
  • applySelectionToChart runs the clear path before the scale-populated guard — the inline comment correctly flags this ordering as load-bearing.
  • Refs for selectionBounds/scaleType/onFilter keep the options memo identity stable across selection/scale changes, avoiding self-inflicted recreation.
  • The seconds↔ms x-axis convention and log-space y clamp via u.scales.y.min/max round-trip correctly, including the yMin = 0 bottom-bucket case.
  • selectionBounds is memoized on primitive coords in DBSearchHeatmapChart, so the downstream useEffect dep is stable when URL didn't change.

Minor (non-blocking) observations

  • ⚠️ JSON.stringify(numberFormat) silently breaks if NumberFormat ever grows a function-valued field (functions stringify to undefined → false equality). The inline comment already calls this out and points at the shallow-equal fix path — fine to defer.
  • ⚠️ useEffect with no deps (DBHeatmapChart.tsx:1041-1044) runs on every commit to mirror props into refs. Intentional and documented; useLayoutEffect would close a theoretical gap if a render-time read happened before the effect flushed, but ready only fires post-onCreate so refs are always fresh in practice.
  • ⚠️ The * 1000 / / 1000 seconds↔ms conversion appears in both setSelect and applySelectionToChart; a named constant or helper would reduce drift risk. Trivial.

Test plan in the PR description is thorough (drag-select persistence, URL reload restore, click-to-clear, full jest + tsc). Looks ready to merge.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

E2E Test Results

All tests passed • 166 passed • 3 skipped • 1101s

Status Count
✅ Passed 166
❌ Failed 0
⚠️ Flaky 3
⏭️ Skipped 3

Tests ran across 4 shards in parallel.

View full report →

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

Compound Engineering Review

✅ No critical issues found. This is a well-engineered fix for the heatmap drag-select rectangle persistence bug. The imperative applySelectionToChart escape hatch is well-bounded, types are tight, and the commentary explains the load-bearing invariants (the numberFormatKey fingerprint, the clear-before-scale-guard ordering in applySelectionToChart, the seconds → ms x-axis convention). Suggestions below are optional polish.

P2 packages/app/src/components/DBHeatmapChart.tsx:80-83 — when bounds != null but u.scales.y.{min,max} are undefined, applySelectionToChart early-returns without clearing, so a stale rectangle from a prior render can persist while scales settle. Asymmetric with the null-bounds path that always clears. → either clear before the guard, or add a one-line comment that the stale-during-settle behavior is intentional and the ready hook will catch up.

P2 packages/app/src/components/DBHeatmapChart.tsx:1037-1041 — the dep-less useEffect that mirrors selectionBounds/scaleType into refs every commit lands after paint, so on a chart recreation the ready hook (which reads the refs) can briefly see stale values during the same commit. Bounds change is recovered one frame later by the bounds-effect, but it's avoidable. → assign refs inline during render (selectionBoundsRef.current = selectionBounds; scaleTypeRef.current = scaleType;), drop the effect entirely. Same semantics, no ordering question, no ESLint suppression needed.

P2 packages/app/src/components/DBHeatmapChart.tsx:1029uplotRef is set by uplot-react but never nulled on unmount. With key={JSON.stringify(config)} recreations, theoretically the bounds effect could fire against a torn-down instance during a recreate seam. → add useEffect(() => () => { uplotRef.current = null; }, []) and an if (!u.root?.isConnected) return; guard inside applySelectionToChart. Belt-and-braces; harmless today.

P2 packages/app/src/components/DBHeatmapChart.tsx:1102-1119numberFormatKey = JSON.stringify(numberFormat) works but the 13-line comment justifying it is itself a smell, and key-order divergence between callers could miss cache hits. The comment already flags the function-field risk. → consider a [output, mantissa, unit, ...].join('|') keyed on the known NumberFormat fields (confirmed plain primitives in packages/common-utils/src/types.ts). Cheaper, more robust, lets the comment shrink.

P2 packages/app/src/components/DBHeatmapChart.tsx JSDoc on applySelectionToChart (around line 38-43) — comment implies "renderer clamps to chart's visible y-axis floor" applies generally, but the floor-clamp is only the log-scale path; linear silently passes yMin through Math.max(yMin, yScaleMin) which can shrink the rectangle if yScaleMin > 0. Behavior is correct, doc is misleading. → tighten to "log scale clamps yMin=0 to axis floor; linear passes yMin literally".

P2 packages/app/src/components/DBHeatmapChart.tsx (bounds useEffect + ready hook) — on chart create with non-null bounds, both paths apply (ready then the bounds effect). Idempotent with fireHook=false, but each recreation costs two valToPos reads. → harmless; if you trim, prefer keeping ready (handles recreations) and dropping the bounds effect's mount-time apply via a useRef guard, or just leave it and add a one-line comment so the next maintainer doesn't "fix" one path.

Skipped as not actionable: the simplicity reviewer's suggestion to delete the memos on generatedTsBuckets / heatmapData — those are load-bearing for the fix (preventing uplot-react's recreate path that wipes u.select), and the author's comments correctly call this out.

alex-fedotyev pushed a commit that referenced this pull request May 6, 2026
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.
@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

P3 nits addressed in 287183d (clarifying comments only, no behavioural change):

  • 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
  • Trailing comment on the deps-less ref-mirroring useEffect so it doesn't read as "missing deps" at a glance
  • numberFormatKey: documented the JSON-serializability assumption with a note to switch to a shallow-equal helper if NumberFormat ever grows a function-valued field

alex-fedotyev and others added 8 commits May 8, 2026 00:18
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.
@alex-fedotyev alex-fedotyev force-pushed the alex/HDX-4147-heatmap-select-persist branch from 287183d to 41b87cc Compare May 8, 2026 00:18
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

Deep Review

🔴 P0/P1 -- must fix

  • packages/app/src/components/DBHeatmapChart.tsx:37-115 -- The new applySelectionToChart helper is the riskiest math in the PR (URL-seconds → uPlot-ms, log/linear branching, scale clamping, yMin <= 0 bottom-bucket sentinel) and has zero unit tests despite being a pure function trivially exercised with a stub u.
    • Fix: Add unit tests covering each branch (null clear, scales-not-populated early-return, log vs linear, yMin === 0 clamp to yScaleMin, xMin * 1000 conversion, reversed xMin > xMax via Math.min/max) using a hand-rolled stub exposing valToPos, setSelect, scales.y.
    • testing, correctness, kieran-typescript

🟡 P2 -- recommended

  • packages/app/src/components/DBHeatmapChart.tsx:1052-1056 -- Persisted rectangle goes stale after a container resize: useElementSize triggers u.setSize (not a full recreate), the ready hook does not re-fire, and the bounds effect's deps ([selectionBounds, scaleType]) don't change, so the pixel-space u.select is left at the old geometry. The PR's stated intent explicitly includes "across resize."

    • Fix: Add width and height (from useElementSize) to the reapply effect's deps, or call applySelectionToChart from a uPlot setSize hook so the rectangle is reprojected onto the new pixel layout.
    • julik-frontend-races
  • packages/app/src/components/DBHeatmapChart.tsx:1037-1056, 1242-1248 -- On a recreation that flips both selectionBounds and an options dep in the same commit (e.g. theme switch coincident with a URL update), the ready hook fires inside uplot-react's child effect and reads selectionBoundsRef.current before the parent's deps-less mirror effect updates it -- one paint frame draws the previous-render bounds before the bounds effect overwrites with the correct value.

    • Fix: Pre-populate the refs synchronously during render (assignment in render is fine for prop mirrors), or call applySelectionToChart from onCreate with the freshly created instance, instead of relying on ready reading from refs that haven't been mirrored yet.
    • julik-frontend-races, correctness
🔵 P3 nitpicks (8)
  • packages/app/src/components/DBHeatmapChart.tsx:76-84 -- The early-return guard at applySelectionToChart checks only u.scales.y, but the function calls u.valToPos(xMin * 1000, 'x') two lines earlier; if x scales are also unpopulated (or become so first under a future scale-init order), xMinPx/xMaxPx go NaN and silently feed setSelect.

    • Fix: Move the xMinPx/xMaxPx computation below a symmetric guard that also validates u.scales.x?.min/max, or add an explicit Number.isFinite check before calling setSelect.
    • correctness, julik-frontend-races
  • packages/app/src/components/DBHeatmapChart.tsx:1079-1082 -- numberFormatKey reads like a React key prop -- especially next to key={JSON.stringify(config)} on line 852 -- but it's a content fingerprint used as a useCallback dep.

    • Fix: Rename to numberFormatFingerprint (or numberFormatHash) and update the tickFormatter dep array so the role is unambiguous.
  • packages/app/src/components/Search/DBSearchHeatmapChart.tsx:148-159 -- The tickFormatter exhaustive-deps disable plus the JSON.stringify(numberFormat) fingerprint exists only because the caller builds a fresh numberFormat literal every render; memoizing the literal at the boundary collapses the disable, the fingerprint memo, and a ~13-line caveat comment in one shot.

    • Fix: Wrap the inline ({ output: 'duration', factor: 0.001 } satisfies NumberFormat) in a useMemo keyed on [fields.value, source], then revert tickFormatter to plain [numberFormat, scaleType] deps and remove the numberFormatKey machinery.
    • maintainability, kieran-typescript
  • packages/app/src/components/DBHeatmapChart.tsx:1037-1056, 1242-1248 -- Selection mirroring uses three coordinated mechanisms (deps-less ref mirror, [selectionBounds, scaleType] effect, uPlot ready hook reading refs) for a single side effect; each path is justified by a comment, but a future maintainer must mentally merge three call sites to reason about reapply timing.

    • Fix: Extract into a small useSelectionMirror(uplotRef, selectionBounds, scaleType) hook that returns the ready hook function, localizing the coupling and making the three pieces visibly one feature.
    • maintainability
  • packages/app/src/components/DBHeatmapChart.tsx:1052-1056 -- After a drag-end, the natively drawn rectangle is overwritten by a recomputed-from-data-space setSelect call, producing 1-2px jitter from the posToVal → URL float → valToPos round-trip plus the Math.log/exp and clamp steps in log mode.

    • Fix: In the setSelect hook, stash the just-drawn pixel rect on a ref and short-circuit the next applySelectionToChart call when the incoming bounds match the dragged rect within tolerance.
    • julik-frontend-races
  • packages/app/src/components/DBHeatmapChart.tsx:59-71 -- applySelectionToChart clears u.select unconditionally on bounds == null; if the parent nulls bounds during an in-flight drag (e.g. an auto-refreshing date range tick on a live-tail dashboard), the cursor's drag preview disappears mid-motion.

    • Fix: Skip the clear branch when justDraggedAtRef is fresh, or check u.cursor.drag.x/u.cursor.drag.y before issuing the setSelect({0,0,0,0}, false) call.
    • julik-frontend-races
  • packages/app/src/components/DBHeatmapChart.tsx:86-97 -- The yMin sentinel intentionally widens the visual rectangle to the chart floor when the user's drag touched the bottom bucket, but the visible jump after mouseup (rectangle snaps downward past the cursor) may surprise users; this is design-intentional but worth confirming.

    • Fix: If the visual jump is undesired, store the original yMin in a parallel URL param so the rectangle paints to the user's actual cursor position while the SQL filter still uses the widened bound; otherwise add a one-line comment near applySelectionToChart reiterating the visual-vs-SQL split.
    • correctness
  • packages/app/src/components/DBHeatmapChart.tsx:1041-1044 -- The deps-less ref-mirror useEffect is the only no-deps effect in the file; the existing onFilterRef mirror at lines 1024-1026 uses [onFilter] deps for the same pattern, and the asymmetry costs the reader a beat to verify intent.

    • Fix: Add [selectionBounds, scaleType] to the deps array to match the surrounding ref-mirror style; behavior is unchanged because the refs always assign the latest value when those props change.
    • kieran-typescript

Reviewers (9): correctness, testing, maintainability, project-standards, kieran-typescript, julik-frontend-races, performance, agent-native, learnings-researcher.

Testing gaps:

  • No assertion that numberFormatKey's fingerprint contract holds -- the load-bearing claim that an equivalent-but-fresh numberFormat does not recreate the chart is unverified, and a future function-valued field on NumberFormat would silently break the memo with no signal.
  • No Playwright spec under packages/app/tests/e2e/ exercises heatmap drag-select persistence end-to-end (the existing clickhouse-dashboard.spec.ts only verifies the heatmap renders); the user-visible behavior the changeset advertises has no automated guard against re-regression.
  • No test asserts JSON.stringify(config) (the existing <Heatmap key=...> content hash) stays stable across renders when only URL state changes -- the entire fix depends on this invariant upstream.
  • No test for the dateRange change → clear effect in DBSearchHeatmapChart (prevDateRangeRef first-mount short-circuit), the theme-toggle-during-active-selection ordering, or the resize-with-active-selection scenario.
  • No coverage for heatmapData reference stability when only selectionBounds changes -- a regression in any memo dep (e.g. reintroducing dateRange[0]/dateRange[1] Date refs) would silently undo both the perf win and the persistence behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automerge compound-review review/tier-3 Standard — full human review required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant