Repository navigation
[Bug] Crosshair: the spring restarts on every frame while data motion runs #208
Description
Version: @tanstack/charts 1.1.0 (57774e1). Split from #188 (section B) so it can be closed on its own.
While a data transition runs, a crosshair or focus guide with a spring transition never moves toward the pointer: every presentation frame cancels its track and starts a new one at the current position. motion.md:59-61 says retargeting "preserves the guide elements and incoming spring velocity".
Why
- Each motion tick calls
updateFocus(..., true), which repaints even for the same point (renderer.ts:1233-1239). - During data motion
paintFocuscancels the guide animation and starts a new one (motion.ts:1089-1093). The new track starts at the first frame it sees (motion.ts:3990), so the spring never advances. - Memoizing the focus scene ([Perf] Focus guides: every pointer move re-renders the scene and re-serializes, re-parses and rewrites the guide layer #188 A) does not fix this: the scene changes on every frame of the data transition.
Repro
Save as packages/charts-core/src/repro.test.ts, run pnpm vitest run packages/charts-core/src/repro.test.ts.
import { scaleLinear } from 'd3-scale'
import { expect, it, vi } from 'vitest'
import { crosshair } from './crosshair'
import { lineY } from './line'
import { motion } from './motion'
import { createChartScene, defineChart } from './scene'
const make = (t: number) => {
const data = Array.from({ length: 20 }, (_, i) => ({ x: i, y: i + t }))
const scales = { x: { scale: scaleLinear().domain([0, 19]) }, y: { scale: scaleLinear().domain([0, 40]) } }
return createChartScene(defineChart({ marks: [lineY(data, { x: 'x', y: 'y', key: 'x' }), crosshair({ x: true, y: false })], scales, guides: false }), { width: 400, height: 200 })
}
const focus = (p: any) => ({ primary: p, group: [p], source: 'pointer' as const, pinned: false })
it('a guide repaint during data motion keeps the crosshair spring running', () => {
const frames: FrameRequestCallback[] = []
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => frames.push(cb))
const container = document.createElement('div')
const surface = motion({ transition: { type: 'spring', stiffness: 170, damping: 18 } }).mount(container, () => {})
surface.render(make(0), { ariaLabel: 'repro' })
frames.splice(0).forEach((cb) => cb(0)); frames.splice(0).forEach((cb) => cb(5000)) // entrance done
const next = make(5)
surface.render(next, { ariaLabel: 'repro' }) // data motion starts
surface.paintFocus(focus(next.points[2]), null)
const xs: number[] = []
for (let i = 0, t = 5000; i < 8; i++) {
surface.paintFocus(focus(next.points[15]), null) // what updateFocus(..., true) does per presentation frame
frames.splice(0).forEach((cb) => cb((t += 16)))
xs.push(Math.round(Number(container.querySelector('.ts-chart__focus-guide-layer line')?.getAttribute('x1'))))
}
expect(xs[7]).toBeGreaterThan(xs[0]! + 20)
})Output on 57774e1:
AssertionError: expected 42 to be greater than 62 (xs = [42, 42, 42, 42, 42, 42, 42, 42])
Control (paintFocus called once): xs = [42, 62, 106, 158, 208, 250, 282, 305], a spring curve toward 316. The per-frame paintFocus stands in for the updateFocus(..., true) call.
Expected
A repaint caused only by data motion keeps the running tracks' start time and velocity. Fix: retarget the tracks instead of cancel + restart; the same focus and targets is a no-op.
Activity
tannerlinsley commentedon Oct 14, 2026
Thanks @toomingos, the clock fix in #222 also fixes this freeze.
I checked the data-frame repaint case in Chromium, Firefox and WebKit. On 1.1.0, the crosshair stays at x = 42.11 through eight repaints. On current main, it advances from 42.11 to 183.703 over those same frames, retaining the guide element and leaving no scheduled frames after cleanup. A dedicated regression test fails against 1.1.0 and passes on main; that follow-up test is still being prepared for merge.
The fix is merged, but npm is still on 1.1.0. It is included in the pending 1.1.1 version PR #220. This does not eliminate the repeated guide-rendering work tracked in #188, which remains open.