Skip to content
Open
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/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.
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