From 255db65bc31609387da2bac974f4aa7df0810280 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Wed, 14 Oct 2026 12:07:40 +0200 Subject: [PATCH 1/6] fix: redraw responsive charts at print width Print layout and headless PDF export resize the chart container without running an animation frame, so the resize redraw never happened and the browser scaled the screen-width SVG down. The DOM host now listens for the `print` media change, measures the container directly, and redraws synchronously when a container-owned dimension changed. Co-Authored-By: Claude Opus 5.5 --- .changeset/print-width-redraw.md | 5 ++ API-FRICTION.md | 27 +++++- .../bundle-size/universal-baseline.json | 16 ++-- docs/guides/responsive-charts.md | 6 ++ .../docs/guides/responsive-charts.md | 6 ++ packages/charts-core/src/renderer.test.ts | 85 +++++++++++++++++++ packages/charts-core/src/renderer.ts | 11 +++ scripts/measure-bundles.mjs | 12 +-- 8 files changed, 153 insertions(+), 15 deletions(-) create mode 100644 .changeset/print-width-redraw.md diff --git a/.changeset/print-width-redraw.md b/.changeset/print-width-redraw.md new file mode 100644 index 00000000..f623b564 --- /dev/null +++ b/.changeset/print-width-redraw.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': patch +--- + +Redraw container-responsive charts synchronously when the page enters or leaves print layout, so printed pages and `page.pdf()` exports use the print container width instead of a scaled screen drawing. diff --git a/API-FRICTION.md b/API-FRICTION.md index 09c20aac..cd3fe2c8 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent evaluations so later API, documentation, and TanStack Intent skill work is based on evidence. -Last updated: 2026-09-30 +Last updated: 2026-10-14 ## Triage rule @@ -351,6 +351,7 @@ Each entry records: | F-312 | Tick stubs could not be styled independently | API | resolved | | F-313 | Initial observer precision cancelled entrance motion | Tooling | resolved | | F-314 | Framework ticks and immediate focus layers were unclear | Documentation | resolved | +| F-315 | Printed charts kept their screen width | API | resolved | ## Findings @@ -9339,3 +9340,27 @@ Each entry records: direction changes. A focus dot appears during entrance while the data path and its running animation remain intact. Concurrent inline states remain a separate feature; this addresses the documented alternatives requested. + +### F-315 - Printed charts kept their screen width + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: a production React application that prints reports and exports + them with Playwright `page.pdf()`. The print layout narrowed the container, + but the resize redraw waited for an animation frame that print never runs. + The browser scaled the screen drawing down, shrinking text and strokes and + letterboxing fixed-height charts. +- Workaround: each application listened for the `print` media change, measured + the container, and passed it as a fixed `width` inside React `flushSync`, then + removed `width` when print ended. +- Decision: the shared DOM host listens for the `print` media change, discards + the last observer size, measures the container, and redraws synchronously + without motion when a container-owned dimension changed. A pending resize + frame later finds the size unchanged and skips. All adapters inherit it. No + public `resize()` was added; no task has needed one yet. +- Verification: a host regression narrows the container from 972 to 420 pixels + and redraws in the same task when the print media changes, before any frame + runs. Leaving print restores 972 despite a stale 420 observer size, unchanged + sizes do not redraw, and `destroy` removes the listener. The test fails + without the change. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index c3a99cd8..09f4f6bd 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -15,24 +15,24 @@ "gzip": 30414 }, "TanStack DOM host": { - "bytes": 82994, - "gzip": 29119 + "bytes": 83137, + "gzip": 29176 }, "React adapter": { - "bytes": 85319, - "gzip": 30074 + "bytes": 85462, + "gzip": 30122 }, "React line consumer": { - "bytes": 108978, - "gzip": 39424 + "bytes": 109121, + "gzip": 39446 }, "Compact-scale line scene": { "bytes": 35859, "gzip": 12797 }, "React compact-scale line consumer": { - "bytes": 91476, - "gzip": 32307 + "bytes": 91619, + "gzip": 32356 }, "Custom-scale line scene": { "bytes": 34043, diff --git a/docs/guides/responsive-charts.md b/docs/guides/responsive-charts.md index 40f45a2f..97a1cc78 100644 --- a/docs/guides/responsive-charts.md +++ b/docs/guides/responsive-charts.md @@ -41,6 +41,12 @@ the common requirement is `min-width: 0` on the grid or flex child: Supply `width` only when an application deliberately owns fixed geometry, such as an export frame or a benchmark. +Container resizes normally redraw on the next animation frame. Printing, and +headless exports such as Playwright's `page.pdf()`, lay out and paint the page +without running one. The DOM host therefore listens for the `print` media +change, measures the container, and redraws immediately when its size changed. +Printed charts use the print container width without application code. + ## Height and aspect ratio Use one of these policies: diff --git a/packages/charts-core/docs/guides/responsive-charts.md b/packages/charts-core/docs/guides/responsive-charts.md index 40f45a2f..97a1cc78 100644 --- a/packages/charts-core/docs/guides/responsive-charts.md +++ b/packages/charts-core/docs/guides/responsive-charts.md @@ -41,6 +41,12 @@ the common requirement is `min-width: 0` on the grid or flex child: Supply `width` only when an application deliberately owns fixed geometry, such as an export frame or a benchmark. +Container resizes normally redraw on the next animation frame. Printing, and +headless exports such as Playwright's `page.pdf()`, lay out and paint the page +without running one. The DOM host therefore listens for the `print` media +change, measures the container, and redraws immediately when its size changed. +Printed charts use the print container width without application code. + ## Height and aspect ratio Use one of these policies: diff --git a/packages/charts-core/src/renderer.test.ts b/packages/charts-core/src/renderer.test.ts index 74259ea1..6efe97a6 100644 --- a/packages/charts-core/src/renderer.test.ts +++ b/packages/charts-core/src/renderer.test.ts @@ -2600,6 +2600,91 @@ describe('renderer-neutral chart host', () => { cancelFrame.mockRestore() }) + it('redraws at the print container width without waiting for a frame', () => { + let resize: ResizeObserverCallback | undefined + let width = 972 + const frames: FrameRequestCallback[] = [] + class TestResizeObserver { + constructor(callback: ResizeObserverCallback) { + resize = callback + } + observe() {} + disconnect() {} + unobserve() {} + } + const printMedia = new EventTarget() as MediaQueryList + const originalResizeObserver = window.ResizeObserver + const originalMatchMedia = window.matchMedia + window.ResizeObserver = TestResizeObserver + window.matchMedia = vi.fn((query: string) => + query === 'print' ? printMedia : ({ matches: false } as MediaQueryList), + ) + const requestFrame = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + frames.push(callback) + return frames.length + }) + const addListener = vi.spyOn(printMedia, 'addEventListener') + const removeListener = vi.spyOn(printMedia, 'removeEventListener') + const fake = createFakeRenderer() + const container = document.createElement('div') + vi.spyOn(container, 'getBoundingClientRect').mockImplementation( + () => new DOMRect(0, 0, width, 320), + ) + const observe = () => + resize?.( + [ + { + target: container, + contentRect: new DOMRect(0, 0, width, 320), + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + }, + ], + {} as ResizeObserver, + ) + const host = mountChartRenderer(container, { + definition, + renderer: fake.renderer, + height: 320, + ariaLabel: 'Printed chart', + }) + expect(host.getScene().width).toBe(972) + + // The observer reports the print width, but no frame runs before paint. + width = 420 + observe() + expect(frames).toHaveLength(1) + printMedia.dispatchEvent(new Event('change')) + expect(host.getScene().width).toBe(420) + expect(fake.render).toHaveBeenCalledTimes(2) + frames.shift()?.(0) + expect(fake.render).toHaveBeenCalledTimes(2) + + // A stale observer size must not hide the restored screen width. + observe() + expect(frames).toHaveLength(0) + width = 972 + printMedia.dispatchEvent(new Event('change')) + expect(host.getScene().width).toBe(972) + expect(fake.render).toHaveBeenCalledTimes(3) + + printMedia.dispatchEvent(new Event('change')) + expect(fake.render).toHaveBeenCalledTimes(3) + + host.destroy() + expect(removeListener).toHaveBeenCalledWith( + 'change', + addListener.mock.calls[0]?.[1], + ) + + window.ResizeObserver = originalResizeObserver + window.matchMedia = originalMatchMedia + requestFrame.mockRestore() + }) + it.each([false, true])( 'adopts observer precision without repainting an unchanged initial layout (transformed border box: %s)', (transformed) => { diff --git a/packages/charts-core/src/renderer.ts b/packages/charts-core/src/renderer.ts index 8f49a7d8..fe1d9b64 100644 --- a/packages/charts-core/src/renderer.ts +++ b/packages/charts-core/src/renderer.ts @@ -431,6 +431,15 @@ export function mountChartRenderer< }) } + // Print layout can resize the container and paint without running an + // animation frame or delivering observer sizes, so measure it directly. + const handlePrintChange = () => { + observedContentSize = undefined + if (responsiveSizeChanged()) render(true, 'layout') + } + + const printMedia = view?.matchMedia?.('print') + const handleFontLoad = () => { if (destroyed || options.measureText) return domText.invalidate() @@ -971,6 +980,7 @@ export function mountChartRenderer< configureCursorController() render() configureObserver() + printMedia?.addEventListener?.('change', handlePrintChange) container.ownerDocument.addEventListener( 'pointerdown', dismissPinnedInteractionOutside, @@ -1051,6 +1061,7 @@ export function mountChartRenderer< cursorMatch = undefined cursorExtension = undefined fontSet?.removeEventListener?.('loadingdone', handleFontLoad) + printMedia?.removeEventListener?.('change', handlePrintChange) if (renderFrame !== undefined) { view?.cancelAnimationFrame?.(renderFrame) } diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 3026af4a..e6bcd390 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -1628,7 +1628,7 @@ const entries = [ budgeted( 'React line consumer + mark Canvas renderer', 'benchmarks/entries/charts-react-line-mark-canvas.ts', - 45.548828125, + 45.5888671875, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'mixed', @@ -1727,7 +1727,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 31.5498046875, + 31.59765625, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1895,7 +1895,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 57.3744609375, + 57.423828125, ), locked( 'Custom-scale line scene', @@ -1946,12 +1946,12 @@ const entries = [ budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 39.853515625, + 39.9052734375, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 45.1396484375, + 45.1806640625, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2155,7 +2155,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 58.4619140625, + 58.5126953125, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot minimal line', 'benchmarks/entries/minimal-line.ts'), From 2c505932a9f2853de86b6bd543991f1c1e0ab4cc Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Wed, 14 Oct 2026 13:00:14 +0200 Subject: [PATCH 2/6] test: cover print width redraw in browser conformance Add `printStart` and `printEnd` behavior steps. `printStart` holds animation frames, as browsers print without them, sizes the container under `@media print`, and emulates print media. The `34-pointer-tooltip` TanStack mount now follows its container so a `print-width` scenario can require the print `viewBox`. Scenarios can name the renderers that run them, since references do not redraw on print. Refresh the catalog index and preview fingerprint. Keep the committed tooltip previews, whose weekday labels depend on the local time zone. Co-Authored-By: Claude Opus 5.5 --- API-FRICTION.md | 21 ++++- .../cases/34-pointer-tooltip/case.json | 33 +++++++ .../cases/34-pointer-tooltip/tanstack.ts | 3 +- benchmarks/conformance/catalog-index.json | 44 ++++++++++ benchmarks/conformance/metadata.ts | 13 +++ benchmarks/conformance/types.ts | 13 +++ scripts/compare-plot-catalog.mjs | 86 ++++++++++++++++++- 7 files changed, 209 insertions(+), 4 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index cd3fe2c8..152e24d5 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -352,6 +352,7 @@ Each entry records: | F-313 | Initial observer precision cancelled entrance motion | Tooling | resolved | | F-314 | Framework ticks and immediate focus layers were unclear | Documentation | resolved | | F-315 | Printed charts kept their screen width | API | resolved | +| F-316 | Catalog previews depend on the local time zone | Tooling | open | ## Findings @@ -9363,4 +9364,22 @@ Each entry records: and redraws in the same task when the print media changes, before any frame runs. Leaving print restores 972 despite a stale 420 observer size, unchanged sizes do not redraw, and `destroy` removes the listener. The test fails - without the change. + without the change. In Chromium, the `34-pointer-tooltip` first-party + conformance scenario `print-width` holds animation frames, enters print media + with a 420 pixel container, and requires a 420 pixel `viewBox`, then restores + the screen width after print. It passes in all 12 variants and fails in all + 12 on the previous renderer. + +### F-316 - Catalog previews depend on the local time zone + +- Status: open +- Severity: low +- Owner: Tooling +- Observed in: refreshing the preview fingerprint for the print redraw change. + `pnpm catalog:previews` in Europe/Warsaw, and again with `TZ=UTC`, shifted + the weekday labels of nine shadcn tooltip previews by one day. The committed + previews were generated west of UTC. +- Workaround: keep the committed SVGs and their manifest records, and commit + only the new `sourceHash`. +- Follow-up: generate previews with a fixed time zone, or format those labels + in UTC, so regeneration is reproducible on any machine. diff --git a/benchmarks/conformance/cases/34-pointer-tooltip/case.json b/benchmarks/conformance/cases/34-pointer-tooltip/case.json index 523ca723..f84242d2 100644 --- a/benchmarks/conformance/cases/34-pointer-tooltip/case.json +++ b/benchmarks/conformance/cases/34-pointer-tooltip/case.json @@ -41,6 +41,39 @@ "assertions": [{ "path": "tooltip.visible", "equals": false }] } ] + }, + { + "id": "print-width", + "renderers": ["tanstack"], + "steps": [ + { "type": "printStart", "width": 420 }, + { + "type": "assertRendered", + "assertions": [ + { + "target": { "selector": "svg.ts-chart[viewBox^='0 0 420 ']" }, + "property": "count", + "equals": 1 + } + ] + }, + { "type": "printEnd" }, + { + "type": "assertRendered", + "assertions": [ + { + "target": { "selector": "svg.ts-chart[viewBox^='0 0 420 ']" }, + "property": "count", + "equals": 0 + }, + { + "target": { "selector": "svg.ts-chart" }, + "property": "count", + "equals": 1 + } + ] + } + ] } ], "source": { diff --git a/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts b/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts index d55096ad..a12c81be 100644 --- a/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts +++ b/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts @@ -34,7 +34,7 @@ export function mount( let focusedDates: string[] = [] const options: ChartHostOptions = { definition: createExampleChart(input), - width: input.width, + // The runner sizes the container, so its width also drives print layout. height: input.height, ariaLabel: exampleAriaLabel, onFocusGroupChange(points) { @@ -87,7 +87,6 @@ export function mount( host.update({ ...options, definition: createExampleChart(nextInput), - width: nextInput.width, height: nextInput.height, }) }, diff --git a/benchmarks/conformance/catalog-index.json b/benchmarks/conformance/catalog-index.json index 721ec1ca..0f72605a 100644 --- a/benchmarks/conformance/catalog-index.json +++ b/benchmarks/conformance/catalog-index.json @@ -1266,6 +1266,50 @@ ] } ] + }, + { + "id": "print-width", + "renderers": ["tanstack"], + "steps": [ + { + "type": "printStart", + "width": 420 + }, + { + "type": "assertRendered", + "assertions": [ + { + "target": { + "selector": "svg.ts-chart[viewBox^='0 0 420 ']" + }, + "property": "count", + "equals": 1 + } + ] + }, + { + "type": "printEnd" + }, + { + "type": "assertRendered", + "assertions": [ + { + "target": { + "selector": "svg.ts-chart[viewBox^='0 0 420 ']" + }, + "property": "count", + "equals": 0 + }, + { + "target": { + "selector": "svg.ts-chart" + }, + "property": "count", + "equals": 1 + } + ] + } + ] } ], "source": { diff --git a/benchmarks/conformance/metadata.ts b/benchmarks/conformance/metadata.ts index 2bf8cb9e..7471eefb 100644 --- a/benchmarks/conformance/metadata.ts +++ b/benchmarks/conformance/metadata.ts @@ -259,6 +259,13 @@ function isInteractionScenario( value !== null && 'id' in value && typeof value.id === 'string' && + (!('renderers' in value) || + (Array.isArray(value.renderers) && + value.renderers.length > 0 && + value.renderers.every( + (renderer) => + typeof renderer === 'string' && isConformanceRenderer(renderer), + ))) && 'steps' in value && Array.isArray(value.steps) && value.steps.length > 0 && @@ -350,6 +357,12 @@ function isInteractionStep( value.durationMs <= 5_000 ) } + if (value.type === 'printStart') { + return 'width' in value && isPositiveInteger(value.width) + } + if (value.type === 'printEnd') { + return true + } if (value.type === 'assert') { return ( 'assertions' in value && diff --git a/benchmarks/conformance/types.ts b/benchmarks/conformance/types.ts index 923d850b..9023435c 100644 --- a/benchmarks/conformance/types.ts +++ b/benchmarks/conformance/types.ts @@ -326,6 +326,17 @@ export type ConformanceInteractionStep = type: 'wait' durationMs: number } + | { + /** + * Enters print media with the container at `width` CSS pixels. Animation + * frames are held until `printEnd`, as browsers print without them. + */ + type: 'printStart' + width: number + } + | { + type: 'printEnd' + } | { type: 'assert' assertions: readonly ConformanceStateAssertion[] @@ -342,6 +353,8 @@ export type ConformanceInteractionStep = export interface ConformanceInteractionScenario { id: string + /** Renderers that run this scenario. Omit to run it for every renderer. */ + renderers?: readonly ConformanceRenderer[] steps: readonly ConformanceInteractionStep[] } diff --git a/scripts/compare-plot-catalog.mjs b/scripts/compare-plot-catalog.mjs index 3ba3c792..117a8dd0 100644 --- a/scripts/compare-plot-catalog.mjs +++ b/scripts/compare-plot-catalog.mjs @@ -407,6 +407,14 @@ function isValidGuideExpectation(expectation, referenceRenderer) { function isValidInteractionScenario(scenario) { return ( typeof scenario?.id === 'string' && + (scenario.renderers === undefined || + (Array.isArray(scenario.renderers) && + scenario.renderers.length > 0 && + scenario.renderers.every( + (renderer) => + renderer === targetRenderer || + referenceRenderers.includes(renderer), + ))) && Array.isArray(scenario.steps) && scenario.steps.length > 0 && scenario.steps.every(isValidInteractionStep) @@ -461,6 +469,10 @@ function isValidInteractionStep(step) { (step.deltaMode === undefined || ['pixel', 'line', 'page'].includes(step.deltaMode)) ) + case 'printStart': + return isPositiveInteger(step.width) + case 'printEnd': + return true case 'wait': return ( Number.isInteger(step.durationMs) && @@ -2567,9 +2579,14 @@ async function runBehaviorImplementation( browser, serverUrl, implementation, - scenarios, + caseScenarios, variant, ) { + const scenarios = caseScenarios.filter( + (scenario) => + !scenario.renderers || + scenario.renderers.includes(implementation.renderer), + ) const page = await browser.newPage({ viewport: { width: Math.max(variant.width + 96, 480), @@ -2714,6 +2731,7 @@ async function runBehaviorImplementation( } finally { try { if (mounted) { + await endBehaviorPrint(page) await cancelActiveBehaviorPointer(page) await page.evaluate(() => { globalThis.__conformanceBehavior?.handle.destroy() @@ -2762,6 +2780,14 @@ async function performBehaviorStep(page, step, variant, context) { assertions, } } + if (step.type === 'printStart') { + await startBehaviorPrint(page, step.width) + return { + type: step.type, + pass: true, + state: await readBehaviorState(page), + } + } if (step.type === 'screenshot') { const screenshot = await captureBehaviorScreenshot( page, @@ -2977,6 +3003,10 @@ async function performBehaviorStep(page, step, variant, context) { await page.waitForTimeout(step.durationMs) break } + case 'printEnd': { + await endBehaviorPrint(page) + break + } } await settleBehavior(page) @@ -3004,6 +3034,60 @@ async function clearBehaviorPointerTarget(page) { }) } +// Browsers lay out and paint print media without running animation frames. +// Hold them while printing so only work done synchronously reaches the page. +// The step skips settling, which would wait for a frame. +async function startBehaviorPrint(page, width) { + await page.evaluate((printWidth) => { + const behavior = globalThis.__conformanceBehavior + const held = [] + const style = document.createElement('style') + style.textContent = `@media print { [data-conformance-root] { width: ${printWidth}px !important; } }` + document.head.append(style) + behavior.print = { + held, + style, + requestAnimationFrame: window.requestAnimationFrame, + changed: printMediaChange(), + } + window.requestAnimationFrame = (callback) => held.push(callback) + + function printMediaChange() { + return new Promise((resolveChange) => { + matchMedia('print').addEventListener('change', resolveChange, { + once: true, + }) + }) + } + }, width) + await page.emulateMedia({ media: 'print' }) + await page.evaluate(() => globalThis.__conformanceBehavior.print.changed) +} + +async function endBehaviorPrint(page) { + const printing = await page.evaluate(() => { + const print = globalThis.__conformanceBehavior?.print + if (!print) return false + print.changed = new Promise((resolveChange) => { + matchMedia('print').addEventListener('change', resolveChange, { + once: true, + }) + }) + return true + }) + if (!printing) return + await page.emulateMedia({ media: null }) + await page.evaluate(async () => { + const behavior = globalThis.__conformanceBehavior + const { changed, held, style, requestAnimationFrame } = behavior.print + await changed + delete behavior.print + style.remove() + window.requestAnimationFrame = requestAnimationFrame + for (const callback of held) requestAnimationFrame.call(window, callback) + }) +} + async function cancelActiveBehaviorPointer(page) { const canceled = await page.evaluate(() => { const behavior = globalThis.__conformanceBehavior From 02a3d5ad45020ff7f5dcc3b3c630b8f98249609b Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Wed, 14 Oct 2026 13:05:49 +0200 Subject: [PATCH 3/6] test: preserve frame cancellation while print holds frames Give held frames negative IDs that cannot collide with native handles, drop canceled held frames, pass native handles to the native cancel, and restore both functions when print ends. Co-Authored-By: Claude Opus 5.5 --- scripts/compare-plot-catalog.mjs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/compare-plot-catalog.mjs b/scripts/compare-plot-catalog.mjs index 117a8dd0..6b964942 100644 --- a/scripts/compare-plot-catalog.mjs +++ b/scripts/compare-plot-catalog.mjs @@ -3040,7 +3040,9 @@ async function clearBehaviorPointerTarget(page) { async function startBehaviorPrint(page, width) { await page.evaluate((printWidth) => { const behavior = globalThis.__conformanceBehavior - const held = [] + // Negative IDs never collide with native frame handles. + const held = new Map() + let nextHeldFrame = 0 const style = document.createElement('style') style.textContent = `@media print { [data-conformance-root] { width: ${printWidth}px !important; } }` document.head.append(style) @@ -3048,9 +3050,19 @@ async function startBehaviorPrint(page, width) { held, style, requestAnimationFrame: window.requestAnimationFrame, + cancelAnimationFrame: window.cancelAnimationFrame, changed: printMediaChange(), } - window.requestAnimationFrame = (callback) => held.push(callback) + window.requestAnimationFrame = (callback) => { + const frame = --nextHeldFrame + held.set(frame, callback) + return frame + } + window.cancelAnimationFrame = (frame) => { + if (!held.delete(frame)) { + behavior.print.cancelAnimationFrame.call(window, frame) + } + } function printMediaChange() { return new Promise((resolveChange) => { @@ -3079,12 +3091,21 @@ async function endBehaviorPrint(page) { await page.emulateMedia({ media: null }) await page.evaluate(async () => { const behavior = globalThis.__conformanceBehavior - const { changed, held, style, requestAnimationFrame } = behavior.print + const { + changed, + held, + style, + requestAnimationFrame, + cancelAnimationFrame, + } = behavior.print await changed delete behavior.print style.remove() window.requestAnimationFrame = requestAnimationFrame - for (const callback of held) requestAnimationFrame.call(window, callback) + window.cancelAnimationFrame = cancelAnimationFrame + for (const callback of held.values()) { + requestAnimationFrame.call(window, callback) + } }) } From 0e9ed4987d42ce83aa590c1bbf2c547fa1e2daf7 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Wed, 14 Oct 2026 13:11:01 +0200 Subject: [PATCH 4/6] test: guard nested print steps and bound media waits Reject `printStart` while a print is active. Bound both media-change waits to 5 s, fail the step when print media does not apply, and always restore the style, frame functions, and print state in `printEnd`. Scenario cleanup ignores a timed-out exit because the page is restored. Co-Authored-By: Claude Opus 5.5 --- scripts/compare-plot-catalog.mjs | 82 ++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/scripts/compare-plot-catalog.mjs b/scripts/compare-plot-catalog.mjs index 6b964942..32523594 100644 --- a/scripts/compare-plot-catalog.mjs +++ b/scripts/compare-plot-catalog.mjs @@ -2731,7 +2731,8 @@ async function runBehaviorImplementation( } finally { try { if (mounted) { - await endBehaviorPrint(page) + // The page is restored even when the media change times out. + await endBehaviorPrint(page).catch(() => {}) await cancelActiveBehaviorPointer(page) await page.evaluate(() => { globalThis.__conformanceBehavior?.handle.destroy() @@ -3040,6 +3041,7 @@ async function clearBehaviorPointerTarget(page) { async function startBehaviorPrint(page, width) { await page.evaluate((printWidth) => { const behavior = globalThis.__conformanceBehavior + if (behavior.print) throw new Error('printStart called while printing') // Negative IDs never collide with native frame handles. const held = new Map() let nextHeldFrame = 0 @@ -3051,7 +3053,8 @@ async function startBehaviorPrint(page, width) { style, requestAnimationFrame: window.requestAnimationFrame, cancelAnimationFrame: window.cancelAnimationFrame, - changed: printMediaChange(), + mediaChange, + changed: mediaChange(), } window.requestAnimationFrame = (callback) => { const frame = --nextHeldFrame @@ -3064,49 +3067,66 @@ async function startBehaviorPrint(page, width) { } } - function printMediaChange() { + // Resolves false if the print media query does not change in time. + function mediaChange() { return new Promise((resolveChange) => { - matchMedia('print').addEventListener('change', resolveChange, { - once: true, - }) + const media = matchMedia('print') + const timeout = setTimeout(() => { + media.removeEventListener('change', changed) + resolveChange(false) + }, 5_000) + function changed() { + clearTimeout(timeout) + resolveChange(true) + } + media.addEventListener('change', changed, { once: true }) }) } }, width) await page.emulateMedia({ media: 'print' }) - await page.evaluate(() => globalThis.__conformanceBehavior.print.changed) + const changed = await page.evaluate( + () => globalThis.__conformanceBehavior.print.changed, + ) + if (!changed) throw new Error('print media did not apply within 5 s') } +// Always restores the page, even when print media never applied. async function endBehaviorPrint(page) { const printing = await page.evaluate(() => { const print = globalThis.__conformanceBehavior?.print if (!print) return false - print.changed = new Promise((resolveChange) => { - matchMedia('print').addEventListener('change', resolveChange, { - once: true, - }) - }) + print.changed = matchMedia('print').matches + ? print.mediaChange() + : Promise.resolve(true) return true }) if (!printing) return - await page.emulateMedia({ media: null }) - await page.evaluate(async () => { - const behavior = globalThis.__conformanceBehavior - const { - changed, - held, - style, - requestAnimationFrame, - cancelAnimationFrame, - } = behavior.print - await changed - delete behavior.print - style.remove() - window.requestAnimationFrame = requestAnimationFrame - window.cancelAnimationFrame = cancelAnimationFrame - for (const callback of held.values()) { - requestAnimationFrame.call(window, callback) - } - }) + try { + await page.emulateMedia({ media: null }) + } finally { + const changed = await page.evaluate(async () => { + const behavior = globalThis.__conformanceBehavior + const { + changed, + held, + style, + requestAnimationFrame, + cancelAnimationFrame, + } = behavior.print + try { + return await changed + } finally { + delete behavior.print + style.remove() + window.requestAnimationFrame = requestAnimationFrame + window.cancelAnimationFrame = cancelAnimationFrame + for (const callback of held.values()) { + requestAnimationFrame.call(window, callback) + } + } + }) + if (!changed) throw new Error('screen media did not apply within 5 s') + } } async function cancelActiveBehaviorPointer(page) { From 82da08bcb050885d3da4ac0fd86d7bbea40fa027 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Wed, 14 Oct 2026 13:17:52 +0200 Subject: [PATCH 5/6] test: keep the original error when leaving print fails Check the screen media timeout after restoring the page instead of throwing from `finally`, so an `emulateMedia` rejection is not replaced by the timeout error. Co-Authored-By: Claude Opus 5.5 --- scripts/compare-plot-catalog.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/compare-plot-catalog.mjs b/scripts/compare-plot-catalog.mjs index 32523594..0ec46a7c 100644 --- a/scripts/compare-plot-catalog.mjs +++ b/scripts/compare-plot-catalog.mjs @@ -3101,10 +3101,11 @@ async function endBehaviorPrint(page) { return true }) if (!printing) return + let changed = true try { await page.emulateMedia({ media: null }) } finally { - const changed = await page.evaluate(async () => { + changed = await page.evaluate(async () => { const behavior = globalThis.__conformanceBehavior const { changed, @@ -3125,8 +3126,8 @@ async function endBehaviorPrint(page) { } } }) - if (!changed) throw new Error('screen media did not apply within 5 s') } + if (!changed) throw new Error('screen media did not apply within 5 s') } async function cancelActiveBehaviorPointer(page) { From f743b91a9983944fd8a3dcbfc1d4e375a2c1e3cd Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 15 Oct 2026 06:13:12 +0200 Subject: [PATCH 6/6] chore: record print redraw bundle costs and refresh artifacts Refresh the comparison bundle baseline and comparison page for the rebased print redraw commit, and the catalog preview fingerprint. Mark F-316 resolved by #230. Co-Authored-By: Claude Opus 5.5 --- API-FRICTION.md | 13 +- benchmarks/comparison/bundle-baseline.json | 126 +++++++++--------- benchmarks/conformance/previews/manifest.json | 2 +- docs/comparison.md | 8 +- packages/charts-core/docs/comparison.md | 8 +- 5 files changed, 80 insertions(+), 77 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index 152e24d5..9c50b76c 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -352,7 +352,7 @@ Each entry records: | F-313 | Initial observer precision cancelled entrance motion | Tooling | resolved | | F-314 | Framework ticks and immediate focus layers were unclear | Documentation | resolved | | F-315 | Printed charts kept their screen width | API | resolved | -| F-316 | Catalog previews depend on the local time zone | Tooling | open | +| F-316 | Catalog previews depend on the local time zone | Tooling | resolved | ## Findings @@ -9372,14 +9372,17 @@ Each entry records: ### F-316 - Catalog previews depend on the local time zone -- Status: open +- Status: resolved - Severity: low - Owner: Tooling - Observed in: refreshing the preview fingerprint for the print redraw change. `pnpm catalog:previews` in Europe/Warsaw, and again with `TZ=UTC`, shifted the weekday labels of nine shadcn tooltip previews by one day. The committed - previews were generated west of UTC. + previews had been generated west of UTC. - Workaround: keep the committed SVGs and their manifest records, and commit only the new `sourceHash`. -- Follow-up: generate previews with a fixed time zone, or format those labels - in UTC, so regeneration is reproducible on any machine. +- Decision: #230 pins preview generation to UTC and regenerates the affected + previews. +- Verification: on top of #230, `pnpm catalog:previews` in Europe/Warsaw + reproduced all 188 SVGs from `main` byte for byte. Only `sourceHash` changed, + and `pnpm catalog:previews:check` passes. diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index bc22e4f0..ca447207 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-14T04:50:28.604Z", + "generatedAt": "2026-10-16T16:48:15.255Z", "packageVersions": { "tanstack": "1.1.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "69d70960dd6b29f3706a173d348f2fc8271b0dcc", - "inputDigest": "sha256:80f5c5447f39757715be5def33eed28b12869946936369671243436701fb7964" + "revision": "255db65bc31609387da2bac974f4aa7df0810280", + "inputDigest": "sha256:c0840e905be2f2eac417b4d3bce67050f63c999403e50a78eac6a7f11d0b3674" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 119266, - "gzipBytes": 43213, - "brotliBytes": 38182, - "incrementalGzipBytes": 43213, - "incrementalBrotliBytes": 38182 + "minifiedBytes": 119409, + "gzipBytes": 43265, + "brotliBytes": 38232, + "incrementalGzipBytes": 43265, + "incrementalBrotliBytes": 38232 }, "tanstack-line-interactive": { - "minifiedBytes": 125013, - "gzipBytes": 45122, - "brotliBytes": 39724, - "incrementalGzipBytes": 45122, - "incrementalBrotliBytes": 39724 + "minifiedBytes": 125160, + "gzipBytes": 45182, + "brotliBytes": 39762, + "incrementalGzipBytes": 45182, + "incrementalBrotliBytes": 39762 }, "tanstack-line-advanced": { - "minifiedBytes": 132209, - "gzipBytes": 47460, - "brotliBytes": 41713, - "incrementalGzipBytes": 47460, - "incrementalBrotliBytes": 41713 + "minifiedBytes": 132352, + "gzipBytes": 47502, + "brotliBytes": 41858, + "incrementalGzipBytes": 47502, + "incrementalBrotliBytes": 41858 }, "tanstack-bar-basic": { - "minifiedBytes": 131703, - "gzipBytes": 47916, - "brotliBytes": 42081, - "incrementalGzipBytes": 47916, - "incrementalBrotliBytes": 42081 + "minifiedBytes": 131846, + "gzipBytes": 47971, + "brotliBytes": 42179, + "incrementalGzipBytes": 47971, + "incrementalBrotliBytes": 42179 }, "tanstack-bar-interactive": { - "minifiedBytes": 136309, - "gzipBytes": 49311, - "brotliBytes": 43248, - "incrementalGzipBytes": 49311, - "incrementalBrotliBytes": 43248 + "minifiedBytes": 136452, + "gzipBytes": 49358, + "brotliBytes": 43285, + "incrementalGzipBytes": 49358, + "incrementalBrotliBytes": 43285 }, "tanstack-bar-advanced": { - "minifiedBytes": 136648, - "gzipBytes": 49455, - "brotliBytes": 43379, - "incrementalGzipBytes": 49455, - "incrementalBrotliBytes": 43379 + "minifiedBytes": 136791, + "gzipBytes": 49496, + "brotliBytes": 43465, + "incrementalGzipBytes": 49496, + "incrementalBrotliBytes": 43465 }, "tanstack-area-basic": { - "minifiedBytes": 124446, - "gzipBytes": 45243, - "brotliBytes": 39958, - "incrementalGzipBytes": 45243, - "incrementalBrotliBytes": 39958 + "minifiedBytes": 124589, + "gzipBytes": 45307, + "brotliBytes": 40026, + "incrementalGzipBytes": 45307, + "incrementalBrotliBytes": 40026 }, "tanstack-area-interactive": { - "minifiedBytes": 130201, - "gzipBytes": 47092, - "brotliBytes": 41523, - "incrementalGzipBytes": 47092, - "incrementalBrotliBytes": 41523 + "minifiedBytes": 130344, + "gzipBytes": 47139, + "brotliBytes": 41579, + "incrementalGzipBytes": 47139, + "incrementalBrotliBytes": 41579 }, "tanstack-area-advanced": { - "minifiedBytes": 137573, - "gzipBytes": 49501, - "brotliBytes": 43577, - "incrementalGzipBytes": 49501, - "incrementalBrotliBytes": 43577 + "minifiedBytes": 137716, + "gzipBytes": 49551, + "brotliBytes": 43615, + "incrementalGzipBytes": 49551, + "incrementalBrotliBytes": 43615 }, "tanstack-scatter-basic": { - "minifiedBytes": 120319, - "gzipBytes": 43574, - "brotliBytes": 38528, - "incrementalGzipBytes": 43574, - "incrementalBrotliBytes": 38528 + "minifiedBytes": 120462, + "gzipBytes": 43637, + "brotliBytes": 38573, + "incrementalGzipBytes": 43637, + "incrementalBrotliBytes": 38573 }, "tanstack-scatter-interactive": { - "minifiedBytes": 126070, - "gzipBytes": 45472, - "brotliBytes": 40050, - "incrementalGzipBytes": 45472, - "incrementalBrotliBytes": 40050 + "minifiedBytes": 126213, + "gzipBytes": 45544, + "brotliBytes": 40150, + "incrementalGzipBytes": 45544, + "incrementalBrotliBytes": 40150 }, "tanstack-scatter-advanced": { - "minifiedBytes": 126086, - "gzipBytes": 45479, - "brotliBytes": 40072, - "incrementalGzipBytes": 45479, - "incrementalBrotliBytes": 40072 + "minifiedBytes": 126229, + "gzipBytes": 45549, + "brotliBytes": 40099, + "incrementalGzipBytes": 45549, + "incrementalBrotliBytes": 40099 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 184adbcd..d40cc905 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": "37d5c492d43c8a93198eed15cb81078a89e1556d6a3a4b66dd0d302ace17efb9", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index 61c55ce6..04d5e1f9 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 `255db65` | | [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 `255db65`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-10-14`. +Baseline date: `2026-10-16`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -------------------: | ---------------------------------------------------------- | -| TanStack Charts | 43.21–49.50 kB gzip | Not applicable | Controlled suite | +| TanStack Charts | 43.27–49.55 kB gzip | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 45.78–59.61 kB gzip | — | Controlled suite | | Apache ECharts | 156.77–177.34 kB gzip | — | Controlled suite | diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 61c55ce6..04d5e1f9 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 `255db65` | | [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 `255db65`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-10-14`. +Baseline date: `2026-10-16`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -------------------: | ---------------------------------------------------------- | -| TanStack Charts | 43.21–49.50 kB gzip | Not applicable | Controlled suite | +| TanStack Charts | 43.27–49.55 kB gzip | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 45.78–59.61 kB gzip | — | Controlled suite | | Apache ECharts | 156.77–177.34 kB gzip | — | Controlled suite |