Skip to content

[Perf] Tooltip: every move re-renders the React chart, builds unused default content and rewrites unchanged attributes #187

Open

Description

@toomingos

Version: @tanstack/charts 1.1.0 (57774e1).

With a custom tooltip body (and with the motion() renderer for F), each pointer move that changes the tooltip does work no one sees: the React adapter re-renders the chart (A), core builds the default content the custom body replaces (E), and paint rewrites attributes that did not change (F). Related: #179 (only the default tooltip duration).

Paths are under packages/; links pin commit 57774e1.

A. Tooltip target state re-renders the chart (react-charts)

  • useTooltipBody keeps the target in React.useState; onChange calls setTarget (tooltip.tsx:152-159).
  • Each render rebuilds hostOptions, so the layout effect calls adapter.update (RendererChart.tsx:162-186). update refreshes DOM text metrics (renderer.ts:985-986).
  • Repro (vitest + jsdom, 4 pointer moves): expected 3 to be +0 calls to adapter.update.
  • Expected: a target change updates only the tooltip body. Proposed: keep the target in an external store read by a leaf component (useSyncExternalStore).
  • Downstream measurement (bklit migration, Chrome, core Chart + external store): pointermove p95 2-6x lower.

B. A repeated hide() restarts the exit

Moved to #209 (a bug, together with C).

C. Finished tooltip animations stay attached

Moved to #209.

D. First show cannot be styled or delayed

Moved to #210 (a feature, together with the per-phase motion proposed under B).

E. Default content is always built

  • paint always calls createChartTooltipContent before the custom body (tooltip.ts:117-124), and the result feeds the aria-label (:293).
  • Repro: an items[].text formatter is called 1 time per paint with a custom body, expected 0.
  • Expected: default content computed only when read. Proposed: resolve content lazily, or let a custom body supply the accessible label.
  • Downstream measurement (bklit migration, Chrome CDP profile, 300 moves): about 6 ms per 100 moves; grows with series count.

F. Each paint rewrites the tooltip element (from this comment)

  • On every move that repaints the tooltip, paint sets visibility: hidden and removes it again, and re-sets className, pointer-events, user-select and data-sticky, even when nothing changed (tooltip.ts:106-162). configureTooltipSemantics re-sets role, aria-live and aria-label.
  • The spring glide writes style.translate from requestAnimationFrame every frame (motion.ts:419-428, motion.ts:484-497). The tween glide uses WAAPI instead.
  • Measured (production build, sankey, MutationObserver on the tooltip, 60 moves): about 12 mutation records per move. About 4.7 are translate, 2.5 visibility, 3.75 the unchanged attributes, 1.8 content and 1 left/top.
  • Proposed: compare before writing in paint. Run the spring glide as a sampled WAAPI translate, like the tween path.

Activity

  1. toomingos commented on Oct 12, 2026

    @toomingos
    Author

    F. Each paint rewrites the tooltip element

    Checked against 57774e14a1a86081eddb3a23523724a40da05640 (v1.1.0).

    • On every move that repaints the tooltip, paint sets visibility: hidden and removes it again, and re-sets className, pointer-events, user-select and data-sticky, even when nothing changed (tooltip.ts:106-162). configureTooltipSemantics re-sets role, aria-live and aria-label.
    • The spring glide writes style.translate from requestAnimationFrame every frame (motion.ts:419-428, motion.ts:484-497). The tween glide uses WAAPI instead.

    Measured (production build, sankey, MutationObserver on the tooltip, 60 moves): about 12 mutation records per move. About 4.7 are translate, 2.5 visibility, 3.75 the unchanged attributes, 1.8 content and 1 left/top.

    Proposal: compare before writing in paint. Run the spring glide as a sampled WAAPI translate, like the tween path.

  2. changed the title [-]Tooltip lifecycle: chart re-renders on tooltip change, repeated hide() restarts the exit, no first-show control[/-] [+][Perf] Tooltip: every move re-renders the React chart, builds unused default content and rewrites unchanged attributes[/+] on Oct 13, 2026
  3. tannerlinsley commented on Oct 16, 2026

    @tannerlinsley
    Member

    Thanks @toomingos. Part A is fixed in #234, which has merged but is not on npm yet. Custom tooltip target state now stays in the tooltip child instead of rerendering the React chart host. The regression tests fail on the old implementation with three extra adapter updates, and the fix passed ordinary and StrictMode tests plus packed-package lifecycle checks on React 18.0, 18.3 and 19.2 in Chromium, Firefox and WebKit. The scene renderer already avoided redraws before this fix, so this is specifically a React host update improvement. Keeping this issue open for the remaining content construction and DOM-write work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions