From 92e98a563120554a917eeb36df6dd5f2b869474c Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 15 Oct 2026 23:29:11 -0600 Subject: [PATCH 1/2] fix(motion): preserve keyed elements returning during exit --- .changeset/keyed-exit-reentry.md | 5 + benchmarks/comparison/bundle-baseline.json | 4 +- benchmarks/conformance/previews/manifest.json | 2 +- docs/comparison.md | 4 +- packages/charts-core/docs/comparison.md | 4 +- packages/charts-core/src/motion.test.ts | 246 ++++++++++++++++++ packages/charts-core/src/motion.ts | 27 +- scripts/measure-bundles.mjs | 2 +- 8 files changed, 278 insertions(+), 16 deletions(-) create mode 100644 .changeset/keyed-exit-reentry.md diff --git a/.changeset/keyed-exit-reentry.md b/.changeset/keyed-exit-reentry.md new file mode 100644 index 00000000..54122c18 --- /dev/null +++ b/.changeset/keyed-exit-reentry.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': patch +--- + +Preserve keyed SVG elements and live point positions when an interrupted exit returns in the next update. diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index bc22e4f0..0a7b68f1 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "69d70960dd6b29f3706a173d348f2fc8271b0dcc", - "inputDigest": "sha256:80f5c5447f39757715be5def33eed28b12869946936369671243436701fb7964" + "revision": "9d806998e0140b87cce44e53e665a4115270c37e", + "inputDigest": "sha256:dd9fe57199ee171712a41f9f26014a5ec747e6eef1aac803a6dfc10c4ba33367" }, "chartjs": { "kind": "package", diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 1a36ca56..b8aade19 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "408fe8d6fb17b71b48ce505eb93dfe8d34afaf2b99dcd855c8b789d78e7cd486", + "sourceHash": "fb4ed12344a5525ded23ebeaddac1d07cd3867120ff6740815f07bf1c1761fbb", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index 61c55ce6..8a3107ac 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `69d7096` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `9d80699` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `69d7096`. +at page render time. The measured TanStack workspace revision is `9d80699`. ## Capability matrix diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 61c55ce6..8a3107ac 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `69d7096` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `9d80699` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `69d7096`. +at page render time. The measured TanStack workspace revision is `9d80699`. ## Capability matrix diff --git a/packages/charts-core/src/motion.test.ts b/packages/charts-core/src/motion.test.ts index ef4049b0..ac8c8016 100644 --- a/packages/charts-core/src/motion.test.ts +++ b/packages/charts-core/src/motion.test.ts @@ -4749,3 +4749,249 @@ function installManagedFrames() { }, } } + +describe('returning keyed points during exit', () => { + it.each([false, true])( + 'keeps opacity, geometry and presentation aligned, moving: %s', + (moving) => { + const frames = installManagedFrames() + const container = document.createElement('div') + const surface = motion({ + initial: false, + transition: { type: 'tween', duration: 1000, easing: 'linear' }, + }).mount(container, () => {}) + const makeScene = (present: boolean, radius = 20) => + createChartScene( + defineChart({ + marks: [ + dot( + [ + { id: 'b', x: 0.7 }, + ...(present + ? [{ id: 'a', x: moving && radius === 40 ? 0.8 : 0.3 }] + : []), + ], + { id: 'points', key: 'id', x: 'x', y: () => 0.5, r: radius }, + ), + ], + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 1]) }, + }, + guides: false, + }), + { width: 300, height: 200 }, + ) + try { + const first = makeScene(true) + const point = first.points.find((point) => point.datum.id === 'a') + if (!point) throw new Error('Missing returning point') + const find = () => + [...container.querySelectorAll('circle')].find( + (element) => element.getAttribute('data-ts-key') === point.key, + ) + surface.render(first, { ariaLabel: 'Returning point' }) + const element = find() + surface.render(makeScene(false), { ariaLabel: 'Exiting point' }) + frames.run(250) + expect(Number(element?.getAttribute('opacity'))).toBe(0.75) + surface.render(makeScene(true, 40), { ariaLabel: 'Returned point' }) + expect(find()).toBe(element) + expect(Number(find()?.getAttribute('opacity'))).toBe(0.75) + expect(Number(find()?.getAttribute('r'))).toBe(20) + expect( + surface + .getPresentationPoints?.() + ?.find((candidate) => candidate.key === point.key)?.x, + ).toBeCloseTo(Number(find()?.getAttribute('cx'))) + frames.run(750) + expect(Number(find()?.getAttribute('r'))).toBe(30) + expect( + surface + .getPresentationPoints?.() + ?.find((candidate) => candidate.key === point.key)?.x, + ).toBeCloseTo(Number(find()?.getAttribute('cx'))) + frames.run(1250) + expect(Number(find()?.getAttribute('r'))).toBe(40) + surface.render(makeScene(false), { ariaLabel: 'Removed point' }) + frames.run(2250) + expect(find()).toBeUndefined() + expect(frames.pending()).toBe(0) + } finally { + surface.destroy() + frames.restore() + } + }, + ) +}) + +describe('returning exit lifecycle', () => { + it.each(['nested', 'subset', 'incompatible', 'resize', 'absent'] as const)( + 'finishes a %s return without stale nodes', + (scenario) => { + const frames = installManagedFrames() + const container = document.createElement('div') + const surface = motion({ + initial: false, + transition: { type: 'tween', duration: 1000, easing: 'linear' }, + }).mount(container, () => {}) + const base = createChartScene( + defineChart({ + marks: [dot([{ x: 0, y: 0 }], { x: 'x', y: 'y' })], + guides: false, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 1]) }, + }, + }), + { width: 300, height: 200 }, + ) + const scene = (phase: 'initial' | 'exit' | 'return'): ChartScene => ({ + ...base, + points: [], + width: phase === 'return' && scenario === 'resize' ? 400 : 300, + nodes: [ + { + kind: 'group', + key: 'marks', + className: 'ts-chart__marks', + children: + phase === 'exit' + ? [] + : [ + { + kind: 'group', + key: 'outer', + children: [ + { + kind: 'group', + key: 'inner', + children: [ + phase === 'return' && scenario === 'incompatible' + ? { + kind: 'rect', + key: 'a', + x: 30, + y: 40, + width: 20, + height: 20, + } + : { + kind: 'dot', + key: 'a', + x: 40, + y: 50, + radius: 10, + }, + ...(phase === 'return' && scenario === 'subset' + ? [] + : [ + { + kind: 'dot' as const, + key: 'b', + x: 80, + y: 50, + radius: 10, + }, + ]), + ], + }, + ], + }, + ], + }, + ], + }) + const find = (key: string) => + container.querySelector(`[data-ts-key="${key}"]`) + try { + surface.render(scene('initial'), { ariaLabel: 'Nested return' }) + const outer = find('outer'), + leaf = find('a') + surface.render(scene('exit'), { ariaLabel: 'Nested exit' }) + frames.run(250) + expect(Number(outer?.getAttribute('opacity'))).toBe(0.75) + if (scenario !== 'absent') { + surface.render(scene('return'), { ariaLabel: 'Nested return' }) + expect(find('outer')).toBe(outer) + if (scenario !== 'incompatible') expect(find('a')).toBe(leaf) + } + if (scenario !== 'resize') frames.run(1250) + else expect(frames.pending()).toBe(0) + expect(container.querySelectorAll('[data-ts-key="a"]')).toHaveLength( + scenario === 'absent' ? 0 : 1, + ) + if (scenario === 'incompatible') + expect(find('a')?.localName).toBe('rect') + if (scenario === 'subset') expect(find('b')).toBeNull() + if (scenario !== 'absent') + expect(Number(find('outer')?.getAttribute('opacity') ?? 1)).toBe(1) + } finally { + surface.destroy() + expect(frames.pending()).toBe(0) + frames.restore() + } + }, + ) + + it.each([false, true])( + 'retains a rolling exit when normal keyed updates resume, dots: %s', + (points) => { + const frames = installManagedFrames() + const container = document.createElement('div') + const surface = motion<{ id: string; time: number; value: number }>({ + initial: false, + }).mount(container, () => {}) + const data = Array.from({ length: 7 }, (_, index) => ({ + id: String(index), + time: index, + value: 20 + index * 5, + })) + const scene = (start: number, rolling = true) => + createChartScene( + defineChart({ + motion: { + path: rolling ? { update: 'rolling', x: 'shift' } : undefined, + transition: { type: 'tween', duration: 1000, easing: 'linear' }, + }, + marks: [ + lineY(data.slice(start, start + 5), { + id: 'stream', + x: 'time', + y: 'value', + key: 'id', + points, + }), + ], + scales: { + x: { scale: scaleLinear().domain([start + 1, start + 4]) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, + guides: false, + clip: true, + }), + { width: 300, height: 200 }, + ) + try { + surface.render(scene(0), { ariaLabel: 'Rolling return' }) + surface.render(scene(1), { ariaLabel: 'Rolling exit' }) + frames.run(250) + const before = surface + .getPresentationPoints?.() + ?.find((point) => point.datum.id === '0') + expect(before?.x).toBe(-125) + surface.render(scene(0, false), { ariaLabel: 'Keyed return' }) + const after = surface + .getPresentationPoints?.() + ?.find((point) => point.datum.id === '0') + expect(after?.x).toBe(before?.x) + frames.run(1250) + expect(surface.getPresentationPoints?.()).toBeUndefined() + } finally { + surface.destroy() + expect(frames.pending()).toBe(0) + frames.restore() + } + }, + ) +}) diff --git a/packages/charts-core/src/motion.ts b/packages/charts-core/src/motion.ts index a0656cb3..0f82c20c 100644 --- a/packages/charts-core/src/motion.ts +++ b/packages/charts-core/src/motion.ts @@ -144,7 +144,7 @@ interface MotionTrack { values: MotionValueBinding[] apply: (values: readonly number[]) => void finish: () => void - cancel?: () => void + cancel?: (nextScene?: ChartScene, nextPoints?: ReadonlySet) => void } interface MotionAttribute { @@ -814,7 +814,7 @@ function createMotionSvgChartRenderer< mount(container) { const adoptedRoot = container.firstElementChild?.matches('svg.ts-chart') ?? false - let cancelAnimation = () => {} + let cancelAnimation: (nextScene?: ChartScene) => void = () => {} let cancelFocusAnimation = () => {} const visibleFocusGuides = new Set<'under' | 'over'>() let scene: ChartScene | undefined @@ -982,7 +982,7 @@ function createMotionSvgChartRenderer< viewportTranslationChanged(previousScene, nextScene), ) const markup = renderSvg(nextScene, options) - cancelAnimation() + cancelAnimation(nextScene) const previousPresentation = presentationPoints ?? previousScene?.points ?? [] cancelFocusAnimation() @@ -2647,7 +2647,9 @@ function addExitMotionTrack( const retargetLayer = element.closest( 'g[data-ts-focus-retarget]', ) - const cleanup = () => { + const cleanup = (nextScene?: ChartScene) => { + if (nextScene && sceneNodeContext(nextScene, timingContext?.key ?? '')) + return element.remove() if (retargetLayer && !retargetLayer.children.length) { retargetLayer.setAttribute('visibility', 'hidden') @@ -3452,8 +3454,12 @@ function createPresentationTracks( } } apply(from) - const cleanupExiting = () => { + const cleanupExiting = ( + _nextScene?: ChartScene, + nextPoints?: ReadonlySet, + ) => { for (const entry of exiting) { + if (nextPoints?.has(entry.identity)) continue presented.delete(entry.identity) if (runtime.points.get(entry.identity) === entry.state) { runtime.points.delete(entry.identity) @@ -3569,7 +3575,11 @@ function createPresentationTracks( ? markMotionRole(element, element) : 'mark' const state = runtime.points.get(identity) - const cleanup = () => { + const cleanup = ( + _nextScene?: ChartScene, + nextPoints?: ReadonlySet, + ) => { + if (nextPoints?.has(identity)) return presented.delete(identity) if (runtime.points.get(identity) === state) { runtime.points.delete(identity) @@ -4008,12 +4018,13 @@ function runTracks( } frame = view.requestAnimationFrame(tick) - return () => { + return (nextScene?: ChartScene) => { if (cancelled) return cancelled = true view.cancelAnimationFrame(frame) + const nextPoints = nextScene && new Set(nextScene.points.map(pointIdentity)) activeTracks.forEach((track) => { - if (!finished.has(track)) track.cancel?.() + if (!finished.has(track)) track.cancel?.(nextScene, nextPoints) }) lifecycle.publish?.() root.dataset.tsMotionState = 'cancelled' diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 3026af4a..e1da4300 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -1789,7 +1789,7 @@ const entries = [ budgeted( 'Motion SVG renderer', 'benchmarks/entries/charts-motion-svg-renderer.ts', - 23_240 / 1024, + 23_277 / 1024, { rendererBoundary: 'svg', inputBoundary: { From 016849cface316b64e479742dd6eb5f86a3ecab0 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 15 Oct 2026 23:31:14 -0600 Subject: [PATCH 2/2] docs: pin comparison provenance to verified motion source --- benchmarks/comparison/bundle-baseline.json | 2 +- docs/comparison.md | 4 ++-- packages/charts-core/docs/comparison.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 0a7b68f1..d689e699 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -11,7 +11,7 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "9d806998e0140b87cce44e53e665a4115270c37e", + "revision": "92e98a563120554a917eeb36df6dd5f2b869474c", "inputDigest": "sha256:dd9fe57199ee171712a41f9f26014a5ec747e6eef1aac803a6dfc10c4ba33367" }, "chartjs": { diff --git a/docs/comparison.md b/docs/comparison.md index 8a3107ac..13274994 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `9d80699` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `92e98a5` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `9d80699`. +at page render time. The measured TanStack workspace revision is `92e98a5`. ## Capability matrix diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 8a3107ac..13274994 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `9d80699` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `92e98a5` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `9d80699`. +at page render time. The measured TanStack workspace revision is `92e98a5`. ## Capability matrix