From 278cb921a56c785cbb6875d55c1cfc06eb655177 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 2 Nov 2026 17:36:40 -0700 Subject: [PATCH 1/2] feat: support opt-in raw pointer samples --- .changeset/early-pointer-samples.md | 5 + API-FRICTION.md | 9 ++ .../bundle-size/universal-baseline.json | 16 +- benchmarks/conformance/previews/manifest.json | 2 +- docs/reference/chart-definitions.md | 2 +- docs/reference/focus-and-interaction.md | 15 ++ .../docs/reference/chart-definitions.md | 2 +- .../docs/reference/focus-and-interaction.md | 15 ++ packages/charts-core/src/renderer.test.ts | 142 +++++++++++++++++- packages/charts-core/src/renderer.ts | 31 +++- packages/charts-core/src/types.ts | 3 + scripts/measure-bundles.mjs | 16 +- 12 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 .changeset/early-pointer-samples.md diff --git a/.changeset/early-pointer-samples.md b/.changeset/early-pointer-samples.md new file mode 100644 index 00000000..348f8499 --- /dev/null +++ b/.changeset/early-pointer-samples.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': minor +--- + +Add an opt-in raw pointer event stream for DOM chart hosts while preserving pointer focus ownership and falling back to pointermove when unsupported. diff --git a/API-FRICTION.md b/API-FRICTION.md index 065e0ddf..b8867294 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -7733,6 +7733,15 @@ Each entry records: its reviewed 2,583-byte increase includes the default viewport and controlled interaction contracts. +- Follow-up, issue #203: feeding early pointer samples through + `setControlledFocus` changes ownership, so ordinary pointer leave cannot + clear that application-owned focus. The opt-in `pointerEvent` option reuses + the existing pointer handler and changes only its subscribed stream. + Six focused cases cover fallback, listener replacement, leave, cancel, + pointer opt-out and destruction, with the same checks on the packed artifact + in Chromium, Firefox and WebKit. The default remains `pointermove`; no + hardware latency improvement has been established. + ### F-244 — Focus cursor width depended on private band inference - Status: resolved diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index c3a99cd8..52555f25 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": 83293, + "gzip": 29243 }, "React adapter": { - "bytes": 85319, - "gzip": 30074 + "bytes": 85618, + "gzip": 30182 }, "React line consumer": { - "bytes": 108978, - "gzip": 39424 + "bytes": 109277, + "gzip": 39544 }, "Compact-scale line scene": { "bytes": 35859, "gzip": 12797 }, "React compact-scale line consumer": { - "bytes": 91476, - "gzip": 32307 + "bytes": 91775, + "gzip": 32437 }, "Custom-scale line scene": { "bytes": 34043, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index e5001659..72a3c6cb 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "bd192a70a226c4e49e5f687cdf4572478b7f465baf62fe6f564e86309f669ff0", + "sourceHash": "c0a8f6f71595708e1525044cae14a913f6f92d0e4a69cabe45508420372142e4", "assets": [ { "id": "01-line-gaps", diff --git a/docs/reference/chart-definitions.md b/docs/reference/chart-definitions.md index 9257c6cc..e842c5b5 100644 --- a/docs/reference/chart-definitions.md +++ b/docs/reference/chart-definitions.md @@ -99,7 +99,7 @@ does not return or own them. `ChartDefinitionOptions` contains `focus`, `focusRing`, `selection`, `controls`, `cursor`, `maxFocusDistance`, -`spatialIndex`, `svgAnimation`, `pointer`, `keyboard`, and `tooltip`. These options +`spatialIndex`, `svgAnimation`, `pointer`, `pointerEvent`, `keyboard`, and `tooltip`. These options belong to both static and responsive definitions. Hosts and framework adapters do not override them. diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index a0d33fc5..94c88de3 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -1042,3 +1042,18 @@ sticky tooltip interactive. Clean up application listeners before the next attachment or unmount. For a completely independent renderer or interaction layer, use the scene and extension contracts in [Custom extensions](./custom-extensions.md). + +## Earlier pointer samples + +DOM hosts use `pointermove` by default. Set `pointerEvent: 'pointerrawupdate'` +on the chart definition to use raw pointer samples when the browser exposes +that event. Unsupported environments fall back to `pointermove`. Browser +support can depend on a secure context. + +The host keeps pointer ownership, so leaving the chart or canceling the +pointer still clears transient focus. This differs from application-owned +`setControlledFocus`. Only one move stream is active, including after updating +the definition. `pointer: false` still disables automatic pointer handling. + +Raw samples can arrive more frequently and perform more focus work. This +option does not promise a particular input-to-paint latency improvement. diff --git a/packages/charts-core/docs/reference/chart-definitions.md b/packages/charts-core/docs/reference/chart-definitions.md index 9257c6cc..e842c5b5 100644 --- a/packages/charts-core/docs/reference/chart-definitions.md +++ b/packages/charts-core/docs/reference/chart-definitions.md @@ -99,7 +99,7 @@ does not return or own them. `ChartDefinitionOptions` contains `focus`, `focusRing`, `selection`, `controls`, `cursor`, `maxFocusDistance`, -`spatialIndex`, `svgAnimation`, `pointer`, `keyboard`, and `tooltip`. These options +`spatialIndex`, `svgAnimation`, `pointer`, `pointerEvent`, `keyboard`, and `tooltip`. These options belong to both static and responsive definitions. Hosts and framework adapters do not override them. diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index a0d33fc5..94c88de3 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -1042,3 +1042,18 @@ sticky tooltip interactive. Clean up application listeners before the next attachment or unmount. For a completely independent renderer or interaction layer, use the scene and extension contracts in [Custom extensions](./custom-extensions.md). + +## Earlier pointer samples + +DOM hosts use `pointermove` by default. Set `pointerEvent: 'pointerrawupdate'` +on the chart definition to use raw pointer samples when the browser exposes +that event. Unsupported environments fall back to `pointermove`. Browser +support can depend on a secure context. + +The host keeps pointer ownership, so leaving the chart or canceling the +pointer still clears transient focus. This differs from application-owned +`setControlledFocus`. Only one move stream is active, including after updating +the definition. `pointer: false` still disables automatic pointer handling. + +Raw samples can arrive more frequently and perform more focus work. This +option does not promise a particular input-to-paint latency improvement. diff --git a/packages/charts-core/src/renderer.test.ts b/packages/charts-core/src/renderer.test.ts index 74259ea1..f0557649 100644 --- a/packages/charts-core/src/renderer.test.ts +++ b/packages/charts-core/src/renderer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import { scaleBand, scaleLinear } from 'd3-scale' import { barY } from './bar' import { createChartCursor, cursorHost } from './cursor' @@ -4164,3 +4164,143 @@ if (false) { renderer: numericRenderer, }) } + +describe('raw input ownership feasibility', () => { + beforeEach(() => { + vi.stubGlobal('onpointerrawupdate', null) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + const rawDefinition = defineChart(definition, { + pointerEvent: 'pointerrawupdate', + }) + + it('falls back to pointermove when raw input is unsupported', () => { + const descriptor = Object.getOwnPropertyDescriptor( + window, + 'onpointerrawupdate', + ) + Reflect.deleteProperty(window, 'onpointerrawupdate') + expect('onpointerrawupdate' in window).toBe(false) + const fake = createFakeRenderer() + const onFocusChange = vi.fn() + const container = document.createElement('div') + const host = mountChartRenderer(container, { + definition: rawDefinition, + renderer: fake.renderer, + width: 480, + height: 260, + ariaLabel: 'Raw fallback', + onFocusChange, + }) + container.dispatchEvent( + new MouseEvent('pointerrawupdate', { bubbles: true }), + ) + expect(onFocusChange).not.toHaveBeenCalled() + container.dispatchEvent(new MouseEvent('pointermove', { bubbles: true })) + expect(onFocusChange).toHaveBeenCalledOnce() + host.destroy() + if (descriptor) + Object.defineProperty(window, 'onpointerrawupdate', descriptor) + }) + it('switches listeners on update without processing both event streams', () => { + const fake = createFakeRenderer() + const container = document.createElement('div') + const options = { + definition, + renderer: fake.renderer, + width: 480, + height: 260, + ariaLabel: 'Raw switching', + } + const host = mountChartRenderer(container, options) + const sample = (name: string) => + container.dispatchEvent(new MouseEvent(name, { bubbles: true })) + sample('pointerrawupdate') + expect(fake.clientToScene).not.toHaveBeenCalled() + sample('pointermove') + expect(fake.clientToScene).toHaveBeenCalledOnce() + host.update({ ...options, definition: rawDefinition }) + fake.clientToScene.mockClear() + sample('pointermove') + expect(fake.clientToScene).not.toHaveBeenCalled() + sample('pointerrawupdate') + expect(fake.clientToScene).toHaveBeenCalledOnce() + host.update(options) + fake.clientToScene.mockClear() + sample('pointerrawupdate') + expect(fake.clientToScene).not.toHaveBeenCalled() + sample('pointermove') + expect(fake.clientToScene).toHaveBeenCalledOnce() + host.destroy() + }) + it.each(['mouseleave', 'pointercancel'])( + 'clears pointer focus on %s', + (event) => { + const fake = createFakeRenderer() + const onFocusChange = vi.fn() + const container = document.createElement('div') + const host = mountChartRenderer(container, { + definition: rawDefinition, + renderer: fake.renderer, + width: 480, + height: 260, + ariaLabel: 'Raw input', + onFocusChange, + }) + container.dispatchEvent( + new MouseEvent('pointerrawupdate', { + bubbles: true, + clientX: 20, + clientY: 30, + }), + ) + expect(onFocusChange).toHaveBeenLastCalledWith(host.getScene().points[0]) + container.dispatchEvent(new MouseEvent(event, { bubbles: true })) + expect(onFocusChange).toHaveBeenLastCalledWith(null) + host.destroy() + }, + ) + it('ignores raw samples when pointer interaction is disabled', () => { + const fake = createFakeRenderer() + const onFocusChange = vi.fn() + const container = document.createElement('div') + const host = mountChartRenderer(container, { + definition: defineChart(rawDefinition, { pointer: false }), + renderer: fake.renderer, + width: 480, + height: 260, + ariaLabel: 'Raw input', + onFocusChange, + }) + container.dispatchEvent( + new MouseEvent('pointerrawupdate', { bubbles: true }), + ) + expect(onFocusChange).not.toHaveBeenCalled() + host.destroy() + }) + it('removes the raw listener on destroy', () => { + const fake = createFakeRenderer() + const onFocusChange = vi.fn() + const container = document.createElement('div') + const host = mountChartRenderer(container, { + definition: rawDefinition, + renderer: fake.renderer, + width: 480, + height: 260, + ariaLabel: 'Raw input', + onFocusChange, + }) + container.dispatchEvent( + new MouseEvent('pointerrawupdate', { bubbles: true }), + ) + expect(onFocusChange).toHaveBeenCalledOnce() + host.destroy() + onFocusChange.mockClear() + container.dispatchEvent( + new MouseEvent('pointerrawupdate', { bubbles: true }), + ) + expect(onFocusChange).not.toHaveBeenCalled() + }) +}) diff --git a/packages/charts-core/src/renderer.ts b/packages/charts-core/src/renderer.ts index 8f49a7d8..4efde15d 100644 --- a/packages/charts-core/src/renderer.ts +++ b/packages/charts-core/src/renderer.ts @@ -959,7 +959,33 @@ export function mountChartRenderer< updateFocus(point ? focusPointsForPoint(point) : []) } } - container.addEventListener('pointermove', handlePointerMove) + const pointerListener = { handleEvent: handlePointerMove } + const changePointerListener = ( + add: boolean, + event: 'pointermove' | 'pointerrawupdate', + ) => { + if (event === 'pointerrawupdate') + container[add ? 'addEventListener' : 'removeEventListener']( + event, + pointerListener, + ) + else if (add) container.addEventListener('pointermove', handlePointerMove) + else container.removeEventListener('pointermove', handlePointerMove) + } + let pointerEvent: 'pointermove' | 'pointerrawupdate' | undefined + const configurePointerEvent = () => { + const next = + options.definition.pointerEvent === 'pointerrawupdate' && + view && + 'onpointerrawupdate' in view + ? 'pointerrawupdate' + : 'pointermove' + if (next === pointerEvent) return + if (pointerEvent) changePointerListener(false, pointerEvent) + pointerEvent = next + changePointerListener(true, pointerEvent) + } + configurePointerEvent() container.addEventListener('pointerdown', handlePointerDown) container.addEventListener('pointercancel', clearPointerFocus) container.addEventListener('mouseleave', clearPointerFocus) @@ -1015,6 +1041,7 @@ export function mountChartRenderer< nextOptions.definition.pointer === false && focusOwner === 'pointer' options = nextOptions + configurePointerEvent() configureCursorController() syncTooltip() if (pointerDisabled) { @@ -1060,7 +1087,7 @@ export function mountChartRenderer< unsubscribePresentation = undefined surface?.destroy() runtime.destroy() - container.removeEventListener('pointermove', handlePointerMove) + if (pointerEvent) changePointerListener(false, pointerEvent) container.removeEventListener('pointerdown', handlePointerDown) container.removeEventListener('pointercancel', clearPointerFocus) container.removeEventListener('mouseleave', clearPointerFocus) diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 6e596579..55b76ae9 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -971,6 +971,8 @@ export interface ChartDefinitionOptions< motion?: ChartMotionDefinition> /** Enables chart-owned pointer focus and selection. Defaults to true. */ pointer?: boolean + /** DOM pointer focus stream. Raw input falls back to pointermove when unsupported. */ + pointerEvent?: 'pointermove' | 'pointerrawupdate' keyboard?: boolean selection?: ChartSelectionController< NoInfer, @@ -997,6 +999,7 @@ interface StoredChartDefinitionOptions { svgAnimation?: boolean | ChartAnimationOptions motion?: ChartMotionDefinition pointer?: boolean + pointerEvent?: 'pointermove' | 'pointerrawupdate' keyboard?: boolean selection?: ChartSelectionController controls?: readonly ChartControl[] diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index da3d5383..fafd8916 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -1364,7 +1364,7 @@ const entries = [ 'Interactive legend + DOM host', 'benchmarks/entries/charts-interactive-legend.ts', 'TanStack DOM host', - 2.861, + 2.8623046875, { rendererBoundary: 'svg', inputBoundary: { @@ -1553,7 +1553,7 @@ const entries = [ 'Horizontal zoom + DOM host', 'benchmarks/entries/charts-zoom-x.ts', 'TanStack DOM host', - 20.55, + 20.55078125, { rendererBoundary: 'svg', inputBoundary: { @@ -1628,7 +1628,7 @@ const entries = [ budgeted( 'React line consumer + mark Canvas renderer', 'benchmarks/entries/charts-react-line-mark-canvas.ts', - 45.548828125, + 45.6552734375, { 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.6767578125, { 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.4853515625, ), 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.9814453125, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 45.1396484375, + 45.244140625, ), 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.5732421875, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot minimal line', 'benchmarks/entries/minimal-line.ts'), From 2650cf5089339a3838cd9c92d5d18a14db3bc881 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 2 Nov 2026 17:38:19 -0700 Subject: [PATCH 2/2] chore: bind raw input comparison to verified source --- benchmarks/comparison/bundle-baseline.json | 4 ++-- docs/comparison.md | 4 ++-- packages/charts-core/docs/comparison.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index a7de76c4..35ea1bbb 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "69d70960dd6b29f3706a173d348f2fc8271b0dcc", - "inputDigest": "sha256:8115665ae3c573649f47d236836e28c8cf14d513be1117a8e5c720e3a618680c" + "revision": "278cb921a56c785cbb6875d55c1cfc06eb655177", + "inputDigest": "sha256:43c08c18fbff692699ea249f1a3575b0f9b7fa3417547062616dbb0c44b3b143" }, "chartjs": { "kind": "package", diff --git a/docs/comparison.md b/docs/comparison.md index a22701cf..fd1b6630 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 `278cb92` | | [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 `278cb92`. ## Capability matrix diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index a22701cf..fd1b6630 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 `278cb92` | | [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 `278cb92`. ## Capability matrix