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..9c50b76c 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,8 @@ 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 | +| F-316 | Catalog previews depend on the local time zone | Tooling | resolved | ## Findings @@ -9339,3 +9341,48 @@ 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. 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: 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 had been generated west of UTC. +- Workaround: keep the committed SVGs and their manifest records, and commit + only the new `sourceHash`. +- 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/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/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/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/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/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/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/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/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 | 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/compare-plot-catalog.mjs b/scripts/compare-plot-catalog.mjs index 3ba3c792..0ec46a7c 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,8 @@ async function runBehaviorImplementation( } finally { try { if (mounted) { + // 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() @@ -2762,6 +2781,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 +3004,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 +3035,101 @@ 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 + 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 + 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, + cancelAnimationFrame: window.cancelAnimationFrame, + mediaChange, + changed: mediaChange(), + } + 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) + } + } + + // Resolves false if the print media query does not change in time. + function mediaChange() { + return new Promise((resolveChange) => { + 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' }) + 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 = matchMedia('print').matches + ? print.mediaChange() + : Promise.resolve(true) + return true + }) + if (!printing) return + let changed = true + try { + await page.emulateMedia({ media: null }) + } finally { + 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) { const canceled = await page.evaluate(() => { const behavior = globalThis.__conformanceBehavior 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'),