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/react-tooltip-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-charts': patch
---

Keep custom tooltip target updates from rerendering the React chart host.
9 changes: 9 additions & 0 deletions API-FRICTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -3821,6 +3821,15 @@ Each entry records:
A direct Preact SSR-to-hydration regression additionally checks SVG and path
identity, immutable label updates, a single surface, and empty-host teardown.

- Oct16 React tooltip follow-up (#187): custom body targets lived above the
chart host, causing three adapter updates for three keyboard target changes
even though the scene did not redraw. A tooltip-owned state boundary now
retains the chart child between target updates, while parent prop changes
still update it. Strict Mode and ordinary regression tests observe zero
adapter updates and correct body values; the current-main negative control
observes three updates. Existing 25 React lifecycle tests also pass.
Packed and full-workspace validation remain required before publication.

### F-124 — Name-only inventories masked undocumented contracts

- Status: resolved
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": "408fe8d6fb17b71b48ce505eb93dfe8d34afaf2b99dcd855c8b789d78e7cd486",
"sourceHash": "e62990d1bc2ce6a38a60f900c2f8c32d8048c57ccd11c5be4f8d673ec9fcb9ad",
"assets": [
{
"id": "01-line-gaps",
Expand Down
124 changes: 124 additions & 0 deletions packages/react-charts/src/tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import * as React from 'react'
import { act } from 'react'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { createRoot } from 'react-dom/client'
import { describe, expect, it, vi } from 'vitest'
import { defineChart, dot } from '@tanstack/charts'
import { tooltip } from '@tanstack/charts/tooltip'
import { scaleLinear } from 'd3-scale'
import { Chart } from './tooltip'

const { updates } = vi.hoisted(() => ({ updates: vi.fn() }))
vi.mock('@tanstack/charts/adapter/renderer', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@tanstack/charts/adapter/renderer')>()
return {
...actual,
createChartRendererAdapter: (
...args: Parameters<typeof actual.createChartRendererAdapter>
) => {
const adapter = actual.createChartRendererAdapter(...args)
const update = adapter.update
adapter.update = (options) => {
updates()
update(options)
}
return adapter
},
}
})

describe('tooltip state ownership', () => {
it.each([false, true])(
'updates only the body and still accepts parent props, strict=%s',
async (strict) => {
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 render = async (prefix: string, enabled = true, width = 480) => {
const chart = (
<Chart
definition={definition}
width={width}
height={260}
ariaLabel={prefix}
renderTooltipBody={
enabled
? ({ primaryPoint }) => (
<span data-body>
{prefix}:{primaryPoint?.datum.y}
</span>
)
: undefined
}
/>
)
await act(async () =>
root.render(
strict ? <React.StrictMode>{chart}</React.StrictMode> : chart,
),
)
}
try {
await render('first')
const svg = container.querySelector('svg')
if (!svg) throw new Error('Missing SVG')
updates.mockClear()
for (const [key, value] of [
['focus', '1'],
['ArrowRight', '2'],
['ArrowLeft', '1'],
]) {
await act(async () =>
svg.dispatchEvent(
key === 'focus'
? new FocusEvent('focusin', { bubbles: true })
: new KeyboardEvent('keydown', { key, bubbles: true }),
),
)
expect(container.querySelector('[data-body]')?.textContent).toBe(
'first:' + value,
)
}
expect(updates).not.toHaveBeenCalled()
await render('second', true, 520)
expect(updates).toHaveBeenCalled()
expect(container.querySelector('svg')).toBe(svg)
expect(svg.getAttribute('aria-label')).toBe('second')
expect(container.querySelector('[data-body]')?.textContent).toBe(
'second:1',
)
await render('disabled', false)
expect(container.querySelector('[data-body]')).toBeNull()
await render('third')
await act(async () =>
svg.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
),
)
expect(container.querySelector('[data-body]')?.textContent).toBe(
'third:2',
)
} finally {
await act(async () => root.unmount())
expect(container.childNodes).toHaveLength(0)
container.remove()
}
},
)
})
80 changes: 46 additions & 34 deletions packages/react-charts/src/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,12 @@ export function Chart<
TYValue extends ChartValue = ChartValue,
>(props: ChartProps<TDatum, TXValue, TYValue>) {
const { renderTooltipBody, ...chartProps } = props
const tooltipBody = useTooltipBody(renderTooltipBody)
return (
<>
<ChartImplementation
{...chartProps}
onTooltipBodyChange={tooltipBody.onChange}
/>
{tooltipBody.portal}
</>
<TooltipBody renderTooltipBody={renderTooltipBody}>
{(onChange) => (
<ChartImplementation {...chartProps} onTooltipBodyChange={onChange} />
)}
</TooltipBody>
)
}

Expand All @@ -108,15 +105,15 @@ export function RendererChart<
TYValue extends ChartValue = ChartValue,
>(props: RendererChartProps<TDatum, TXValue, TYValue>) {
const { renderTooltipBody, ...chartProps } = props
const tooltipBody = useTooltipBody(renderTooltipBody)
return (
<>
<RendererChartImplementation
{...chartProps}
onTooltipBodyChange={tooltipBody.onChange}
/>
{tooltipBody.portal}
</>
<TooltipBody renderTooltipBody={renderTooltipBody}>
{(onChange) => (
<RendererChartImplementation
{...chartProps}
onTooltipBodyChange={onChange}
/>
)}
</TooltipBody>
)
}

Expand All @@ -126,29 +123,39 @@ export function CanvasChart<
TYValue extends ChartValue = ChartValue,
>(props: CanvasChartProps<TDatum, TXValue, TYValue>) {
const { renderTooltipBody, ...chartProps } = props
const tooltipBody = useTooltipBody(renderTooltipBody)
return (
<>
<CanvasChartImplementation
{...chartProps}
onTooltipBodyChange={tooltipBody.onChange}
/>
{tooltipBody.portal}
</>
<TooltipBody renderTooltipBody={renderTooltipBody}>
{(onChange) => (
<CanvasChartImplementation
{...chartProps}
onTooltipBodyChange={onChange}
/>
)}
</TooltipBody>
)
}

function useTooltipBody<
function TooltipBody<
TDatum,
TXValue extends ChartValue,
TYValue extends ChartValue,
>(
>({
renderTooltipBody,
children,
}: {
children: (
onChange:
| ((
target: ChartTooltipBodyTarget<TDatum, TXValue, TYValue> | null,
) => void)
| undefined,
) => React.ReactNode
renderTooltipBody:
| ((
context: ChartTooltipBodyRenderContext<TDatum, TXValue, TYValue>,
) => React.ReactNode)
| undefined,
) {
| undefined
}) {
const [target, setTarget] = React.useState<ChartTooltipBodyTarget<
TDatum,
TXValue,
Expand All @@ -160,10 +167,14 @@ function useTooltipBody<
},
[],
)
return {
onChange: renderTooltipBody ? onChange : undefined,
portal:
renderTooltipBody && target
const notify = renderTooltipBody ? onChange : undefined
// Target updates belong to the body, not the chart host. Parent prop
// changes supply new children and still update the host normally.
const chart = React.useMemo(() => children(notify), [children, notify])
return (
<>
{chart}
{renderTooltipBody && target
? createPortal(
renderTooltipBody({
primaryPoint: target.primaryPoint,
Expand All @@ -175,8 +186,9 @@ function useTooltipBody<
}),
target.element,
)
: null,
}
: null}
</>
)
}

function DefaultTooltipBody({
Expand Down