Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/early-pointer-samples.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions API-FRICTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
hardware latency improvement has been established.

### F-244 — Focus cursor width depended on private band inference

- Status: resolved
Expand Down
16 changes: 8 additions & 8 deletions benchmarks/bundle-size/universal-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/comparison/bundle-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"sources": {
"tanstack": {
"kind": "workspace",
"revision": "69d70960dd6b29f3706a173d348f2fc8271b0dcc",
"inputDigest": "sha256:8115665ae3c573649f47d236836e28c8cf14d513be1117a8e5c720e3a618680c"
"revision": "278cb921a56c785cbb6875d55c1cfc06eb655177",
"inputDigest": "sha256:43c08c18fbff692699ea249f1a3575b0f9b7fa3417547062616dbb0c44b3b143"
},
"chartjs": {
"kind": "package",
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/conformance/previews/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"width": 288,
"height": 192,
"sourceHash": "bd192a70a226c4e49e5f687cdf4572478b7f465baf62fe6f564e86309f669ff0",
"sourceHash": "c0a8f6f71595708e1525044cae14a913f6f92d0e4a69cabe45508420372142e4",
"assets": [
{
"id": "01-line-gaps",
Expand Down
4 changes: 2 additions & 2 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/chart-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ does not return or own them.

`ChartDefinitionOptions<TDatum, TXValue, TYValue>` 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.

Expand Down
15 changes: 15 additions & 0 deletions docs/reference/focus-and-interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions packages/charts-core/docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/charts-core/docs/reference/chart-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ does not return or own them.

`ChartDefinitionOptions<TDatum, TXValue, TYValue>` 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.

Expand Down
15 changes: 15 additions & 0 deletions packages/charts-core/docs/reference/focus-and-interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
142 changes: 141 additions & 1 deletion packages/charts-core/src/renderer.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
})
})
31 changes: 29 additions & 2 deletions packages/charts-core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1015,6 +1041,7 @@ export function mountChartRenderer<
nextOptions.definition.pointer === false &&
focusOwner === 'pointer'
options = nextOptions
configurePointerEvent()
configureCursorController()
syncTooltip()
if (pointerDisabled) {
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/charts-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,8 @@ export interface ChartDefinitionOptions<
motion?: ChartMotionDefinition<NoInfer<TDatum>>
/** 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<TDatum>,
Expand All @@ -997,6 +999,7 @@ interface StoredChartDefinitionOptions<TTooltipHost extends string = string> {
svgAnimation?: boolean | ChartAnimationOptions
motion?: ChartMotionDefinition<any>
pointer?: boolean
pointerEvent?: 'pointermove' | 'pointerrawupdate'
keyboard?: boolean
selection?: ChartSelectionController<any, any, any>
controls?: readonly ChartControl<any, any>[]
Expand Down
Loading