Skip to content

Commit 1a31abd

Browse files
perf(charts): skip identity zoom-transform replay on chart rebuild (#445)
setupZoom unconditionally replayed the stored zoom transform after re-attaching the zoom behavior. zoom.transform dispatches start/zoom/end synchronously, and the chart's zoom handler answers with a full axes + grid + every-layer re-render — so every chart rebuild rendered everything twice, even when the user had never zoomed (identity transform, the overwhelmingly common case). Profiling the live site shows this doubles the cost of every ~250-490ms rebuild long task. Replay now happens only when there is actually a zoom to restore (stored transform or node state non-identity). Zoom preservation across rebuilds — the reason the replay exists (docs/d3-charts.md 'Zoom Transform Preservation') — is unchanged: non-identity transforms replay exactly as before, including charts with a non-1 defaultZoomK. The identity replay had one observable side effect: its emit dismissed a pinned tooltip on every rebuild. useD3ChartRenderer now performs that dismissal explicitly when the replay is skipped, so behavior is identical. Co-authored-by: Alec Ibarra <93070681+adibarra@users.noreply.github.com>
1 parent dc7dadd commit 1a31abd

3 files changed

Lines changed: 166 additions & 2 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// @vitest-environment jsdom
2+
import { act, createElement } from 'react';
3+
import { createRoot, type Root } from 'react-dom/client';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
import * as d3 from 'd3';
6+
7+
import { useChartZoom, type UseChartZoomResult } from './useChartZoom';
8+
9+
// Lightweight renderHook — TLR isn't installed, so we mount a 1-component root
10+
// and capture the latest hook return value in a ref-style object.
11+
function renderHook<T>(hook: () => T): { result: { current: T }; unmount: () => void; root: Root } {
12+
const result = { current: undefined as unknown as T };
13+
function TestComponent() {
14+
result.current = hook();
15+
return null;
16+
}
17+
const container = document.createElement('div');
18+
document.body.append(container);
19+
const root = createRoot(container);
20+
act(() => {
21+
root.render(createElement(TestComponent));
22+
});
23+
return {
24+
result,
25+
unmount: () => {
26+
act(() => root.unmount());
27+
container.remove();
28+
},
29+
root,
30+
};
31+
}
32+
33+
function setup(defaultZoomK?: number) {
34+
const svgEl = d3.create('svg:svg').node()! as SVGSVGElement;
35+
document.body.append(svgEl);
36+
const svgRef = { current: svgEl };
37+
const rendered = renderHook<UseChartZoomResult>(() =>
38+
useChartZoom({
39+
resetEventName: 'test_zoom_reset',
40+
scaleExtent: [0.5, 20],
41+
svgRef,
42+
defaultZoomK,
43+
}),
44+
);
45+
return {
46+
svgEl,
47+
svgSelection: d3.select(svgEl) as d3.Selection<SVGSVGElement, unknown, null, undefined>,
48+
hook: rendered.result,
49+
cleanup: () => {
50+
rendered.unmount();
51+
svgEl.remove();
52+
},
53+
};
54+
}
55+
56+
afterEach(() => {
57+
vi.restoreAllMocks();
58+
});
59+
60+
describe('setupZoom transform replay', () => {
61+
it('does not emit a zoom event when the stored transform is identity', () => {
62+
const { svgSelection, hook, cleanup } = setup();
63+
const onZoom = vi.fn();
64+
65+
hook.current.setupZoom(svgSelection, 800, 600, { onZoom });
66+
67+
// Drawing just happened at base scales; replaying identity would force the
68+
// chart's zoom handler through a full axes + grid + layers pass for
69+
// pixel-identical output.
70+
expect(onZoom).not.toHaveBeenCalled();
71+
cleanup();
72+
});
73+
74+
it('replays a non-identity stored transform on re-setup (zoom preservation)', () => {
75+
const { svgSelection, hook, cleanup } = setup();
76+
const firstOnZoom = vi.fn();
77+
const zoom = hook.current.setupZoom(svgSelection, 800, 600, { onZoom: firstOnZoom });
78+
79+
// User zooms in: 2x around an offset.
80+
const userTransform = d3.zoomIdentity.translate(-100, -50).scale(2);
81+
svgSelection.call(zoom.transform as any, userTransform);
82+
expect(firstOnZoom).toHaveBeenCalledTimes(1);
83+
expect(hook.current.zoomTransformRef.current.k).toBe(2);
84+
85+
// Chart rebuilds (data change) and re-runs setupZoom: the stored zoom must
86+
// be replayed exactly once so the freshly drawn DOM matches the zoom state.
87+
const secondOnZoom = vi.fn();
88+
hook.current.setupZoom(svgSelection, 800, 600, { onZoom: secondOnZoom });
89+
90+
expect(secondOnZoom).toHaveBeenCalledTimes(1);
91+
const replayed = secondOnZoom.mock.calls[0][0].transform;
92+
expect(replayed.k).toBe(2);
93+
expect(replayed.x).toBe(-100);
94+
expect(replayed.y).toBe(-50);
95+
cleanup();
96+
});
97+
98+
it('keeps zoomTransformRef in sync after the replay', () => {
99+
const { svgSelection, hook, cleanup } = setup();
100+
const zoom = hook.current.setupZoom(svgSelection, 800, 600, {});
101+
svgSelection.call(zoom.transform as any, d3.zoomIdentity.scale(4));
102+
103+
hook.current.setupZoom(svgSelection, 800, 600, {});
104+
105+
expect(hook.current.zoomTransformRef.current.k).toBe(4);
106+
cleanup();
107+
});
108+
109+
it('replays when the node state disagrees with the stored ref (defensive sync)', () => {
110+
const { svgEl, svgSelection, hook, cleanup } = setup();
111+
const onZoom = vi.fn();
112+
113+
// Stored ref says identity but someone left a stale transform on the node.
114+
(svgEl as unknown as { __zoom: d3.ZoomTransform }).__zoom = d3.zoomIdentity.scale(3);
115+
hook.current.setupZoom(svgSelection, 800, 600, { onZoom });
116+
117+
// The replay normalizes the node back to the stored (identity) transform.
118+
expect(onZoom).toHaveBeenCalledTimes(1);
119+
expect(d3.zoomTransform(svgEl).k).toBe(1);
120+
cleanup();
121+
});
122+
123+
it('replays a non-identity defaultZoomK on first setup', () => {
124+
const { svgSelection, hook, cleanup } = setup(1.5);
125+
const onZoom = vi.fn();
126+
127+
hook.current.setupZoom(svgSelection, 800, 600, { onZoom });
128+
129+
// Charts that declare a default zoom level still get their initial
130+
// transform applied — only the no-op identity replay is skipped.
131+
expect(onZoom).toHaveBeenCalledTimes(1);
132+
expect(onZoom.mock.calls[0][0].transform.k).toBe(1.5);
133+
cleanup();
134+
});
135+
});

packages/app/src/hooks/useChartZoom.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,25 @@ export function useChartZoom(options: UseChartZoomOptions): UseChartZoomResult {
181181
// store zoom behavior in ref
182182
zoomRef.current = zoom;
183183

184-
// restore previous zoom transform
185-
svg.call(zoom.transform as any, zoomTransformRef.current);
184+
// Restore the previous zoom transform — but only when there is actually
185+
// a zoom to restore. `zoom.transform` dispatches start/zoom/end events
186+
// synchronously, and the chart's zoom handler answers with a full
187+
// axes + grid + every-layer re-render. Charts call setupZoom right after
188+
// drawing at base scales, so replaying an identity transform repeats all
189+
// of that work to render the exact same pixels — on every rebuild.
190+
//
191+
// At identity nothing needs to move: attaching the behavior above
192+
// already initialized the node's internal `__zoom` state (d3-zoom
193+
// preserves an existing transform or defaults to identity), so internal
194+
// state and drawn state agree. The node-state check is defensive: if the
195+
// node somehow disagrees with our ref (it shouldn't — the `zoom.store`
196+
// listener keeps them in sync), fall through to the replay.
197+
const stored = zoomTransformRef.current;
198+
const nodeTransform = d3.zoomTransform(svg.node()!);
199+
const isIdentity = (t: d3.ZoomTransform) => t.k === 1 && t.x === 0 && t.y === 0;
200+
if (!isIdentity(stored) || !isIdentity(nodeTransform)) {
201+
svg.call(zoom.transform as any, stored);
202+
}
186203

187204
// double-click to reset zoom
188205
svg.on('dblclick.zoom', () => {

packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,18 @@ export function useD3ChartRenderer<T>(props: D3ChartProps<T>, deps: RendererDeps
446446
},
447447
},
448448
);
449+
450+
// setupZoom only replays the stored transform (re-emitting a zoom
451+
// event over the freshly drawn base-scale DOM) when it is non-identity.
452+
// The identity replay used to dismiss a pinned tooltip as a side
453+
// effect of that emit — keep that behavior when the replay is skipped,
454+
// since the chart under the tooltip was just rebuilt.
455+
const restored = zoomTransformRef.current;
456+
if (restored.k === 1 && restored.x === 0 && restored.y === 0 && isPinned()) {
457+
dismissTooltip(true);
458+
tooltip.style('opacity', 0).style('display', 'none').style('pointer-events', 'none');
459+
renderGroup.select('.ruler-group').style('display', 'none');
460+
}
449461
}
450462

451463
// ── Animate from old positions to new positions ──

0 commit comments

Comments
 (0)