From f37cd91c080b27fcab14b8dd53edc7ffc267d808 Mon Sep 17 00:00:00 2001 From: Piotr Kulpinski Date: Wed, 21 Oct 2026 14:41:05 +0200 Subject: [PATCH 1/6] fix(react): render a custom tooltip body before the moved tooltip paints Fixes #239 --- .changeset/react-tooltip-body-sync.md | 5 ++ packages/react-charts/src/RendererChart.tsx | 29 ++++++++-- packages/react-charts/src/tooltip.test.tsx | 62 +++++++++++++++++++++ packages/react-charts/src/tooltip.tsx | 6 +- 4 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 .changeset/react-tooltip-body-sync.md 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/RendererChart.tsx b/packages/react-charts/src/RendererChart.tsx index 570e0b1f..b95dd4c2 100644 --- a/packages/react-charts/src/RendererChart.tsx +++ b/packages/react-charts/src/RendererChart.tsx @@ -21,6 +21,21 @@ const serverSnapshot = () => true const useIsomorphicLayoutEffect = typeof document === 'undefined' ? React.useEffect : React.useLayoutEffect +let hostCommits = 0 + +function inHostCommit(run: () => void) { + hostCommits += 1 + try { + run() + } finally { + hostCommits -= 1 + } +} + +export function isInHostCommit() { + return hostCommits > 0 +} + interface ChartSurfaceProps { markup: string } @@ -179,16 +194,18 @@ export function RendererChartImplementation< useIsomorphicLayoutEffect(() => { const container = containerRef.current if (!container) return - adapter.update(hostOptions) - if (!mountedRef.current) { - adapter.mount(container) - mountedRef.current = true - } + inHostCommit(() => { + adapter.update(hostOptions) + if (!mountedRef.current) { + adapter.mount(container) + mountedRef.current = true + } + }) }, [adapter, hostOptions]) useIsomorphicLayoutEffect( () => () => { - adapter.destroy() + inHostCommit(() => adapter.destroy()) mountedRef.current = false }, [adapter], diff --git a/packages/react-charts/src/tooltip.test.tsx b/packages/react-charts/src/tooltip.test.tsx index c96c0a7c..db9391d1 100644 --- a/packages/react-charts/src/tooltip.test.tsx +++ b/packages/react-charts/src/tooltip.test.tsx @@ -121,4 +121,66 @@ 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') + expect(errors).not.toHaveBeenCalled() + } finally { + errors.mockRestore() + await act(async () => root.unmount()) + container.remove() + } + }) }) diff --git a/packages/react-charts/src/tooltip.tsx b/packages/react-charts/src/tooltip.tsx index e8e82925..3a749e5c 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, @@ -19,6 +19,7 @@ import { type CanvasChartProps as BaseCanvasChartProps, } from './CanvasChart' import { + isInHostCommit, RendererChartImplementation, type RendererChartCommonProps as BaseRendererChartCommonProps, type RendererChartProps as BaseRendererChartProps, @@ -163,7 +164,8 @@ function TooltipBody< > | null>(null) const onChange = React.useCallback( (nextTarget: ChartTooltipBodyTarget | null) => { - setTarget(nextTarget) + if (isInHostCommit()) setTarget(nextTarget) + else flushSync(() => setTarget(nextTarget)) }, [], ) From 9da2c57ae82efa05d252db1103af1afa2b9a0180 Mon Sep 17 00:00:00 2001 From: Piotr Kulpinski Date: Wed, 21 Oct 2026 14:53:25 +0200 Subject: [PATCH 2/6] fix(react): keep a dismissal from the tooltip body's effects off flushSync --- packages/react-charts/src/tooltip.test.tsx | 66 ++++++++++++++++++++++ packages/react-charts/src/tooltip.tsx | 45 ++++++++++++--- 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/packages/react-charts/src/tooltip.test.tsx b/packages/react-charts/src/tooltip.test.tsx index db9391d1..cb341df4 100644 --- a/packages/react-charts/src/tooltip.test.tsx +++ b/packages/react-charts/src/tooltip.test.tsx @@ -183,4 +183,70 @@ describe('tooltip state ownership', () => { container.remove() } }) + + it.each(['layout', 'passive'])( + 'accepts a dismissal from the body %s effect', + async (phase) => { + 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({ y, dismiss }: { y: unknown; dismiss: () => void }) { + useEffect(() => { + if (y === 2) dismiss() + }, [y, dismiss]) + return {String(y)} + } + 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 })), + ) + expect(container.querySelector('[data-body]')?.textContent).toBe('1') + await act(async () => + svg.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ), + ) + expect(container.querySelector('[data-body]')).toBeNull() + expect(errors).not.toHaveBeenCalled() + } finally { + errors.mockRestore() + await act(async () => root.unmount()) + container.remove() + } + }, + ) }) diff --git a/packages/react-charts/src/tooltip.tsx b/packages/react-charts/src/tooltip.tsx index 3a749e5c..8012f401 100644 --- a/packages/react-charts/src/tooltip.tsx +++ b/packages/react-charts/src/tooltip.tsx @@ -136,6 +136,29 @@ export function CanvasChart< ) } +let bodyCommits = 0 + +function BodyCommitStart() { + const enter = () => { + bodyCommits += 1 + queueMicrotask(() => { + bodyCommits = 0 + }) + } + React.useLayoutEffect(enter) + React.useEffect(enter) + return null +} + +function BodyCommitEnd() { + const leave = () => { + bodyCommits = Math.max(0, bodyCommits - 1) + } + React.useLayoutEffect(leave) + React.useEffect(leave) + return null +} + function TooltipBody< TDatum, TXValue extends ChartValue, @@ -164,7 +187,7 @@ function TooltipBody< > | null>(null) const onChange = React.useCallback( (nextTarget: ChartTooltipBodyTarget | null) => { - if (isInHostCommit()) setTarget(nextTarget) + if (isInHostCommit() || bodyCommits > 0) setTarget(nextTarget) else flushSync(() => setTarget(nextTarget)) }, [], @@ -178,14 +201,18 @@ function TooltipBody< {chart} {renderTooltipBody && target ? createPortal( - renderTooltipBody({ - primaryPoint: target.primaryPoint, - points: target.points, - content: target.content, - pinned: target.pinned, - dismiss: target.dismiss, - defaultBody: , - }), + <> + + {renderTooltipBody({ + primaryPoint: target.primaryPoint, + points: target.points, + content: target.content, + pinned: target.pinned, + dismiss: target.dismiss, + defaultBody: , + })} + + , target.element, ) : null} From da4d2e8fbbb5daddde9ecef2b968ea06079fa21d Mon Sep 17 00:00:00 2001 From: Piotr Kulpinski Date: Wed, 21 Oct 2026 15:00:23 +0200 Subject: [PATCH 3/6] test(react): watch console errors through the tooltip chart's unmount --- packages/react-charts/src/tooltip.test.tsx | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/react-charts/src/tooltip.test.tsx b/packages/react-charts/src/tooltip.test.tsx index cb341df4..6fbeba68 100644 --- a/packages/react-charts/src/tooltip.test.tsx +++ b/packages/react-charts/src/tooltip.test.tsx @@ -176,11 +176,14 @@ describe('tooltip state ownership', () => { expect(container.querySelector('[data-body]')?.textContent).toBe('2') await render(520) expect(container.querySelector('[data-body]')?.textContent).toBe('2') - expect(errors).not.toHaveBeenCalled() } finally { - errors.mockRestore() - await act(async () => root.unmount()) - container.remove() + try { + await act(async () => root.unmount()) + expect(errors).not.toHaveBeenCalled() + } finally { + errors.mockRestore() + container.remove() + } } }) @@ -241,11 +244,14 @@ describe('tooltip state ownership', () => { ), ) expect(container.querySelector('[data-body]')).toBeNull() - expect(errors).not.toHaveBeenCalled() } finally { - errors.mockRestore() - await act(async () => root.unmount()) - container.remove() + try { + await act(async () => root.unmount()) + expect(errors).not.toHaveBeenCalled() + } finally { + errors.mockRestore() + container.remove() + } } }, ) From e3caf5f25700ab4ec121a074d5ba7365d27b6df6 Mon Sep 17 00:00:00 2001 From: Piotr Kulpinski Date: Wed, 21 Oct 2026 15:19:00 +0200 Subject: [PATCH 4/6] test(react): check a pointer move updates the tooltip body synchronously --- packages/react-charts/src/Chart.test.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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, ) From 5e36c111afcc87095c5cb9ec35757cfe3b23b02a Mon Sep 17 00:00:00 2001 From: Piotr Kulpinski Date: Wed, 21 Oct 2026 22:04:02 +0200 Subject: [PATCH 5/6] fix(react): keep tooltip dismissals off flushSync and the host guard in the tooltip entry A dismissal now uses a plain state update, so a body that dismisses from its own effect no longer triggers the lifecycle warning. The host-commit guard moves from RendererChart into the tooltip entry, so default React entries keep their size. --- packages/react-charts/src/RendererChart.tsx | 29 +++--------- packages/react-charts/src/tooltip.test.tsx | 38 +++++++++------- packages/react-charts/src/tooltip.tsx | 50 +++++++++------------ 3 files changed, 48 insertions(+), 69 deletions(-) diff --git a/packages/react-charts/src/RendererChart.tsx b/packages/react-charts/src/RendererChart.tsx index b95dd4c2..570e0b1f 100644 --- a/packages/react-charts/src/RendererChart.tsx +++ b/packages/react-charts/src/RendererChart.tsx @@ -21,21 +21,6 @@ const serverSnapshot = () => true const useIsomorphicLayoutEffect = typeof document === 'undefined' ? React.useEffect : React.useLayoutEffect -let hostCommits = 0 - -function inHostCommit(run: () => void) { - hostCommits += 1 - try { - run() - } finally { - hostCommits -= 1 - } -} - -export function isInHostCommit() { - return hostCommits > 0 -} - interface ChartSurfaceProps { markup: string } @@ -194,18 +179,16 @@ export function RendererChartImplementation< useIsomorphicLayoutEffect(() => { const container = containerRef.current if (!container) return - inHostCommit(() => { - adapter.update(hostOptions) - if (!mountedRef.current) { - adapter.mount(container) - mountedRef.current = true - } - }) + adapter.update(hostOptions) + if (!mountedRef.current) { + adapter.mount(container) + mountedRef.current = true + } }, [adapter, hostOptions]) useIsomorphicLayoutEffect( () => () => { - inHostCommit(() => adapter.destroy()) + adapter.destroy() mountedRef.current = false }, [adapter], diff --git a/packages/react-charts/src/tooltip.test.tsx b/packages/react-charts/src/tooltip.test.tsx index 6fbeba68..8a832c2c 100644 --- a/packages/react-charts/src/tooltip.test.tsx +++ b/packages/react-charts/src/tooltip.test.tsx @@ -187,9 +187,14 @@ describe('tooltip state ownership', () => { } }) - it.each(['layout', 'passive'])( - 'accepts a dismissal from the body %s effect', - async (phase) => { + 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( @@ -208,11 +213,17 @@ describe('tooltip state ownership', () => { }) const useEffect = phase === 'layout' ? React.useLayoutEffect : React.useEffect - function Body({ y, dismiss }: { y: unknown; dismiss: () => void }) { + function Body({ dismiss }: { dismiss: () => void }) { + const [armed, setArmed] = React.useState(false) useEffect(() => { - if (y === 2) dismiss() - }, [y, dismiss]) - return {String(y)} + if (step === 'setup' && armed) dismiss() + if (step === 'cleanup' && !armed) return () => dismiss() + }, [armed, dismiss]) + return ( + + ) } const container = document.createElement('div') document.body.append(container) @@ -226,9 +237,7 @@ describe('tooltip state ownership', () => { width={480} height={260} ariaLabel="chart" - renderTooltipBody={({ primaryPoint, dismiss }) => ( - - )} + renderTooltipBody={({ dismiss }) => } />, ), ) @@ -237,12 +246,9 @@ describe('tooltip state ownership', () => { await act(async () => svg.dispatchEvent(new FocusEvent('focusin', { bubbles: true })), ) - expect(container.querySelector('[data-body]')?.textContent).toBe('1') - await act(async () => - svg.dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowRight', 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 { diff --git a/packages/react-charts/src/tooltip.tsx b/packages/react-charts/src/tooltip.tsx index 8012f401..8d572871 100644 --- a/packages/react-charts/src/tooltip.tsx +++ b/packages/react-charts/src/tooltip.tsx @@ -19,7 +19,6 @@ import { type CanvasChartProps as BaseCanvasChartProps, } from './CanvasChart' import { - isInHostCommit, RendererChartImplementation, type RendererChartCommonProps as BaseRendererChartCommonProps, type RendererChartProps as BaseRendererChartProps, @@ -136,26 +135,19 @@ export function CanvasChart< ) } -let bodyCommits = 0 +let hostCommit = false -function BodyCommitStart() { - const enter = () => { - bodyCommits += 1 - queueMicrotask(() => { - bodyCommits = 0 - }) - } - React.useLayoutEffect(enter) - React.useEffect(enter) +function HostCommitStart() { + React.useLayoutEffect(() => { + hostCommit = true + }) return null } -function BodyCommitEnd() { - const leave = () => { - bodyCommits = Math.max(0, bodyCommits - 1) - } - React.useLayoutEffect(leave) - React.useEffect(leave) +function HostCommitEnd() { + React.useLayoutEffect(() => { + hostCommit = false + }) return null } @@ -187,7 +179,7 @@ function TooltipBody< > | null>(null) const onChange = React.useCallback( (nextTarget: ChartTooltipBodyTarget | null) => { - if (isInHostCommit() || bodyCommits > 0) setTarget(nextTarget) + if (nextTarget === null || hostCommit) setTarget(nextTarget) else flushSync(() => setTarget(nextTarget)) }, [], @@ -198,21 +190,19 @@ function TooltipBody< const chart = React.useMemo(() => children(notify), [children, notify]) return ( <> + {chart} + {renderTooltipBody && target ? createPortal( - <> - - {renderTooltipBody({ - primaryPoint: target.primaryPoint, - points: target.points, - content: target.content, - pinned: target.pinned, - dismiss: target.dismiss, - defaultBody: , - })} - - , + renderTooltipBody({ + primaryPoint: target.primaryPoint, + points: target.points, + content: target.content, + pinned: target.pinned, + dismiss: target.dismiss, + defaultBody: , + }), target.element, ) : null} From 2c38c1982025d7b0095004e4240a43ca75ca11ce Mon Sep 17 00:00:00 2001 From: Piotr Kulpinski Date: Wed, 21 Oct 2026 22:48:55 +0200 Subject: [PATCH 6/6] fix(react): use the isomorphic layout effect for the tooltip host markers React 18 warns about useLayoutEffect during server rendering. The markers now use useEffect when document is undefined, as RendererChart does. --- packages/react-charts/src/tooltip.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/react-charts/src/tooltip.tsx b/packages/react-charts/src/tooltip.tsx index 8d572871..0b5b8a38 100644 --- a/packages/react-charts/src/tooltip.tsx +++ b/packages/react-charts/src/tooltip.tsx @@ -135,17 +135,20 @@ export function CanvasChart< ) } +const useIsomorphicLayoutEffect = + typeof document === 'undefined' ? React.useEffect : React.useLayoutEffect + let hostCommit = false function HostCommitStart() { - React.useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { hostCommit = true }) return null } function HostCommitEnd() { - React.useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { hostCommit = false }) return null