perf(charts): eliminate redundant D3 rebuilds at mount#444
Merged
Conversation
Profiling the live site showed every chart runs 2-3 full (visually identical) D3 teardown/rebuild passes right after mount, each ~300ms of main-thread time. Two unstable identities cause this: - useThemeColors re-set themeColors via setTimeout(0) on mount even though the synchronous useState read already saw the correct computed styles (next-themes applies the theme class pre-hydration). The new object identity invalidates getCssColor -> layers -> full rebuild. Now only an actual resolvedTheme change re-reads colors; the setTimeout(0) is kept for real switches so the <html> class flip settles first. - useResponsiveChartDimensions produced a new dimensions object from the ResizeObserver's initial callback (same width the ref callback just measured) and from same-value height updates. Same-size updates now keep the previous object so React bails out. Both fixes are identity-only: real theme switches and real resizes behave exactly as before.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9aa141e. Configure here.
This was referenced Jun 12, 2026
iwanthue's force-vector clustering (quality 50 x 5 attempts) costs tens of milliseconds per call and showed up repeatedly in the profile's useMemo render paths whenever high-contrast colors recompute (mount, legend toggles, theme switches). The output is fully deterministic (seeded RNG) and independent of the key names - a vendor group's palette depends only on item count, vendor zone/ban mode, theme seed, and lightness bounds - so identical requests across renders, charts, and tabs now share one cached entry. Key space is tiny (vendors x themes x counts x 3 modes); no eviction needed. Existing color-quality tests (brand zones, bans, min distances) pass unchanged, confirming identical output.
This was referenced Jun 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Context
Field data (CrUX) has the site failing Core Web Vitals on INP (273 ms p75). A Firefox performance profile of the live inference tab shows every chart performs 2–3 full D3 teardown/rebuild passes immediately after mount, each ~300 ms of main-thread blocking — all rendering identical pixels. Two unstable identities in shared hooks are responsible.
Changes
useThemeColors— on mount,setThemeColors(getChartThemeColors())ran in asetTimeout(0)even though the synchronoususeStateinitializer already read the correct computed styles (next-themes applies the theme class in a blocking inline script before hydration). The new object identity invalidatesgetCssColor→ every chart'slayersmemo → full rebuild. Now:resolvedThemeis recorded and skipped (no state update, no rebuild)setTimeout(0)(load-bearing: lets the<html>class flip settle before consumers re-resolve CSS variables)useResponsiveChartDimensions— the ResizeObserver fires once right afterobserve()with the same width the ref callback just measured, and the hook produced a freshdimensionsobject for it;dimensionsis a dependency of the chart render effect, so that initial observation triggered a second full rebuild. Same-size updates now return the previous object so React bails out. Same guard applied to the height-sync effect.Both fixes are identity-only — real theme switches and real resizes behave exactly as before.
Impact
Removes ~600-900 ms of main-thread blocking from initial chart mount (2 charts × 1-2 redundant ~300 ms rebuilds), benefiting every D3 chart (inference, GPU timeline, evaluation/reliability bars, trends, calculator).
Tests
useThemeColors.test.ts— identity stability across mount effects and the next-themes hydration sequence (regression tests: fail against the old implementation), re-read on real theme change, no re-read on same-theme re-render, vendor color resolution, high-contrast color map.useResponsiveChartDimensions.test.ts— same-size observation keeps identity (regression test: fails against old implementation), new width updates, observer cleanup on container change/detach.pnpm typecheck,pnpm lint,pnpm fmtclean.Not chart-data-path-specific, so no overlay-specific behavior change: unofficial-run overlays render through the same D3Chart mount path and simply stop double-rebuilding too.
Note
Low Risk
Performance-only changes with identity guards and deterministic caching; behavior for real theme switches and resizes is preserved and heavily regression-tested.
Overview
Cuts redundant full D3 chart rebuilds after mount by keeping shared hook outputs referentially stable when values do not actually change, and by caching expensive high-contrast palette generation.
useThemeColorsno longer re-runsgetChartThemeColors()on the first resolved theme from next-themes (hydration already matches the initialuseStateread). Real theme switches still refresh viasetTimeout(0)so CSS variables settle before charts re-resolve colors.useResponsiveChartDimensionsroutes width/height updates through a guard that returns the previousdimensionsobject when width and height are unchanged—avoiding a spurious rebuild from ResizeObserver’s initial callback afterobserve(). The height-only effect uses the same pattern.generateHighContrastColorsadds a module-levelPALETTE_CACHEkeyed by vendor, theme, count, and palette mode so repeatediwanthueclustering is skipped; output colors stay the same.New Vitest/jsdom suites cover mount stability, resize observer behavior, theme hydration, and cache hit/miss behavior for
iwanthue.Reviewed by Cursor Bugbot for commit 0d7c798. Bugbot is set up for automated code reviews on this repo. Configure here.
Addendum: iwanthue palette caching (second commit)
Same color-subsystem, same goal (stop repeating identical expensive work): `generateHighContrastColors` runs iwanthue's force-vector clustering (quality 50 × 5 attempts, tens of ms) on every recompute — visible in the profile's render-path `useMemo` stacks at mount and on every high-contrast legend/theme change. The output is deterministic (seeded) and independent of key names — a vendor group's palette depends only on (vendor, theme, count, ban/preferred mode, lightness bounds) — so palettes are now cached in a module-level map keyed by exactly those inputs. Key space is tiny; no eviction.
Tests: 3 new cache tests using a `{ spy: true }` mock of iwanthue (repeat request → no re-run, fails without the cache; name-independence → cache hit with distinct colors; different count → new entry). All 30+ pre-existing color-quality tests (brand zones, banned hues, min pairwise distances, determinism) pass unchanged — output colors are identical.