Skip to content
5 changes: 5 additions & 0 deletions .changeset/react-tooltip-body-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-charts': patch
---

Render a custom tooltip body in the same task as the tooltip moves, so a pointer move no longer paints the previous point's body at the new position.
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": "c0a8f6f71595708e1525044cae14a913f6f92d0e4a69cabe45508420372142e4",
"sourceHash": "d552c73b5cdf16c08c4710962b303c320259d9fbf23b151ef1fc1350d53a1b66",
"assets": [
{
"id": "01-line-gaps",
Expand Down
13 changes: 11 additions & 2 deletions packages/react-charts/src/Chart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,15 +343,24 @@ describe('React adapter', () => {
})
const scene = createChartScene(definition, { width: 480, height: 260 })
for (const point of scene.points) {
await act(async () =>
const move = () =>
svg.dispatchEvent(
new MouseEvent('pointermove', {
bubbles: true,
clientX: point.x,
clientY: point.y,
}),
),
)
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
try {
move()
} finally {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
}
expect(target.querySelector('[data-testid="primary"]')?.textContent).toBe(
point.groupLabel,
)
await act(async () => move())
expect(target.querySelector('[data-testid="primary"]')?.textContent).toBe(
point.groupLabel,
)
Expand Down
140 changes: 140 additions & 0 deletions packages/react-charts/src/tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,144 @@ describe('tooltip state ownership', () => {
}
},
)

it('commits the body before the browser paints an interaction', async () => {
const definition = defineChart({
marks: [
dot(
[
{ x: 0, y: 1 },
{ x: 1, y: 2 },
],
{ x: 'x', y: 'y' },
),
],
scales: {
x: { scale: scaleLinear().domain([0, 1]) },
y: { scale: scaleLinear().domain([0, 3]) },
},
tooltip: { use: tooltip },
})
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
const errors = vi.spyOn(console, 'error')
const render = (width: number) =>
act(async () =>
root.render(
<Chart
definition={definition}
width={width}
height={260}
ariaLabel="chart"
renderTooltipBody={({ primaryPoint }) => (
<span data-body>{primaryPoint?.datum.y}</span>
)}
/>,
),
)
try {
await render(480)
const svg = container.querySelector('svg')
if (!svg) throw new Error('Missing SVG')
await act(async () =>
svg.dispatchEvent(new FocusEvent('focusin', { bubbles: true })),
)
expect(container.querySelector('[data-body]')?.textContent).toBe('1')
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
try {
svg.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
)
} finally {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
}
expect(container.querySelector('[data-body]')?.textContent).toBe('2')
await render(520)
expect(container.querySelector('[data-body]')?.textContent).toBe('2')
} finally {
try {
await act(async () => root.unmount())
expect(errors).not.toHaveBeenCalled()
} finally {
errors.mockRestore()
container.remove()
}
}
})

it.each([
['layout', 'setup'],
['layout', 'cleanup'],
['passive', 'setup'],
['passive', 'cleanup'],
] as const)(
'accepts a dismissal from the body %s effect %s',
async (phase, step) => {
const definition = defineChart({
marks: [
dot(
[
{ x: 0, y: 1 },
{ x: 1, y: 2 },
],
{ x: 'x', y: 'y' },
),
],
scales: {
x: { scale: scaleLinear().domain([0, 1]) },
y: { scale: scaleLinear().domain([0, 3]) },
},
tooltip: { use: tooltip },
})
const useEffect =
phase === 'layout' ? React.useLayoutEffect : React.useEffect
function Body({ dismiss }: { dismiss: () => void }) {
const [armed, setArmed] = React.useState(false)
useEffect(() => {
if (step === 'setup' && armed) dismiss()
if (step === 'cleanup' && !armed) return () => dismiss()
}, [armed, dismiss])
return (
<button data-body type="button" onClick={() => setArmed(true)}>
dismiss
</button>
)
}
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
const errors = vi.spyOn(console, 'error')
try {
await act(async () =>
root.render(
<Chart
definition={definition}
width={480}
height={260}
ariaLabel="chart"
renderTooltipBody={({ dismiss }) => <Body dismiss={dismiss} />}
/>,
),
)
const svg = container.querySelector('svg')
if (!svg) throw new Error('Missing SVG')
await act(async () =>
svg.dispatchEvent(new FocusEvent('focusin', { bubbles: true })),
)
const button = container.querySelector<HTMLElement>('[data-body]')
if (!button) throw new Error('Missing tooltip body')
await act(async () => button.click())
expect(container.querySelector('[data-body]')).toBeNull()
} finally {
try {
await act(async () => root.unmount())
expect(errors).not.toHaveBeenCalled()
} finally {
errors.mockRestore()
container.remove()
}
}
},
)
})
26 changes: 24 additions & 2 deletions packages/react-charts/src/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import * as React from 'react'
import { createPortal } from 'react-dom'
import { createPortal, flushSync } from 'react-dom'
import type {
ChartTooltipBodyContext,
ChartTooltipBodyTarget,
Expand Down Expand Up @@ -135,6 +135,25 @@ export function CanvasChart<
)
}

const useIsomorphicLayoutEffect =
typeof document === 'undefined' ? React.useEffect : React.useLayoutEffect

let hostCommit = false

function HostCommitStart() {
useIsomorphicLayoutEffect(() => {
hostCommit = true
})
return null
}

function HostCommitEnd() {
useIsomorphicLayoutEffect(() => {
hostCommit = false
})
return null
}

function TooltipBody<
TDatum,
TXValue extends ChartValue,
Expand Down Expand Up @@ -163,7 +182,8 @@ function TooltipBody<
> | null>(null)
const onChange = React.useCallback(
(nextTarget: ChartTooltipBodyTarget<TDatum, TXValue, TYValue> | null) => {
setTarget(nextTarget)
if (nextTarget === null || hostCommit) setTarget(nextTarget)
else flushSync(() => setTarget(nextTarget))
},
[],
)
Expand All @@ -173,7 +193,9 @@ function TooltipBody<
const chart = React.useMemo(() => children(notify), [children, notify])
return (
<>
<HostCommitStart />
{chart}
<HostCommitEnd />
{renderTooltipBody && target
? createPortal(
renderTooltipBody({
Expand Down