diff --git a/.changeset/react-tooltip-body-sync.md b/.changeset/react-tooltip-body-sync.md new file mode 100644 index 00000000..91b33045 --- /dev/null +++ b/.changeset/react-tooltip-body-sync.md @@ -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. diff --git a/packages/react-charts/src/Chart.test.tsx b/packages/react-charts/src/Chart.test.tsx index 1f22f9c6..456b92a0 100644 --- a/packages/react-charts/src/Chart.test.tsx +++ b/packages/react-charts/src/Chart.test.tsx @@ -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, ) diff --git a/packages/react-charts/src/tooltip.test.tsx b/packages/react-charts/src/tooltip.test.tsx index c96c0a7c..8a832c2c 100644 --- a/packages/react-charts/src/tooltip.test.tsx +++ b/packages/react-charts/src/tooltip.test.tsx @@ -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( + ( + {primaryPoint?.datum.y} + )} + />, + ), + ) + 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 ( + + ) + } + 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( + } + />, + ), + ) + 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('[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() + } + } + }, + ) }) diff --git a/packages/react-charts/src/tooltip.tsx b/packages/react-charts/src/tooltip.tsx index e8e82925..0b5b8a38 100644 --- a/packages/react-charts/src/tooltip.tsx +++ b/packages/react-charts/src/tooltip.tsx @@ -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, @@ -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, @@ -163,7 +182,8 @@ function TooltipBody< > | null>(null) const onChange = React.useCallback( (nextTarget: ChartTooltipBodyTarget | null) => { - setTarget(nextTarget) + if (nextTarget === null || hostCommit) setTarget(nextTarget) + else flushSync(() => setTarget(nextTarget)) }, [], ) @@ -173,7 +193,9 @@ function TooltipBody< const chart = React.useMemo(() => children(notify), [children, notify]) return ( <> + {chart} + {renderTooltipBody && target ? createPortal( renderTooltipBody({