diff --git a/.changeset/quick-charts-mount.md b/.changeset/quick-charts-mount.md new file mode 100644 index 00000000..603d7959 --- /dev/null +++ b/.changeset/quick-charts-mount.md @@ -0,0 +1,6 @@ +--- +'@tanstack/charts': patch +'@tanstack/react-charts': patch +--- + +Reduce chart mounting work by creating default SVG focus indicators only when needed and avoiding duplicate React client rendering. Speed up SVG serialization and categorical domain inference while preserving server rendering, hydration, chart appearance, and interactions. diff --git a/API-FRICTION.md b/API-FRICTION.md index 7e77198a..96a59837 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -570,6 +570,7 @@ Each entry records: - Status: resolved - Severity: high +- Owner: API - Observed in: TanStack Charts sandbox migration - Friction: `ChartSurface` creates a temporary runtime and prepares the dynamic definition for initial markup. The mounted DOM host creates another runtime @@ -581,6 +582,32 @@ Each entry records: application reactivity owns transformed data and asynchronous cleanup. - Verification: React and Octane dynamic mounts, hydration, and SSR retain complete initial markup without a preparation lifecycle. +- 2026-09-08 performance follow-up: the 10,000-cell heatmap investigation + confirms that application data preparation remains outside the runtime, but + React still builds and serializes initial SVG markup, then builds the scene + again and serializes/reconciles SVG during its mount effect. A wrapped public + `renderSvg` callback runs twice on each production React mount. With default + focus rings disabled in both diagnostic cases, React SVG takes 74.4 ms median + to the Element Timing paint timestamp, while mounting the same definition + through the direct SVG host takes 37.5 ms. Both retain 10,000 visible cells. +- Follow-up resolution: React client-only mounts now build the chart once in + the existing layout effect, using DOM text metrics. A hydration snapshot + retains complete initial markup for server rendering and hydration. No + chart options or application changes are required. +- Verification: client SVG and canvas builders run once, SVG serialization + runs once, and the mounted chart is available to parent layout effects. + Strict Mode hydration retains SVG and mark identity without recoverable + errors. Combined with F-225, the same heatmap takes 39.4 ms for SVG and + 24.5 ms for canvas, down from 130.1 and 35.9 ms respectively. + +- Construction/layout follow-up: React ran a second adapter update with the + same options immediately after mounting. That update refreshed computed + typography after inserting SVG. The adapter now mounts or updates once per + commit and retains explicit unmount cleanup. Later updates still refresh + fonts, and Strict Mode hydration and chart identity remain covered. +- Verification: a regression checks that initial mounting does not read chart + surface styles after SVG insertion and that a later font change still + triggers rendering. All 903 core and React tests and TypeScript pass. ### F-012 — Render callbacks omit diagnostic metrics @@ -6920,6 +6947,88 @@ Each entry records: focus layer. DOM, adapter, renderer-neutral, React SSR/hydration, and React Native tests cover the disabled focus contract. The catalog test and packed consumer gate server-render all 110 catalog components. +- 2026-09-08 performance follow-up: a 100 by 100 cell matrix with tooltip and + keyboard disabled still emits 10,000 hidden default-focus circles, as its + pointer focus remains enabled. Production React SVG takes 130.1 ms median + to the Element Timing paint timestamp. Setting only `focusRing: false` + reduces that to 74.4 ms and SVG markup from 2,837,699 to 1,138,821 characters. + All 10,000 visible cells remain. Canvas changes from 35.9 to 33.3 ms. +- Follow-up resolution: the SVG surface serializes only active default focus + geometry. The scene retains all point geometry, authored focus layers keep + their behavior, and animated updates materialize previous geometry before + interpolation. The `focus: false` contract is unchanged. +- Verification: all 10,000 cells remain. Native Chromium pointer and keyboard + interactions match HEAD, with byte-identical screenshots and stable base + cell identity. Tests cover clipping, paint resources, custom serialization, + blur, animation interpolation, and cleanup after a serializer throws. + Mounting without axes takes 39.4 ms for SVG and 24.5 ms for canvas; + with axes it takes 43.6 and 26.9 ms, down from 171.2 and 37.6 ms. + Initial SVG contains 10,007 elements instead of 20,007. +- Bundle review: shared renderer and adapter changes add 451 gzip bytes to + the representative React line consumer. Locked bundle baselines and only + exceeded ceilings were updated by their measured deltas; dependency + isolation checks pass. Competitor bundle measurements are unchanged. +- Test status: the final focused suite passes all 91 tests and TypeScript + passes. The full workspace root suite passes 1,782 tests; two existing + catalog assertions still expect 110 cases and omit `120-sales-funnel`. + Neither those assertions nor the catalog inputs changed in this task. +- Second performance pass: CPU profiling found allocations in SVG + serialization. SVG escaping now uses one pass, and shared clip, formatting, + and focus-layer routines remove duplicate implementations. Group traversal + avoids temporary arrays, paint serialization avoids per-node closures, and + scenes without gradients skip gradient lookup. A key-lookup experiment was + discarded because scene timings did not show a consistent benefit. Scene + output, identity rules, and enabled interactions are unchanged. +- Second-pass verification: all 901 core and React tests and TypeScript pass. + Seven native Chromium screenshots match HEAD byte for byte, including axes, + SVG, canvas, and pointer/keyboard focus. The representative React line + consumer adds 334 gzip bytes over HEAD, down from 451 bytes in the first + pass. Reviewed bundle ceilings track actual entry-point deltas and retain + dependency isolation. Interleaved before/after samples and the CPU profile + are in `.benchmark-output/heatmap-investigation/round2/`. +- Final second-pass timings: 30 interleaved measured mounts per version after + 10 warmups give SVG paint medians of 40.3 to 37.5 ms without axes and 44.2 + to 39.6 ms with axes. Isolated serialization drops from 4.3 to 1.9 ms; + scene construction remains effectively unchanged at 9.8 versus 9.9 ms. + These compare the first optimization with the retained second-pass code, + rather than comparing separate historical timing runs. +- Third pass: scene construction skips the point-translation lookup when no + viewport is active. Categorical domain inference uses primitive identities + and a separate Date timestamp set, avoiding filtered copies and temporary + key strings. Tests retain insertion order, distinguish Date/number/string + inputs, skip invalid values, and preserve empty factory domains. Seven + native Chromium screenshots remain byte-identical to HEAD. +- Third-pass bundle review: the representative React line consumer adds + 372 gzip bytes over HEAD, 38 bytes above the previous pass and still below + the first pass's 451 bytes. Renderer dependency isolation checks pass. + Raw before/after samples and style-read evidence are retained in + `.benchmark-output/heatmap-investigation/round3/`. +- Final third-pass timings: interleaved medians give scene construction at + 9.8 to 8.6 ms and synchronous SVG mount at 33.9 to 24.9 ms. Paint drops + from 35.4 to 33.1 ms for SVG and 23.3 to 21.5 ms for canvas without axes; + with axes it drops from 38.0 to 35.9 ms and 26.1 to 23.1 ms respectively. + The lifecycle-only probe shows why synchronous mount and paint must remain + separate measurements: avoiding a forced style read moves browser work + out of the commit but does not by itself remove that work before paint. +- Release port: the historical measurements above used the 0.9.0 worktree. + The release is based on 0.16.1. It keeps the newer mapped focus-coordinate + registration, so the no-viewport point-map shortcut is not included. + All 973 core and React tests pass on that base, and seven native Chromium + screenshots match the current baseline byte for byte. The React line + consumer adds 365 gzip bytes, with dependency isolation preserved. +- Release measurement against 0.16.1: 30 interleaved measured mounts after 10 + warmups per version give SVG paint medians of 133.4 to 35.9 ms without axes + and 164.3 to 37.9 ms with axes. Canvas gives 38.8 to 23.6 ms without axes + and 42.8 to 25.8 ms with axes. All cases render the same 10,000 cells at + 500 by 300 pixels on the M5 Pro in production React and Chromium. These + measure mounting through paint, not network loading or dashboard totals. +- Measurement: Apple M5 Pro, Chromium 151.0.7922.34, production React profiling + build, 500 by 300 pixels, 10 warmups and 20 measured mounts, median without + outlier removal, no axes, animation, or progressive rendering. Workspace + revision `1b1df994b5c224dda00ef90664c5a44589b53cd9`. This reconstructs the + workload and does not claim to reproduce Colm Tuite's unpublished harness. + Local fixture, runner, screenshots, and raw samples are in + `.benchmark-output/heatmap-investigation/`. ### F-226 — Worker runtimes rejected bundled CSV parsing @@ -7761,6 +7870,13 @@ Each entry records: dependencies. All 12 `0.9.0` release artifacts pass with the unified fixture installing from its isolated store. +- Performance release tooling: the isolated unified consumer had no + `packageManager`, so Corepack selected pnpm 12.3.4 instead of the repository's + pinned pnpm 11.15.1 and rejected the install flags. The fixture now inherits + the root package-manager pin, preserving its offline dependency check. + Verification: all seven framework adapter gates and the unified packed + artifact gate pass with the repository-pinned package manager. + ### F-258 — Tooltip chrome required specificity overrides - Status: resolved diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 486ae8f9..f3d13fa5 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -3,44 +3,44 @@ "policy": "Exact minified and gzip output for entries that optional features must not affect. Review every change before updating.", "bundles": { "D3-scale line scene": { - "bytes": 50813, - "gzip": 19070 + "bytes": 50942, + "gzip": 19130 }, "D3-scale line + static SVG": { - "bytes": 55544, - "gzip": 20776 + "bytes": 55598, + "gzip": 20866 }, "Representative marks": { - "bytes": 75617, - "gzip": 27582 + "bytes": 75674, + "gzip": 27649 }, "TanStack DOM host": { - "bytes": 74947, - "gzip": 26053 + "bytes": 75211, + "gzip": 26339 }, "React adapter": { - "bytes": 77130, - "gzip": 26831 + "bytes": 77508, + "gzip": 27190 }, "React line consumer": { - "bytes": 100766, - "gzip": 36222 + "bytes": 101143, + "gzip": 36587 }, "Compact-scale line scene": { - "bytes": 33265, - "gzip": 11935 + "bytes": 33395, + "gzip": 11979 }, "React compact-scale line consumer": { - "bytes": 83266, - "gzip": 29144 + "bytes": 83644, + "gzip": 29509 }, "Custom-scale line scene": { - "bytes": 31447, - "gzip": 11193 + "bytes": 31577, + "gzip": 11249 }, "D3 linear-scale line scene": { - "bytes": 50745, - "gzip": 19032 + "bytes": 50874, + "gzip": 19092 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 68be5a33..30e84478 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 4, - "generatedAt": "2026-09-08T22:11:29.376Z", + "generatedAt": "2026-09-08T22:47:47.702Z", "packageVersions": { - "tanstack": "0.16.0", + "tanstack": "0.16.1", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "3df87d71f0305e5a450c10b66940f76f3e14259a", - "inputDigest": "sha256:40278798ed7b78aa3777062fc6ff810a50244823da5e49d9700eeef8b536994f" + "revision": "2310d3eb45088cee610565ad6f9341b72728f1f4", + "inputDigest": "sha256:2d32ae988429cf2b27eaca8a6621aba7d7deeb28be6ad79d7770c406690a6fbf" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 110996, - "gzipBytes": 40024, - "brotliBytes": 35426, - "incrementalGzipBytes": 40024, - "incrementalBrotliBytes": 35426 + "minifiedBytes": 111259, + "gzipBytes": 40353, + "brotliBytes": 35711, + "incrementalGzipBytes": 40353, + "incrementalBrotliBytes": 35711 }, "tanstack-line-interactive": { - "minifiedBytes": 116437, - "gzipBytes": 41782, - "brotliBytes": 36848, - "incrementalGzipBytes": 41782, - "incrementalBrotliBytes": 36848 + "minifiedBytes": 116700, + "gzipBytes": 42088, + "brotliBytes": 37108, + "incrementalGzipBytes": 42088, + "incrementalBrotliBytes": 37108 }, "tanstack-line-advanced": { - "minifiedBytes": 123627, - "gzipBytes": 44128, - "brotliBytes": 38892, - "incrementalGzipBytes": 44128, - "incrementalBrotliBytes": 38892 + "minifiedBytes": 123890, + "gzipBytes": 44444, + "brotliBytes": 39135, + "incrementalGzipBytes": 44444, + "incrementalBrotliBytes": 39135 }, "tanstack-bar-basic": { - "minifiedBytes": 119790, - "gzipBytes": 43347, - "brotliBytes": 38287, - "incrementalGzipBytes": 43347, - "incrementalBrotliBytes": 38287 + "minifiedBytes": 120053, + "gzipBytes": 43647, + "brotliBytes": 38517, + "incrementalGzipBytes": 43647, + "incrementalBrotliBytes": 38517 }, "tanstack-bar-interactive": { - "minifiedBytes": 124086, - "gzipBytes": 44720, - "brotliBytes": 39309, - "incrementalGzipBytes": 44720, - "incrementalBrotliBytes": 39309 + "minifiedBytes": 124349, + "gzipBytes": 45008, + "brotliBytes": 39660, + "incrementalGzipBytes": 45008, + "incrementalBrotliBytes": 39660 }, "tanstack-bar-advanced": { - "minifiedBytes": 124425, - "gzipBytes": 44868, - "brotliBytes": 39495, - "incrementalGzipBytes": 44868, - "incrementalBrotliBytes": 39495 + "minifiedBytes": 124688, + "gzipBytes": 45146, + "brotliBytes": 39739, + "incrementalGzipBytes": 45146, + "incrementalBrotliBytes": 39739 }, "tanstack-area-basic": { - "minifiedBytes": 116205, - "gzipBytes": 42004, - "brotliBytes": 37190, - "incrementalGzipBytes": 42004, - "incrementalBrotliBytes": 37190 + "minifiedBytes": 116468, + "gzipBytes": 42287, + "brotliBytes": 37422, + "incrementalGzipBytes": 42287, + "incrementalBrotliBytes": 37422 }, "tanstack-area-interactive": { - "minifiedBytes": 121650, - "gzipBytes": 43727, - "brotliBytes": 38578, - "incrementalGzipBytes": 43727, - "incrementalBrotliBytes": 38578 + "minifiedBytes": 121913, + "gzipBytes": 44033, + "brotliBytes": 38900, + "incrementalGzipBytes": 44033, + "incrementalBrotliBytes": 38900 }, "tanstack-area-advanced": { - "minifiedBytes": 129022, - "gzipBytes": 46195, - "brotliBytes": 40688, - "incrementalGzipBytes": 46195, - "incrementalBrotliBytes": 40688 + "minifiedBytes": 129285, + "gzipBytes": 46478, + "brotliBytes": 40997, + "incrementalGzipBytes": 46478, + "incrementalBrotliBytes": 40997 }, "tanstack-scatter-basic": { - "minifiedBytes": 112025, - "gzipBytes": 40405, - "brotliBytes": 35796, - "incrementalGzipBytes": 40405, - "incrementalBrotliBytes": 35796 + "minifiedBytes": 112288, + "gzipBytes": 40704, + "brotliBytes": 36037, + "incrementalGzipBytes": 40704, + "incrementalBrotliBytes": 36037 }, "tanstack-scatter-interactive": { - "minifiedBytes": 117466, - "gzipBytes": 42150, - "brotliBytes": 37148, - "incrementalGzipBytes": 42150, - "incrementalBrotliBytes": 37148 + "minifiedBytes": 117729, + "gzipBytes": 42455, + "brotliBytes": 37423, + "incrementalGzipBytes": 42455, + "incrementalBrotliBytes": 37423 }, "tanstack-scatter-advanced": { - "minifiedBytes": 117482, - "gzipBytes": 42155, - "brotliBytes": 37106, - "incrementalGzipBytes": 42155, - "incrementalBrotliBytes": 37106 + "minifiedBytes": 117745, + "gzipBytes": 42460, + "brotliBytes": 37392, + "incrementalGzipBytes": 42460, + "incrementalBrotliBytes": 37392 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/34-pointer-tooltip.svg b/benchmarks/conformance/previews/34-pointer-tooltip.svg index fdb6d046..ba311efe 100644 --- a/benchmarks/conformance/previews/34-pointer-tooltip.svg +++ b/benchmarks/conformance/previews/34-pointer-tooltip.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/35-grouped-tooltip.svg b/benchmarks/conformance/previews/35-grouped-tooltip.svg index 7cedf5a4..98d0c6fb 100644 --- a/benchmarks/conformance/previews/35-grouped-tooltip.svg +++ b/benchmarks/conformance/previews/35-grouped-tooltip.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/65-voronoi-nearest-tooltip.svg b/benchmarks/conformance/previews/65-voronoi-nearest-tooltip.svg index 6efb7a1b..d76b3b42 100644 --- a/benchmarks/conformance/previews/65-voronoi-nearest-tooltip.svg +++ b/benchmarks/conformance/previews/65-voronoi-nearest-tooltip.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/81-recharts-interactive-legend.svg b/benchmarks/conformance/previews/81-recharts-interactive-legend.svg index 376082d6..81d883fe 100644 --- a/benchmarks/conformance/previews/81-recharts-interactive-legend.svg +++ b/benchmarks/conformance/previews/81-recharts-interactive-legend.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/82-chart-table-selection.svg b/benchmarks/conformance/previews/82-chart-table-selection.svg index fb7aa686..f41422af 100644 --- a/benchmarks/conformance/previews/82-chart-table-selection.svg +++ b/benchmarks/conformance/previews/82-chart-table-selection.svg @@ -1 +1 @@ -Use arrow keys to move between observations and Enter or Space to select one. The table below offers the same selections. +Use arrow keys to move between observations and Enter or Space to select one. The table below offers the same selections. diff --git a/benchmarks/conformance/previews/83-focus-context-window.svg b/benchmarks/conformance/previews/83-focus-context-window.svg index c847dfa6..26bd49bc 100644 --- a/benchmarks/conformance/previews/83-focus-context-window.svg +++ b/benchmarks/conformance/previews/83-focus-context-window.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/84-pinned-nested-chart-tooltip.svg b/benchmarks/conformance/previews/84-pinned-nested-chart-tooltip.svg index 9bf5e4c1..850bda64 100644 --- a/benchmarks/conformance/previews/84-pinned-nested-chart-tooltip.svg +++ b/benchmarks/conformance/previews/84-pinned-nested-chart-tooltip.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/86-streaming-window-preservation.svg b/benchmarks/conformance/previews/86-streaming-window-preservation.svg index ed99c241..8c2fdd35 100644 --- a/benchmarks/conformance/previews/86-streaming-window-preservation.svg +++ b/benchmarks/conformance/previews/86-streaming-window-preservation.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 0aba1d47..78fb4e1c 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "ca812a14160bf95e625c3d4a98387baa9e103a4086af3a29a0d9a590a672f05b", + "sourceHash": "dea4d109a28ccf64c7744e34402fe5d8fc2ec7455277e71f5ab7595bb4e27f82", "assets": [ { "id": "01-line-gaps", @@ -166,13 +166,13 @@ }, { "id": "34-pointer-tooltip", - "sha256": "e4ff132c905893b271d67984435b50620b267447c8081c2ac9f96295d99482e5", - "bytes": 15814 + "sha256": "bb680eff754d3837a93b5610e0c59c66cdce2ea88655d73149d452342807152a", + "bytes": 7797 }, { "id": "35-grouped-tooltip", - "sha256": "8a6d29b3745685108a1d2486d18dc7ee62daa6645543740b80c8a0eaf564e1b1", - "bytes": 56607 + "sha256": "f6b8f0bae4229e1ad147e1de902ac8aea65f1f17c4b0aad608cd491889b52353", + "bytes": 30876 }, { "id": "36-hierarchy-tree", @@ -301,8 +301,8 @@ }, { "id": "65-voronoi-nearest-tooltip", - "sha256": "fbdc48535e8997ef0ccb47ccd85f14c3443c26f869b95e9b79fd317414bb9c88", - "bytes": 16224 + "sha256": "35fe2d05b25e99428908ab281641469f39bd49db5e5d25e0ecff9a6c3e3a91d2", + "bytes": 12219 }, { "id": "70-composed-chart", @@ -356,23 +356,23 @@ }, { "id": "81-recharts-interactive-legend", - "sha256": "101eb6796ec9aa0ec5844e685f1b76c993b38259fbf4e22dffc7dbe9031b944e", - "bytes": 5840 + "sha256": "982819593232691a6992052e15ee325c7536a7b519cf379f062e20571c6d7f72", + "bytes": 3183 }, { "id": "82-chart-table-selection", - "sha256": "ff310136b7d15c327b6d924c7907793e8c4b291947c8aa1bd744b2a5af334219", - "bytes": 3857 + "sha256": "558dd507203747813f88950c52da42e332ae55d20eae66199996091cfb15e0f5", + "bytes": 2812 }, { "id": "83-focus-context-window", - "sha256": "42cb178cd3c96993548968c7779b71fa54e64141c09a8ea2b2cb6a2aaccdf571", - "bytes": 9681 + "sha256": "8f8b7eab7513a21eb3f46720e2be3a6692ed06dae2e5fda351b46c715aaf9360", + "bytes": 5512 }, { "id": "84-pinned-nested-chart-tooltip", - "sha256": "e83f4a5f64646c627af03099e0796ddfc0d8699a2ac7b0be848552715ed06dde", - "bytes": 26174 + "sha256": "9fa2d6d0965a4e6baef591c425d6eb341cebd0d37299342b6662a5a1db43c28e", + "bytes": 25352 }, { "id": "85-scrollable-resource-lanes", @@ -381,8 +381,8 @@ }, { "id": "86-streaming-window-preservation", - "sha256": "0d45437aed02ed7e219227c5b4ccb638e1d073e680578fd6975c83e520d65440", - "bytes": 5311 + "sha256": "fc21a5108a4028addecfb03a56bc63c178c421784e890a620e6d47a44a1afaed", + "bytes": 3638 }, { "id": "87-echarts-synchronized-cursors", diff --git a/docs/comparison.md b/docs/comparison.md index 5965d38d..89d2d692 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 `3df87d7` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `2310d3e` | | [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 `3df87d7`. +at page render time. The measured TanStack workspace revision is `2310d3e`. ## Capability matrix @@ -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 | 39.09–45.11 KiB | Not applicable | Controlled suite | +| TanStack Charts | 39.41–45.39 KiB | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 5965d38d..89d2d692 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 `3df87d7` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `2310d3e` | | [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 `3df87d7`. +at page render time. The measured TanStack workspace revision is `2310d3e`. ## Capability matrix @@ -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 | 39.09–45.11 KiB | Not applicable | Controlled suite | +| TanStack Charts | 39.41–45.39 KiB | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/packages/charts-core/src/canvas.ts b/packages/charts-core/src/canvas.ts index 9c0c06c3..bddf9490 100644 --- a/packages/charts-core/src/canvas.ts +++ b/packages/charts-core/src/canvas.ts @@ -1,3 +1,4 @@ +import { escapeAttribute } from './markup-internal' import { mountChartRenderer } from './renderer' import { createChartRuntime } from './runtime' import { resolveFocusScene } from './focus-layer' @@ -1776,11 +1777,3 @@ function requiredContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D { function integer(value: number): string { return String(Math.max(0, Math.round(value))) } - -function escapeAttribute(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') -} diff --git a/packages/charts-core/src/configured-scale.test.ts b/packages/charts-core/src/configured-scale.test.ts index 9c353fb7..599730f0 100644 --- a/packages/charts-core/src/configured-scale.test.ts +++ b/packages/charts-core/src/configured-scale.test.ts @@ -6,7 +6,7 @@ import { scaleQuantize, scaleUtc, } from 'd3-scale' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { barY } from './bar' import { createMark } from './mark' import { createChartScene, defineChart } from './scene' @@ -550,6 +550,34 @@ describe('configured scales', () => { ).toThrow('An inferred log scale cannot include an implicit zero baseline') }) + it('deduplicates categorical values without merging dates, numbers, or strings', () => { + const date = new Date(0) + const source = scaleBand() + const copy = source.copy() + vi.spyOn(source, 'copy').mockReturnValue(copy) + const domain = vi.spyOn(copy, 'domain') + resolveScaleInput(() => source, { + values: [ + date, + new Date(0), + 0, + -0, + '0', + 'date:0', + '0', + undefined, + NaN, + new Date(NaN), + ], + }) + expect(domain).toHaveBeenCalledWith([date, 0, '0', 'date:0']) + expect( + resolveScaleInput(() => scaleBand().domain(['default']), { + values: [undefined, NaN], + }).domain(), + ).toEqual(['default']) + }) + it('retains native factory domains for empty channels', () => { const scene = createChartScene( defineChart({ diff --git a/packages/charts-core/src/default-focus-internal.ts b/packages/charts-core/src/default-focus-internal.ts new file mode 100644 index 00000000..e41e4862 --- /dev/null +++ b/packages/charts-core/src/default-focus-internal.ts @@ -0,0 +1,16 @@ +import type { SceneGroup } from './types' + +const defaultFocusLayer = Symbol('default-focus-layer') + +type DefaultFocus = NonNullable & { + [defaultFocusLayer]?: true +} + +export function markDefaultFocusLayer(layer: SceneGroup): SceneGroup { + if (layer.focus) Object.assign(layer.focus, { [defaultFocusLayer]: true }) + return layer +} + +export function isDefaultFocusLayer(layer: SceneGroup): boolean { + return (layer.focus as DefaultFocus | undefined)?.[defaultFocusLayer] === true +} diff --git a/packages/charts-core/src/focus-layer.ts b/packages/charts-core/src/focus-layer.ts index dfff0e1f..ddf405ec 100644 --- a/packages/charts-core/src/focus-layer.ts +++ b/packages/charts-core/src/focus-layer.ts @@ -155,7 +155,7 @@ function collectFocusedNodes( return output } -function selectedFocusChildren( +export function selectedFocusChildren( layer: SceneGroup, focus: ChartFocusState, ): readonly SceneNode[] { diff --git a/packages/charts-core/src/markup-internal.ts b/packages/charts-core/src/markup-internal.ts new file mode 100644 index 00000000..525e34cb --- /dev/null +++ b/packages/charts-core/src/markup-internal.ts @@ -0,0 +1,22 @@ +export function number(value: number): string { + return String(Math.round(value * 100) / 100) +} + +const entities: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', +} + +function escapeCharacter(character: string): string { + return entities[character] ?? character +} + +export function escapeText(value: string): string { + return value.replace(/[&<>]/g, escapeCharacter) +} + +export function escapeAttribute(value: string): string { + return value.replace(/[&<>"]/g, escapeCharacter) +} diff --git a/packages/charts-core/src/scale-input.ts b/packages/charts-core/src/scale-input.ts index 243573e0..ef227d9d 100644 --- a/packages/charts-core/src/scale-input.ts +++ b/packages/charts-core/src/scale-input.ts @@ -73,27 +73,28 @@ function inferScaleDomain( values: readonly unknown[], includeZero = false, ): ChartValue[] | undefined { - const observed = values.filter(isChartValue) - if (!observed.length) return undefined - if ( typeof scale.bandwidth === 'function' || typeof scale.ticks !== 'function' ) { const domain: ChartValue[] = [] - const seen = new Set() - for (const value of observed) { - const key = - value instanceof Date - ? `date:${value.getTime()}` - : `${typeof value}:${String(value)}` - if (seen.has(key)) continue - seen.add(key) + const seen = new Set() + const dates = new Set() + for (const value of values) { + if (!isChartValue(value)) continue + const date = value instanceof Date + const key = date ? value.getTime() : value + const identities = date ? dates : seen + if (identities.has(key)) continue + identities.add(key) domain.push(value) } - return domain + return domain.length ? domain : undefined } + const observed = values.filter(isChartValue) + if (!observed.length) return undefined + const temporal = scale.domain().some((value) => value instanceof Date) if (temporal) { const dates = observed.filter( diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 5f465fd4..83b81cad 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -9,6 +9,7 @@ import { setMappedFocusCoordinate } from './focus-coordinate-internal' import { readMaterializedPositionChannel } from './materialized-channel-internal' import { mapScenePointReferences } from './scene-point-map' import { chartSceneSource } from './scene-source' +import { markDefaultFocusLayer } from './default-focus-internal' import type { ResponsiveChartDefinition, InitializedMark, @@ -530,31 +531,33 @@ function createChartSceneWithScaleResolver< points.length ) { for (const entry of defaultFocusEntries) { - nodes.push({ - kind: 'group', - key: `default-focus:${entry.markId}`, - className: 'ts-chart__focus-layer ts-chart__focus-layer--default', - ariaHidden: true, - clip: entry.clipped ? chart : undefined, - focus: { - match: 'primary', - anchors: entry.points, - points: entry.points, - placement: 'over', - }, - children: entry.points.map((point) => ({ - kind: 'dot', - key: point.key, - x: point.x, - y: point.y, - radius: 5, - style: { - fill: 'var(--ts-chart-focus-fill, Canvas)', - stroke: point.color, - strokeWidth: 2.5, + nodes.push( + markDefaultFocusLayer({ + kind: 'group', + key: `default-focus:${entry.markId}`, + className: 'ts-chart__focus-layer ts-chart__focus-layer--default', + ariaHidden: true, + clip: entry.clipped ? chart : undefined, + focus: { + match: 'primary', + anchors: entry.points, + points: entry.points, + placement: 'over', }, - })), - }) + children: entry.points.map((point) => ({ + kind: 'dot', + key: point.key, + x: point.x, + y: point.y, + radius: 5, + style: { + fill: 'var(--ts-chart-focus-fill, Canvas)', + stroke: point.color, + strokeWidth: 2.5, + }, + })), + }), + ) } } diff --git a/packages/charts-core/src/svg-focus-guide-serializer.ts b/packages/charts-core/src/svg-focus-guide-serializer.ts index e12c9a5a..3eb702a0 100644 --- a/packages/charts-core/src/svg-focus-guide-serializer.ts +++ b/packages/charts-core/src/svg-focus-guide-serializer.ts @@ -8,6 +8,52 @@ import type { SceneNode, } from './types' +export function renderSvgFocusLayerWithRenderer< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + svg: SVGSVGElement, + scene: ChartScene, + node: SceneGroup, + options: RenderChartSvgOptions, + renderSvg: ChartSvgRenderer, + placement?: 'under' | 'over', +): string { + const root = parseSvgMarkup( + svg.ownerDocument, + renderSvg({ ...scene, nodes: [node] }, options), + ) + const layer = root && keyedElement(root, node.key) + if (!root || !layer || layer.localName !== 'g') { + throw new Error( + `The SVG renderer must preserve a g[data-ts-key="${node.key}"] element when serializing focus guides.`, + ) + } + if (placement) { + layer.classList.add( + 'ts-chart__focus-guide-layer', + `ts-chart__focus-guide-layer--${placement}`, + ) + layer.setAttribute('data-ts-focus-layer', placement) + layer.setAttribute('data-ts-focus-guide-layer', placement) + layer.setAttribute('aria-hidden', 'true') + layer.setAttribute( + 'visibility', + node.children.length ? 'visible' : 'hidden', + ) + mergeFocusGuideClipFallback( + svg.ownerDocument, + layer, + node.children, + placement, + options.idPrefix ?? '', + ) + } + copyMissingRendererDefinitions(svg, root, layer, node.key) + return layer.outerHTML +} + export function renderFocusGuideLayerWithRenderer< TDatum, TXValue extends ChartValue, @@ -20,7 +66,6 @@ export function renderFocusGuideLayerWithRenderer< options: RenderChartSvgOptions, renderSvg: ChartSvgRenderer, ): string { - const document = svg.ownerDocument const key = `focus-guide-layer:${placement}` const wrapper: SceneGroup = { kind: 'group', @@ -29,39 +74,14 @@ export function renderFocusGuideLayerWithRenderer< ariaHidden: true, children: nodes, } - const markup = renderSvg( - { - ...scene, - nodes: [wrapper], - focusGuides: undefined, - }, + return renderSvgFocusLayerWithRenderer( + svg, + { ...scene, focusGuides: undefined }, + wrapper, options, - ) - const root = parseSvgMarkup(document, markup) - const layer = root ? keyedElement(root, key) : undefined - if (!root || !layer || layer.localName !== 'g') { - throw new Error( - `The SVG renderer must preserve a g[data-ts-key="${key}"] element when serializing focus guides.`, - ) - } - - layer.classList.add( - 'ts-chart__focus-guide-layer', - `ts-chart__focus-guide-layer--${placement}`, - ) - layer.setAttribute('data-ts-focus-layer', placement) - layer.setAttribute('data-ts-focus-guide-layer', placement) - layer.setAttribute('aria-hidden', 'true') - layer.setAttribute('visibility', nodes.length ? 'visible' : 'hidden') - mergeFocusGuideClipFallback( - document, - layer, - nodes, + renderSvg, placement, - options.idPrefix ?? '', ) - copyMissingRendererDefinitions(svg, root, layer, key) - return layer.outerHTML } function mergeFocusGuideClipFallback( diff --git a/packages/charts-core/src/svg-render-context-internal.ts b/packages/charts-core/src/svg-render-context-internal.ts new file mode 100644 index 00000000..b2b3249c --- /dev/null +++ b/packages/charts-core/src/svg-render-context-internal.ts @@ -0,0 +1,30 @@ +import type { RenderChartSvgOptions, SceneGroup, SceneNode } from './types' + +export type SvgRenderChildren = ( + group: SceneGroup, +) => readonly SceneNode[] | undefined + +const childrenByOptions = new WeakMap< + RenderChartSvgOptions, + SvgRenderChildren +>() + +export function svgRenderChildren(options: RenderChartSvgOptions) { + return childrenByOptions.get(options) +} + +/** Keep renderer-local presentation out of the public scene and options. */ +export function withSvgRenderChildren( + options: RenderChartSvgOptions, + children: SvgRenderChildren, + render: () => T, +): T { + const previous = childrenByOptions.get(options) + childrenByOptions.set(options, children) + try { + return render() + } finally { + if (previous) childrenByOptions.set(options, previous) + else childrenByOptions.delete(options) + } +} diff --git a/packages/charts-core/src/svg-renderer.test.ts b/packages/charts-core/src/svg-renderer.test.ts index af0d7c39..e0f4b431 100644 --- a/packages/charts-core/src/svg-renderer.test.ts +++ b/packages/charts-core/src/svg-renderer.test.ts @@ -3,6 +3,22 @@ import { renderChartSvg } from './svg' import type { ChartScene } from './types' describe('SVG scene renderer', () => { + it('escapes attributes and labels without changing entities or Unicode', () => { + const text = 'A & "quoted" \u0000 🌈' + const scene = { + ...testScene(), + nodes: [{ kind: 'label' as const, key: text, text, x: 1, y: 2 }], + } + const svg = renderChartSvg(scene, { ariaLabel: text }) + expect(svg).toContain( + 'aria-label="A &amp; <tag> "quoted" \u0000 🌈"', + ) + expect(svg).toContain( + 'data-ts-key="A &amp; <tag> "quoted" \u0000 🌈"', + ) + expect(svg).toContain('>A &amp; <tag> "quoted" \u0000 🌈') + }) + it('renders structured disconnected polygons and holes with even-odd fill', () => { const scene = testScene() const svg = renderChartSvg(scene, { ariaLabel: 'Density contour' }) diff --git a/packages/charts-core/src/svg-renderer.ts b/packages/charts-core/src/svg-renderer.ts index aa191bf5..83812e2b 100644 --- a/packages/charts-core/src/svg-renderer.ts +++ b/packages/charts-core/src/svg-renderer.ts @@ -1,3 +1,4 @@ +import { number, escapeText, escapeAttribute } from './markup-internal' import type { ChartScene, RenderChartSvgOptions, @@ -6,6 +7,10 @@ import type { ScenePolygon, SceneStyle, } from './types' +import { + svgRenderChildren, + type SvgRenderChildren, +} from './svg-render-context-internal' export interface ChartSvgRenderHooks { renderDefinitions?: (scene: ChartScene, idPrefix: string) => string @@ -46,15 +51,19 @@ export function renderChartSvgWithHooks( idPrefix, ) - return `${description}${definitions}${background}${renderSceneNodes(scene.nodes, idPrefix, hooks)}` + return `${description}${definitions}${background}${renderSceneNodes(scene.nodes, idPrefix, hooks, svgRenderChildren(options))}` } export function renderSceneNodes( nodes: readonly SceneNode[], idPrefix = '', hooks?: ChartSvgRenderHooks, + children?: SvgRenderChildren, ): string { - return nodes.map((node) => renderNode(node, hooks, idPrefix)).join('') + let markup = '' + for (const node of nodes) + markup += renderNode(node, hooks, idPrefix, children) + return markup } export function renderFocusGuideLayer( @@ -68,13 +77,14 @@ export function renderFocusGuideLayer( } const focusGuideRenderHooks: ChartSvgRenderHooks = { - renderGroup: renderFocusGuideClip, + renderGroup: renderSvgClip, } function renderNode( node: SceneNode, hooks: ChartSvgRenderHooks | undefined, idPrefix: string, + children?: SvgRenderChildren, ): string { const common = renderCommon(node, hooks, idPrefix) @@ -88,19 +98,12 @@ function renderNode( const focus = node.focus ? ` data-ts-focus-layer="${node.focus.placement}"${node.focus.retarget ? ' data-ts-focus-retarget="true"' : ''} visibility="hidden"` : '' - return `${extension?.content ?? ''}${node.children.map((child) => renderNode(child, hooks, idPrefix)).join('')}` + return `${extension?.content ?? ''}${renderSceneNodes(children?.(node) ?? node.children, idPrefix, hooks, children)}` } case 'rule': return `` case 'polyline': { - const path = - node.path ?? - node.points - .map( - ([x, y], index) => - `${index === 0 ? 'M' : 'L'}${number(x)},${number(y)}`, - ) - .join('') + const path = node.path ?? pointsPath(node.points, false) return `` } case 'area': { @@ -156,7 +159,7 @@ function pointsPath( .join('')}${close ? 'Z' : ''}` } -function renderFocusGuideClip(node: SceneGroup, idPrefix: string) { +export function renderSvgClip(node: SceneGroup, idPrefix: string) { if (!node.clip) return undefined const prefix = idPrefix.replaceAll(/[^a-zA-Z0-9_-]/g, '') const id = `${prefix ? `${prefix}-` : ''}ts-chart-clip-${stableId(node.key)}` @@ -193,39 +196,34 @@ function renderStyle( idPrefix: string, ): string { if (!style) return '' - const paint = (value: string | undefined) => - value && hooks?.resolvePaint ? hooks.resolvePaint(value, idPrefix) : value - const attributes: [string, string | number | undefined][] = [ - ['fill', paint(style.fill)], - ['fill-opacity', style.fillOpacity], - ['stroke', paint(style.stroke)], - ['stroke-opacity', style.strokeOpacity], - ['stroke-width', style.strokeWidth], - ['opacity', style.opacity], - ['stroke-linecap', style.lineCap], - ['stroke-linejoin', style.lineJoin], - ['stroke-dasharray', style.strokeDasharray], - ] - return attributes - .filter((entry): entry is [string, string | number] => entry[1] != null) - .map( - ([name, value]) => - ` ${name}="${typeof value === 'number' ? number(value) : escapeAttribute(value)}"`, - ) - .join('') -} - -function number(value: number): string { - return String(Math.round(value * 100) / 100) + return ( + renderAttribute('fill', paint(style.fill, hooks, idPrefix)) + + renderAttribute('fill-opacity', style.fillOpacity) + + renderAttribute('stroke', paint(style.stroke, hooks, idPrefix)) + + renderAttribute('stroke-opacity', style.strokeOpacity) + + renderAttribute('stroke-width', style.strokeWidth) + + renderAttribute('opacity', style.opacity) + + renderAttribute('stroke-linecap', style.lineCap) + + renderAttribute('stroke-linejoin', style.lineJoin) + + renderAttribute('stroke-dasharray', style.strokeDasharray) + ) } -function escapeText(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') +function paint( + value: string | undefined, + hooks: ChartSvgRenderHooks | undefined, + idPrefix: string, +) { + return value && hooks?.resolvePaint + ? hooks.resolvePaint(value, idPrefix) + : value } -function escapeAttribute(value: string): string { - return escapeText(value).replaceAll('"', '"') +function renderAttribute( + name: string, + value: string | number | undefined, +): string { + return value == null + ? '' + : ` ${name}="${typeof value === 'number' ? number(value) : escapeAttribute(value)}"` } diff --git a/packages/charts-core/src/svg-surface.test.ts b/packages/charts-core/src/svg-surface.test.ts index 0f23946f..57fa0649 100644 --- a/packages/charts-core/src/svg-surface.test.ts +++ b/packages/charts-core/src/svg-surface.test.ts @@ -9,6 +9,166 @@ import { renderChartSvgWithResources } from './svg-resources' import { createSvgChartRenderer, svgChartRenderer } from './svg-surface' describe('SVG surface coordinates', () => { + it('creates only active default focus circles without replacing base marks', () => { + const rows = Array.from({ length: 200 }, (_, x) => ({ x, y: x % 5 })) + const scene = createChartScene( + defineChart({ + marks: [dot(rows, { x: 'x', y: 'y', fill: 'url(#points)' })], + scales: { + x: { + scale: scaleLinear().domain([0, 199]), + viewport: { domain: [0, 100] }, + }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, + gradients: [ + { + id: 'points', + stops: [ + { offset: 0, color: 'red' }, + { offset: 1, color: 'blue' }, + ], + }, + ], + guides: false, + }), + { width: 480, height: 240 }, + ) + const container = document.createElement('div') + const renderer = createSvgChartRenderer((currentScene, options) => + renderChartSvg(currentScene, options).replaceAll( + ' {}) + surface.render(scene, options) + const marks = [...container.querySelectorAll('.ts-chart__marks circle')] + const layer = container.querySelector('.ts-chart__focus-layer--default')! + expect(marks).toHaveLength(200) + expect(layer.querySelectorAll('circle')).toHaveLength(0) + expect(scene.points).toHaveLength(200) + const [first, second] = scene.points + if (!first || !second) throw new Error('Expected focus candidates') + for (const [point, source] of [ + [first, 'pointer'], + [second, 'keyboard'], + ] as const) { + surface.paintFocus({ + primary: point, + group: [first, second], + source, + pinned: true, + }) + const circles = [...layer.querySelectorAll('circle')] + expect(circles).toHaveLength(1) + expect(circles[0]?.getAttribute('data-ts-key')).toBe(point.key) + expect(circles[0]?.getAttribute('visibility')).toBe('visible') + expect(circles[0]?.getAttribute('data-custom')).toBe('true') + expect(circles[0]?.getAttribute('r')).toBe('5') + expect(circles[0]?.getAttribute('stroke')).toBe('url(#dense-points)') + expect(Number(circles[0]?.getAttribute('cx'))).toBeCloseTo(point.x) + expect(Number(circles[0]?.getAttribute('cy'))).toBeCloseTo(point.y) + expect(layer.getAttribute('clip-path')).toMatch(/^url\(#dense-/) + expect(layer.querySelector('clipPath rect')).not.toBeNull() + expect([ + ...container.querySelectorAll('.ts-chart__marks circle'), + ]).toEqual(marks) + } + surface.paintFocus(null) + expect(layer.getAttribute('visibility')).toBe('hidden') + expect(layer.querySelectorAll('circle')).toHaveLength(0) + surface.destroy() + }) + + it('retains interpolated focus geometry when an animation starts after a static mount', () => { + const sceneAt = (offset: number) => + createChartScene( + defineChart({ + marks: [ + dot( + [ + { id: 'a', x: 1 + offset, y: 1 }, + { id: 'b', x: 2 + offset, y: 2 }, + ], + { x: 'x', y: 'y', key: 'id' }, + ), + ], + scales: { + x: { scale: scaleLinear().domain([0, 5]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, + guides: false, + }), + { width: 500, height: 250 }, + ) + const first = sceneAt(0) + const next = sceneAt(1) + const frames: FrameRequestCallback[] = [] + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback) + return frames.length + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + const container = document.createElement('div') + const surface = svgChartRenderer.mount(container, () => {}) + surface.render(first, { ariaLabel: 'Animated focus' }) + expect( + container.querySelectorAll('.ts-chart__focus-layer--default circle'), + ).toHaveLength(0) + surface.render(next, { + ariaLabel: 'Animated focus', + animation: { duration: 100 }, + }) + const point = next.points[1]! + const focus = { + primary: point, + group: [point], + source: 'pointer' as const, + pinned: false, + } + surface.paintFocus(focus) + const ring = container.querySelector( + `.ts-chart__focus-layer--default circle[data-ts-key="${point.key}"]`, + )! + expect(Number(ring.getAttribute('cx'))).toBeCloseTo(first.points[1]!.x) + frames.shift()?.(0) + frames.shift()?.(50) + expect(Number(ring.getAttribute('cx'))).toBeGreaterThan(first.points[1]!.x) + expect(Number(ring.getAttribute('cx'))).toBeLessThan(point.x) + surface.paintFocus(focus) + expect(Number(ring.getAttribute('cx'))).toBeLessThan(point.x) + frames.shift()?.(100) + expect(Number(ring.getAttribute('cx'))).toBeCloseTo(point.x) + expect(ring.getAttribute('visibility')).toBe('visible') + surface.destroy() + }) + + it('does not leak deferred focus serialization into standalone exports after an error', () => { + const scene = createChartScene( + defineChart({ + marks: [dot([1, 2])], + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 2]) }, + }, + }), + { width: 200, height: 100 }, + ) + const options = { ariaLabel: 'Export' } + const renderer = createSvgChartRenderer(() => { + throw new Error('custom renderer') + }) + expect(() => renderer.prerender(scene, options)).toThrow('custom renderer') + const container = document.createElement('div') + container.innerHTML = renderChartSvg(scene, options) + expect( + container.querySelectorAll('.ts-chart__focus-layer--default circle'), + ).toHaveLength(2) + }) + it('mounts viewport content with the fixed authored plot clip', () => { const scene = createChartScene( defineChart({ diff --git a/packages/charts-core/src/svg-surface.ts b/packages/charts-core/src/svg-surface.ts index bb23a087..96d283bd 100644 --- a/packages/charts-core/src/svg-surface.ts +++ b/packages/charts-core/src/svg-surface.ts @@ -1,9 +1,18 @@ import { reconcileChartSvg, reconcileChartSvgFragment } from './reconcile' import { renderChartSvg } from './svg' -import { focusedNodeKeys, resolveFocusScene } from './focus-layer' +import { + focusedNodeKeys, + resolveFocusScene, + selectedFocusChildren, +} from './focus-layer' +import { isDefaultFocusLayer } from './default-focus-internal' +import { withSvgRenderChildren } from './svg-render-context-internal' import { resolveFocusGuides } from './focus-presentation' import { renderFocusGuideLayer } from './svg-renderer' -import { renderFocusGuideLayerWithRenderer } from './svg-focus-guide-serializer' +import { + renderFocusGuideLayerWithRenderer, + renderSvgFocusLayerWithRenderer, +} from './svg-focus-guide-serializer' import { detachSvgFocusGuideLayers, ensureSvgFocusGuideLayer, @@ -38,7 +47,8 @@ export function createSvgChartRenderer< ): ChartRenderer { const renderer: ChartRenderer = { id: 'svg', - prerender: renderSvg, + prerender: (scene, options) => + renderWithFocus(scene, options, null, renderSvg), mount(container) { let cancelAnimation = () => {} let cancelFocusAnimation = () => {} @@ -47,6 +57,8 @@ export function createSvgChartRenderer< let stateTransition: ChartMarkStateTransition | undefined let markStatePainted = false let retargetedFocus = false + let currentFocus: ChartFocusState | null = null + let eagerFocus = false const svgElement = () => { const svg = container.querySelector('svg.ts-chart') if (!svg) { @@ -57,6 +69,23 @@ export function createSvgChartRenderer< return svg } + // Animated updates need the previous geometry of every possible target. + // Keep that existing path once animation is requested, without making + // static mounts pay for thousands of inactive circles. + const prepareAnimatedFocus = () => { + if (eagerFocus) return + eagerFocus = true + if (!scene || !renderOptions) return + paintSvgFocus( + svgElement(), + scene, + currentFocus, + renderOptions, + renderSvg, + 'all', + ) + } + const surface: ChartSurface = { renderer, get element() { @@ -66,6 +95,7 @@ export function createSvgChartRenderer< const viewportMoved = Boolean( scene && viewportTranslationChanged(scene, nextScene), ) + if (options.animation) prepareAnimatedFocus() cancelAnimation() cancelFocusAnimation() cancelFocusAnimation = () => {} @@ -75,7 +105,9 @@ export function createSvgChartRenderer< : {} cancelAnimation = reconcileChartSvg( container, - renderSvg(nextScene, options), + eagerFocus + ? renderSvg(nextScene, options) + : renderWithFocus(nextScene, options, currentFocus, renderSvg), viewportMoved ? undefined : options.animation, ) if (retainsFocusGuideLayers) { @@ -102,6 +134,11 @@ export function createSvgChartRenderer< const state = resolveMarkStateScene(scene, focus, pointer) const resolved = resolveFocusScene(state.scene, focus) const previousTransition = stateTransition + const transition = resolveMarkStateTransition( + state.transition ?? previousTransition, + container, + ) + if (transition) prepareAnimatedFocus() if ( resolved.scene !== scene || markStatePainted || @@ -114,11 +151,15 @@ export function createSvgChartRenderer< cancelAnimation() cancelAnimation = reconcileChartSvg( container, - renderSvg(resolved.scene, renderOptions), - resolveMarkStateTransition( - state.transition ?? previousTransition, - container, - ), + eagerFocus + ? renderSvg(resolved.scene, renderOptions) + : renderWithFocus( + resolved.scene, + renderOptions, + focus, + renderSvg, + ), + transition, ) restoreSvgFocusGuideLayers(svgElement(), focusGuideLayers) } @@ -127,7 +168,15 @@ export function createSvgChartRenderer< stateTransition = focus ? (state.transition ?? previousTransition) : undefined - paintSvgFocus(svgElement(), resolved.scene, focus) + currentFocus = focus + paintSvgFocus( + svgElement(), + resolved.scene, + focus, + renderOptions, + renderSvg, + !eagerFocus, + ) cancelFocusAnimation() cancelFocusAnimation = paintSvgFocusGuides( svgElement(), @@ -155,10 +204,17 @@ export function createSvgChartRenderer< export const svgChartRenderer = createSvgChartRenderer() -function paintSvgFocus( +function paintSvgFocus< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( svg: SVGSVGElement, - scene: ChartScene, + scene: ChartScene, focus: ChartFocusState | null, + options: ChartSurfaceRenderOptions, + renderSvg: ChartSvgRenderer, + deferDefaultFocus: boolean | 'all', ): void { const sceneLayers = collectFocusLayers(scene.nodes) const elements = svg.querySelectorAll( @@ -166,6 +222,35 @@ function paintSvgFocus( ) elements.forEach((element, index) => { const layer = sceneLayers[index] + if (layer && deferDefaultFocus && isDefaultFocusLayer(layer)) { + const children = + deferDefaultFocus === 'all' + ? layer.children + : focus + ? selectedFocusChildren(layer, focus) + : [] + const existing = [...element.children].filter( + (child) => child.localName === 'circle', + ) + if ( + existing.length !== children.length || + existing.some( + (child, index) => + child.getAttribute('data-ts-key') !== children[index]?.key, + ) + ) { + reconcileChartSvgFragment( + element, + renderSvgFocusLayerWithRenderer( + svg, + scene, + { ...layer, children }, + options, + renderSvg, + ), + ) + } + } const visible = layer ? focusedNodeKeys(layer, focus) : new Set() element.setAttribute( 'visibility', @@ -181,6 +266,28 @@ function paintSvgFocus( }) } +function renderWithFocus< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + scene: ChartScene, + options: Parameters>[1], + focus: ChartFocusState | null, + renderSvg: ChartSvgRenderer, +): string { + return withSvgRenderChildren( + options, + (layer) => + isDefaultFocusLayer(layer) + ? focus + ? selectedFocusChildren(layer, focus) + : [] + : undefined, + () => renderSvg(scene, options), + ) +} + function paintSvgFocusGuides< TDatum, TXValue extends ChartValue, diff --git a/packages/charts-core/src/svg.ts b/packages/charts-core/src/svg.ts index 41bb0404..fc7c5132 100644 --- a/packages/charts-core/src/svg.ts +++ b/packages/charts-core/src/svg.ts @@ -1,8 +1,10 @@ +import { number, escapeAttribute } from './markup-internal' import { renderChartSvgWithHooks, + renderSvgClip, type ChartSvgRenderHooks, } from './svg-renderer' -import type { ChartScene, RenderChartSvgOptions, SceneGroup } from './types' +import type { ChartScene, RenderChartSvgOptions } from './types' export function renderChartSvg( scene: ChartScene, @@ -12,14 +14,16 @@ export function renderChartSvg( return renderChartSvgWithHooks(scene, options, { renderDefinitions: (currentScene, idPrefix) => renderGradients(currentScene, sanitizeId(idPrefix)), - renderGroup: (group, idPrefix) => renderClip(group, sanitizeId(idPrefix)), - resolvePaint: (value, idPrefix) => { - const match = /^url\(#([^)]+)\)$/.exec(value) - const id = match?.[1] - return id && gradientIds.has(id) - ? `url(#${scopedId(sanitizeId(idPrefix), id)})` - : value - }, + renderGroup: renderSvgClip, + resolvePaint: gradientIds.size + ? (value, idPrefix) => { + const match = /^url\(#([^)]+)\)$/.exec(value) + const id = match?.[1] + return id && gradientIds.has(id) + ? `url(#${scopedId(sanitizeId(idPrefix), id)})` + : value + } + : undefined, } satisfies ChartSvgRenderHooks) } @@ -38,15 +42,6 @@ function renderGradients(scene: ChartScene, idPrefix: string) { .join('')}` } -function renderClip(group: SceneGroup, idPrefix: string) { - if (!group.clip) return undefined - const id = scopedId(idPrefix, `ts-chart-clip-${stableId(group.key)}`) - return { - attributes: ` clip-path="url(#${id})"`, - content: ``, - } -} - function scopedId(prefix: string, id: string) { return prefix ? `${prefix}-${id}` : id } @@ -58,23 +53,3 @@ function sanitizeId(value: string) { function percent(value: number) { return `${number(Math.max(0, Math.min(1, value)) * 100)}%` } - -function stableId(value: string) { - let hash = 2166136261 - for (let index = 0; index < value.length; index += 1) { - hash = Math.imul(hash ^ value.charCodeAt(index), 16777619) - } - return (hash >>> 0).toString(36) -} - -function number(value: number) { - return String(Math.round(value * 100) / 100) -} - -function escapeAttribute(value: string) { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') -} diff --git a/packages/react-charts/src/CanvasChart.test.tsx b/packages/react-charts/src/CanvasChart.test.tsx index 276947e0..593a1400 100644 --- a/packages/react-charts/src/CanvasChart.test.tsx +++ b/packages/react-charts/src/CanvasChart.test.tsx @@ -77,6 +77,27 @@ if (false) { } describe('React Canvas adapter', () => { + it('builds a client-only canvas scene once', async () => { + const getContext = mockCanvasContexts() + const build = vi.fn(() => definition) + const target = document.createElement('div') + const root = createRoot(target) + await act(async () => + root.render( + , + ), + ) + expect(build).toHaveBeenCalledTimes(1) + expect(target.querySelector('.ts-chart-canvas__scene')).not.toBeNull() + await act(async () => root.unmount()) + getContext.mockRestore() + }) + it('server-renders an accessible Canvas shell without using Canvas APIs', () => { const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext') diff --git a/packages/react-charts/src/Chart.test.tsx b/packages/react-charts/src/Chart.test.tsx index fc75ede4..1a40ed34 100644 --- a/packages/react-charts/src/Chart.test.tsx +++ b/packages/react-charts/src/Chart.test.tsx @@ -299,6 +299,103 @@ describe('React adapter', () => { target.remove() }) + it('builds and serializes a client-only chart once before parent layout effects', async () => { + const build = vi.fn(() => definition) + const dynamic = defineChart(build) + const serialize = vi.fn(renderChartSvgWithResources) + const target = document.createElement('div') + const root = createRoot(target) + const observed = vi.fn() + function Parent() { + React.useLayoutEffect(() => { + observed(target.querySelector('svg path')) + }, []) + return ( + + ) + } + await act(async () => root.render()) + expect(build).toHaveBeenCalledTimes(1) + expect(serialize).toHaveBeenCalledTimes(1) + expect(observed).toHaveBeenCalledWith(expect.any(SVGElement)) + await act(async () => root.unmount()) + }) + + it('avoids a post-mount font read but refreshes typography on later updates', async () => { + const target = document.createElement('div') + document.body.append(target) + const root = createRoot(target) + const serialize = vi.fn(renderChartSvgWithResources) + const original = window.getComputedStyle.bind(window) + const reads: boolean[] = [] + const spy = vi + .spyOn(window, 'getComputedStyle') + .mockImplementation((element) => { + if (element.classList.contains('ts-chart-surface')) { + reads.push(Boolean(element.querySelector('svg'))) + } + return original(element) + }) + const render = () => + root.render( + , + ) + try { + await act(async () => render()) + expect(reads.length).toBeGreaterThan(0) + expect(reads).not.toContain(true) + expect(serialize).toHaveBeenCalledTimes(1) + const surface = target.querySelector('.ts-chart-surface')! + surface.style.fontFamily = 'monospace' + await act(async () => render()) + expect(reads).toContain(true) + expect(serialize).toHaveBeenCalledTimes(2) + } finally { + await act(async () => root.unmount()) + spy.mockRestore() + target.remove() + } + }) + + it('keeps complete SSR markup and hydration identity in Strict Mode', async () => { + const target = document.createElement('div') + const chart = ( + + + + ) + target.innerHTML = renderToString(chart) + const svg = target.querySelector('svg') + const path = target.querySelector('.ts-chart__marks path') + expect(path).not.toBeNull() + const recoverable = vi.fn() + let root!: ReturnType + await act(async () => { + root = hydrateRoot(target, chart, { onRecoverableError: recoverable }) + }) + expect(target.querySelector('svg')).toBe(svg) + expect(target.querySelector('.ts-chart__marks path')).toBe(path) + expect(recoverable).not.toHaveBeenCalled() + await act(async () => root.unmount()) + }) + it('server-renders the complete shared SVG renderer output', () => { const html = renderToString( () => {} +const clientSnapshot = () => false +const serverSnapshot = () => true + interface ChartSurfaceProps { markup: string } @@ -157,21 +161,35 @@ export function RendererChartImplementation< } adapterRef.current ??= createChartRendererAdapter(hostOptions) const adapter = adapterRef.current + // Server rendering and hydration need matching initial markup. A client-only + // mount can render once in the layout effect, with the real DOM text metrics. + const needsInitialMarkup = React.useSyncExternalStore( + subscribeToHydration, + clientSnapshot, + serverSnapshot, + ) const initialMarkupRef = React.useRef(null) - initialMarkupRef.current ??= adapter.prerender() + initialMarkupRef.current ??= needsInitialMarkup ? adapter.prerender() : '' + const mountedRef = React.useRef(false) React.useLayoutEffect(() => { const container = containerRef.current if (!container) return adapter.update(hostOptions) - adapter.mount(container) - return () => adapter.destroy() - }, []) - - React.useLayoutEffect(() => { - adapter.update(hostOptions) + if (!mountedRef.current) { + adapter.mount(container) + mountedRef.current = true + } }, [adapter, hostOptions]) + React.useLayoutEffect( + () => () => { + adapter.destroy() + mountedRef.current = false + }, + [adapter], + ) + return (
name === '@tanstack/charts') assert.ok(coreInfo) @@ -307,6 +310,7 @@ export async function verifyUnifiedCoreArtifact({ `${JSON.stringify( { name: 'tanstack-charts-unified-consumer', + packageManager, private: true, type: 'module', dependencies: {