diff --git a/.changeset/presentation-subscriber-reentry.md b/.changeset/presentation-subscriber-reentry.md new file mode 100644 index 00000000..39cdc140 --- /dev/null +++ b/.changeset/presentation-subscriber-reentry.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': patch +--- + +Keep presentation subscribers and animation completion on the newest scene when a subscriber renders another scene. diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index bc22e4f0..ef1b050a 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:df88fde6d476b4ebe294c688d0cf692234d8d5aa441f583c6a36c40f5465725c" }, "chartjs": { "kind": "package", diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 184adbcd..3bd1e964 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "e62990d1bc2ce6a38a60f900c2f8c32d8048c57ccd11c5be4f8d673ec9fcb9ad", + "sourceHash": "901349e0d4c70f85ace00303b88ca3e643cc2d3d39a14fab7c54b2da6a4be966", "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..519a5a40 100644 --- a/packages/charts-core/src/motion.test.ts +++ b/packages/charts-core/src/motion.test.ts @@ -31,6 +31,83 @@ const rows = [ { id: 'b', category: 'B', value: 80 }, ] +describe('presentation subscriber re-entry', () => { + it.each(['snap', 'initial', 'mid', 'finish', 'cancel'])( + 'keeps the newest render after %s notification re-entry', + (phase) => { + const frames = installManagedFrames() + const makeScene = (revision: number) => + createChartScene( + defineChart({ + marks: [dot([{ x: 1, y: revision, revision }], { x: 'x', y: 'y' })], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, + guides: false, + }), + { width: 400, height: 200 }, + ) + const container = document.createElement('div') + const surface = motion<{ x: number; y: number; revision: number }>({ + initial: false, + transition: { + type: 'tween', + duration: phase === 'snap' ? 0 : 100, + easing: 'linear', + }, + }).mount(container, () => {}) + const third = makeScene(3) + let armed = phase === 'snap' || phase === 'initial' + let reentered = false + const observations: number[] = [] + try { + surface.render(makeScene(1), { ariaLabel: 'one' }) + surface.subscribePresentationPoints?.((points) => { + if (armed && !reentered && points[0]?.datum.revision === 2) { + reentered = true + surface.render(third, { ariaLabel: 'three' }) + } + }) + surface.subscribePresentationPoints?.((points) => { + observations.push(points[0]!.datum.revision) + }) + surface.render(makeScene(2), { ariaLabel: 'two' }) + if (phase === 'cancel') { + armed = true + surface.render(makeScene(4), { ariaLabel: 'four' }) + } + if (phase !== 'snap') { + frames.run(0) + if (phase === 'mid') armed = true + frames.run(50) + armed = true + frames.run(100) + if (frames.pending()) frames.run(200) + if (frames.pending()) frames.run(400) + } + const expected = phase === 'cancel' ? 4 : 3 + expect(reentered).toBe(phase !== 'cancel') + expect(observations).toContain(expected) + expect( + observations.slice(observations.indexOf(expected)), + ).not.toContain(2) + expect(container.querySelector('svg')?.getAttribute('aria-label')).toBe( + phase === 'cancel' ? 'four' : 'three', + ) + expect(surface.getPresentationPoints?.()).toBeUndefined() + expect( + Number(container.querySelector('circle')?.getAttribute('cy')), + ).toBeCloseTo(makeScene(expected).points[0]!.y) + } finally { + surface.destroy() + expect(frames.pending()).toBe(0) + frames.restore() + } + }, + ) +}) + describe('motion resize policy', () => { it.each([false, true])( 'handles a resize during entrance with resize: %s', diff --git a/packages/charts-core/src/motion.ts b/packages/charts-core/src/motion.ts index a0656cb3..2676dfc0 100644 --- a/packages/charts-core/src/motion.ts +++ b/packages/charts-core/src/motion.ts @@ -828,6 +828,7 @@ function createMotionSvgChartRenderer< let stateTransitions: Readonly> | undefined let stateScene: ChartScene | undefined + let rendering = false let dataMotionRevision = 0 let dataMotionActive = false let stateFlushQueued = false @@ -861,7 +862,12 @@ function createMotionSvgChartRenderer< points: readonly ChartPoint[], ) => { presentationPoints = points - for (const listener of presentationListeners) listener(points) + if (rendering) return + const revision = dataMotionRevision + for (const listener of presentationListeners) { + if (revision !== dataMotionRevision) break + listener(points) + } } const queuePendingStateFocus = () => { if (stateFlushQueued || !pendingStateFocus) return @@ -953,105 +959,116 @@ function createMotionSvgChartRenderer< }) return presented.scene } + function renderScene( + nextScene: ChartScene, + options: ChartSurfaceRenderOptions, + ) { + const previousScene = scene + const initial = previousScene === undefined + const resized = Boolean( + previousScene && + (previousScene.width !== nextScene.width || + previousScene.height !== nextScene.height), + ) + const reduced = + motion.respectReducedMotion && + (container.ownerDocument.defaultView?.matchMedia?.( + '(prefers-reduced-motion: reduce)', + ).matches ?? + false) + const animate = + !reduced && + (initial + ? motion.initial && (!adoptedRoot || motion.initial === 'always') + : motion.resize || !resized) + const viewportMoved = Boolean( + previousScene && viewportTranslationChanged(previousScene, nextScene), + ) + const markup = renderSvg(nextScene, options) + cancelAnimation() + const previousPresentation = + presentationPoints ?? previousScene?.points ?? [] + cancelFocusAnimation() + cancelFocusAnimation = () => {} + const retainsFocusGuideLayers = Boolean( + previousScene?.focusGuides?.length, + ) + const focusGuideLayers = retainsFocusGuideLayers + ? detachSvgFocusGuideLayers(svgElement()) + : {} + for (const placement of visibleFocusGuides) { + if ( + !nextScene.focusGuides?.some( + (guide) => guide.placement === placement, + ) + ) { + visibleFocusGuides.delete(placement) + } + } + const revision = ++dataMotionRevision + stateScene = undefined + stateTransition = undefined + scene = nextScene + renderOptions = options + dataMotionActive = animate && !viewportMoved + pendingStateFocus = dataMotionActive ? desiredStateFocus : undefined + if (animate && !viewportMoved) { + if (initial) reconcileChartSvg(container, markup) + cancelAnimation = motion.animateSvg({ + container, + scene: nextScene as ChartScene, + previousScene: previousScene as ChartScene | undefined, + presentationPoints: + previousPresentation as readonly ChartPoint[], + markup, + phase: initial ? 'initial' : 'update', + setPresentationPoints(points) { + publishPresentationPoints( + points as readonly ChartPoint[], + ) + if ( + revision === dataMotionRevision && + points === nextScene.points + ) { + dataMotionActive = false + queuePendingStateFocus() + } + }, + }) + } else { + reconcileChartSvg(container, markup) + publishPresentationPoints(nextScene.points) + dataMotionActive = false + } + if (retainsFocusGuideLayers) { + restoreSvgFocusGuideLayers( + svgElement(), + focusGuideLayers, + (placement) => + nextScene.focusGuides?.some( + (guide) => guide.placement === placement, + ) === true, + ) + } + scene = nextScene + stateScene = undefined + renderOptions = options + stateTransition = undefined + stateTransitions = undefined + } const surface: ChartSurface = { renderer: ownerRenderer ?? renderer, get element() { return svgElement() }, render(nextScene, options) { - const previousScene = scene - const initial = previousScene === undefined - const resized = Boolean( - previousScene && - (previousScene.width !== nextScene.width || - previousScene.height !== nextScene.height), - ) - const reduced = - motion.respectReducedMotion && - (container.ownerDocument.defaultView?.matchMedia?.( - '(prefers-reduced-motion: reduce)', - ).matches ?? - false) - const animate = - !reduced && - (initial - ? motion.initial && (!adoptedRoot || motion.initial === 'always') - : motion.resize || !resized) - const viewportMoved = Boolean( - previousScene && - viewportTranslationChanged(previousScene, nextScene), - ) - const markup = renderSvg(nextScene, options) - cancelAnimation() - const previousPresentation = - presentationPoints ?? previousScene?.points ?? [] - cancelFocusAnimation() - cancelFocusAnimation = () => {} - const retainsFocusGuideLayers = Boolean( - previousScene?.focusGuides?.length, - ) - const focusGuideLayers = retainsFocusGuideLayers - ? detachSvgFocusGuideLayers(svgElement()) - : {} - for (const placement of visibleFocusGuides) { - if ( - !nextScene.focusGuides?.some( - (guide) => guide.placement === placement, - ) - ) { - visibleFocusGuides.delete(placement) - } - } - const revision = ++dataMotionRevision - stateScene = undefined - stateTransition = undefined - scene = nextScene - renderOptions = options - dataMotionActive = animate && !viewportMoved - pendingStateFocus = dataMotionActive ? desiredStateFocus : undefined - if (animate && !viewportMoved) { - if (initial) reconcileChartSvg(container, markup) - cancelAnimation = motion.animateSvg({ - container, - scene: nextScene as ChartScene, - previousScene: previousScene as ChartScene | undefined, - presentationPoints: - previousPresentation as readonly ChartPoint[], - markup, - phase: initial ? 'initial' : 'update', - setPresentationPoints(points) { - publishPresentationPoints( - points as readonly ChartPoint[], - ) - if ( - revision === dataMotionRevision && - points === nextScene.points - ) { - dataMotionActive = false - queuePendingStateFocus() - } - }, - }) - } else { - reconcileChartSvg(container, markup) - publishPresentationPoints(nextScene.points) - dataMotionActive = false - } - if (retainsFocusGuideLayers) { - restoreSvgFocusGuideLayers( - svgElement(), - focusGuideLayers, - (placement) => - nextScene.focusGuides?.some( - (guide) => guide.placement === placement, - ) === true, - ) + rendering = true + try { + renderScene(nextScene, options) + } finally { + rendering = false } - scene = nextScene - stateScene = undefined - renderOptions = options - stateTransition = undefined - stateTransitions = undefined + if (presentationPoints) publishPresentationPoints(presentationPoints) }, clientToScene(currentScene, clientX, clientY) { return svgClientToScene(svgElement(), currentScene, clientX, clientY) @@ -3994,6 +4011,7 @@ function runTracks( } } lifecycle.publish?.() + if (cancelled) return root.dataset.tsMotionProgress = String(finished.size / activeTracks.length) if (finished.size < activeTracks.length && elapsed < safetyLimit) { frame = view.requestAnimationFrame(tick) @@ -4003,6 +4021,7 @@ function runTracks( if (!finished.has(track)) completeMotionTrack(track) } lifecycle.finish?.() + if (cancelled) return root.dataset.tsMotionState = 'finished' root.dataset.tsMotionProgress = '1' } diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 3026af4a..7f7f784d 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_286 / 1024, { rendererBoundary: 'svg', inputBoundary: {