perf: optimize ScatterChart hover by reducing re-renders from O(n) to O(1)#7133
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds a per-point ScatterPoint component and memoized per-point isActive selector; moves per-point rendering, event binding, and z-index handling into ScatterPoint so only affected points re-render; exports ScatterShapeProps and adds tests for per-point render optimization. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant User as User
participant Symbols as ScatterSymbols
participant Point as ScatterPoint
participant State as RechartsRootState
participant ZLayer as ZIndexLayer
participant DOM as DOM
User->>Symbols: mount / initial render
Symbols->>Point: map data -> render per-point components
Point->>State: read per-point activeIndex selector
State-->>Point: isActive (false for most)
Point->>ZLayer: wrap point with z-index layer
ZLayer->>DOM: render point element
User->>Point: hover point (mouseEnter)
Point->>State: dispatch activeIndex update
State-->>Point: memoized selector -> hovered point true, previous false
Point->>ZLayer: apply active zIndex for active point
ZLayer->>DOM: update only affected points' DOM
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/cartesian/Scatter.tsx (1)
605-619: Consider simplifying the key construction.The key uses optional chaining (
entry?.cx) butentryis always defined when iterating viapoints.map. The optional chaining is unnecessary and could be simplified.More importantly, if
cx,cy, orsizeareundefined, the key becomessymbol-undefined-undefined-undefined-0, which could cause React key collisions if multiple points have undefined coordinates. Since the indexiis included, uniqueness is guaranteed, but consider using just the index or a more stable identifier.Suggested simplification
{points.map((entry: ScatterPointItem, i: number) => ( <ScatterPoint - key={`symbol-${entry?.cx}-${entry?.cy}-${entry?.size}-${i}`} + key={`symbol-${i}`} entry={entry} index={i}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/cartesian/Scatter.tsx` around lines 605 - 619, The key for ScatterPoint created inside points.map in Scatter.tsx unnecessarily uses optional chaining (entry?.cx) and can produce "undefined" parts leading to less readable keys; replace it with a stable, simple key—either use the loop index (e.g., key={`symbol-${i}`}) or, if each point has a stable identifier (e.g., entry.id or entry.key), use that (e.g., key={entry.id})—update the key prop on the ScatterPoint component instantiation and remove the optional chaining to ensure concise, collision-resistant keys.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/cartesian/Scatter.spec.tsx`:
- Around line 701-746: Add fake-timer handling and tighten the assertions in the
"ScatterPoint per-point rendering optimization" test: call vi.useFakeTimers() at
the start of the it block and vi.runOnlyPendingTimers() immediately after each
fireEvent.mouseEnter(symbols[X]) (and after initial render if needed) so Redux
autoBatch updates and rAF finish before asserting shapeSpy.mock.calls; replace
the loose expect(...).toBeLessThan(pointCount) checks with stricter expectations
(e.g., expect(afterFirstHover).toBeLessThanOrEqual(1) and
expect(afterSecondHover).toBeLessThanOrEqual(2)); finally restore timers with
vi.useRealTimers() at the end of the test.
---
Nitpick comments:
In `@src/cartesian/Scatter.tsx`:
- Around line 605-619: The key for ScatterPoint created inside points.map in
Scatter.tsx unnecessarily uses optional chaining (entry?.cx) and can produce
"undefined" parts leading to less readable keys; replace it with a stable,
simple key—either use the loop index (e.g., key={`symbol-${i}`}) or, if each
point has a stable identifier (e.g., entry.id or entry.key), use that (e.g.,
key={entry.id})—update the key prop on the ScatterPoint component instantiation
and remove the optional chaining to ensure concise, collision-resistant keys.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 29a5a697-783d-457e-8bf3-bbc7eb41aca6
📒 Files selected for processing (2)
src/cartesian/Scatter.tsxtest/cartesian/Scatter.spec.tsx
|
Regarding the key simplification suggestion (using (Written by Claude Code.) |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #7133 +/- ##
=======================================
Coverage 88.27% 88.27%
=======================================
Files 607 607
Lines 14124 14127 +3
Branches 3550 3551 +1
=======================================
+ Hits 12468 12471 +3
Misses 1469 1469
Partials 187 187 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
@roy7 can you rebase to get a green build. should be fixed now |
beef355 to
2f7f036
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/cartesian/Scatter.tsx (1)
555-559: Consider extracting selector factory for clarity and potential reuse.The inline selector factory pattern works correctly, but could be extracted for clarity:
💡 Optional refactor to extract selector factory
+const createIsActiveSelector = (index: number, hasActiveShape: boolean) => (state: RechartsRootState): boolean => + hasActiveShape && selectActiveTooltipIndex(state) === String(index); + function ScatterPoint({ // ... props }): React.ReactElement { const hasActiveShape = activeShape != null && activeShape !== false; - const selectIsActive = useMemo(() => { - const strIndex = String(index); - return (state: RechartsRootState): boolean => hasActiveShape && selectActiveTooltipIndex(state) === strIndex; - }, [hasActiveShape, index]); + const selectIsActive = useMemo( + () => createIsActiveSelector(index, hasActiveShape), + [hasActiveShape, index] + ); const isActive: boolean = useAppSelector(selectIsActive) ?? false;This could make the selector reusable if other components need similar per-index active state logic. However, the current inline approach is also fine.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/cartesian/Scatter.tsx` around lines 555 - 559, Extract the inline selector factory into a named function (e.g., makeSelectIsActive) instead of creating it inside the useMemo; the function should accept index and hasActiveShape and return a selector that compares selectActiveTooltipIndex(state) to String(index) and checks hasActiveShape. Replace the current useMemo/selectIsActive creation with a call to makeSelectIsActive(index, hasActiveShape) and keep the useAppSelector(selectIsActive) usage to obtain isActive; this makes selector logic reusable and clearer while preserving existing behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/cartesian/Scatter.tsx`:
- Around line 555-559: Extract the inline selector factory into a named function
(e.g., makeSelectIsActive) instead of creating it inside the useMemo; the
function should accept index and hasActiveShape and return a selector that
compares selectActiveTooltipIndex(state) to String(index) and checks
hasActiveShape. Replace the current useMemo/selectIsActive creation with a call
to makeSelectIsActive(index, hasActiveShape) and keep the
useAppSelector(selectIsActive) usage to obtain isActive; this makes selector
logic reusable and clearer while preserving existing behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 338ce19a-f126-427e-ae5b-5d3585ff1a29
📒 Files selected for processing (2)
src/cartesian/Scatter.tsxtest/cartesian/Scatter.spec.tsx
I did but seems like another internal error of some sort. |
2f7f036 to
b1a1921
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (2)
test/cartesian/Scatter.spec.tsx (1)
703-752: Harden timer cleanup withtry/finally.If an assertion fails before Line 751, fake timers can leak into subsequent tests in this file. Wrapping the body guarantees cleanup.
Proposed hardening
it('should only re-render the active and previously-active points when hovering, not all points', () => { vi.useFakeTimers(); - const shapeSpy = vi.fn((_props: ScatterShapeProps) => <circle r={5} cx={0} cy={0} />); - const pointCount = 20; - const scatterData = Array.from({ length: pointCount }, (_, i) => ({ - x: i * 50, - y: i * 50, - z: 100, - })); + try { + const shapeSpy = vi.fn((_props: ScatterShapeProps) => <circle r={5} cx={0} cy={0} />); + const pointCount = 20; + const scatterData = Array.from({ length: pointCount }, (_, i) => ({ + x: i * 50, + y: i * 50, + z: 100, + })); - const { container } = render( - <ScatterChart width={1000} height={1000}> - <XAxis type="number" dataKey="x" /> - <YAxis type="number" dataKey="y" /> - <Scatter isAnimationActive={false} data={scatterData} shape={shapeSpy} activeShape={{ fill: 'red' }} /> - </ScatterChart>, - ); - vi.runOnlyPendingTimers(); + const { container } = render( + <ScatterChart width={1000} height={1000}> + <XAxis type="number" dataKey="x" /> + <YAxis type="number" dataKey="y" /> + <Scatter isAnimationActive={false} data={scatterData} shape={shapeSpy} activeShape={{ fill: 'red' }} /> + </ScatterChart>, + ); + vi.runOnlyPendingTimers(); - // ...existing assertions... + // ...existing assertions... + } finally { + vi.useRealTimers(); + } - - vi.useRealTimers(); });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/cartesian/Scatter.spec.tsx` around lines 703 - 752, Wrap the test body that calls vi.useFakeTimers() and vi.useRealTimers() in a try/finally so vi.useRealTimers() always runs; specifically, in the "Scatter" test that sets up vi.useFakeTimers(), move the render(...) and all assertions into a try block and call vi.useRealTimers() in the finally block to guarantee timer cleanup even if an assertion fails (references: vi.useFakeTimers, vi.useRealTimers, render, shapeSpy).src/cartesian/Scatter.tsx (1)
555-559: ScopeisActiveto the active graphical item, not index alone.On Line 557,
isActiveis derived only from tooltip index. In multi-<Scatter />charts, matching indexes across series can become active together. Please verify this behavior with two scatter series usingactiveShape, and gate by active graphical item id if single-series activation is intended.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/cartesian/Scatter.tsx` around lines 555 - 559, The selector selectIsActive only checks selectActiveTooltipIndex against String(index), so multiple series with the same point index can all appear active; update selectIsActive (used by useAppSelector) to also compare the active graphical item id (e.g., selectActiveGraphicItemId or similar state selector) against this series' unique id/prop (use props.seriesId or the component's unique key) and include hasActiveShape in the predicate; specifically, modify the lambda in selectIsActive to return true only when hasActiveShape && selectActiveTooltipIndex(state) === String(index) && selectActiveGraphicItemId(state) === String(seriesId) (or the appropriate id field) so isActive is scoped to the actual graphical item, not index alone.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/cartesian/Scatter.tsx`:
- Around line 555-559: The selector selectIsActive only checks
selectActiveTooltipIndex against String(index), so multiple series with the same
point index can all appear active; update selectIsActive (used by
useAppSelector) to also compare the active graphical item id (e.g.,
selectActiveGraphicItemId or similar state selector) against this series' unique
id/prop (use props.seriesId or the component's unique key) and include
hasActiveShape in the predicate; specifically, modify the lambda in
selectIsActive to return true only when hasActiveShape &&
selectActiveTooltipIndex(state) === String(index) &&
selectActiveGraphicItemId(state) === String(seriesId) (or the appropriate id
field) so isActive is scoped to the actual graphical item, not index alone.
In `@test/cartesian/Scatter.spec.tsx`:
- Around line 703-752: Wrap the test body that calls vi.useFakeTimers() and
vi.useRealTimers() in a try/finally so vi.useRealTimers() always runs;
specifically, in the "Scatter" test that sets up vi.useFakeTimers(), move the
render(...) and all assertions into a try block and call vi.useRealTimers() in
the finally block to guarantee timer cleanup even if an assertion fails
(references: vi.useFakeTimers, vi.useRealTimers, render, shapeSpy).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 92c565d0-4826-4b7e-b691-72f1a4573e7a
📒 Files selected for processing (2)
src/cartesian/Scatter.tsxtest/cartesian/Scatter.spec.tsx
c8d9afc to
e6a5215
Compare
Bundle ReportChanges will increase total bundle size by 7.83kB (0.14%) ⬆️. This is within the configured threshold ✅ Detailed changes
Affected Assets, Files, and Routes:view changes for bundle: recharts/bundle-treeshaking-cartesianAssets Changed:
view changes for bundle: recharts/bundle-umdAssets Changed:
view changes for bundle: recharts/bundle-cjsAssets Changed:
view changes for bundle: recharts/bundle-es6Assets Changed:
|
|
any concerns @PavelVanecek ? |
|
Code change lgtm, I would like to find some time to test this before merging |
…ectors Extract ScatterPoint component from ScatterSymbols so each point subscribes to its own isActive selector instead of the parent subscribing to selectActiveTooltipIndex and re-rendering all points. On hover, only the 1-2 affected points re-render instead of all N. Addresses recharts#2862. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Address CodeRabbit review feedback: - Add @param JSDoc tags to ScatterPoint component for 80%+ docstring coverage - Use vi.useFakeTimers() and vi.runOnlyPendingTimers() in render-count test for correct Redux autoBatch timer handling - Tighten assertions from toBeLessThan(pointCount) to toBeLessThanOrEqual(2) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Ensures vi.useRealTimers() runs even if an assertion fails, preventing fake timer leaks into subsequent tests. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
e6a5215 to
62dabd6
Compare
|
Rebased onto latest main to resolve the merge conflict with #7215 (new animation props). The conflict was in No functional changes to the optimization itself — typecheck, lint, and the Scatter/ScatterChart suites all pass. |
|
I want to measure before merging |
|
So I am comparing this branch against the baseline from #7493 Mousemove looks way better:
|
|
I tried to remove the ZIndex completely but I can't see any difference in performance. |



Summary
ScatterPointcomponent so each point subscribes to its ownisActiveselector, instead of the parentScatterSymbolssubscribing toselectActiveTooltipIndexand re-rendering all N points on every hover changeThis significantly improves hover responsiveness but does not eliminate all lag at very high point counts (10K+), as the remaining cost comes from SVG hit-testing across thousands of DOM elements and per-point hook overhead (ZIndexLayer, useLayoutEffect). Further optimizations to initial render time and component tree depth are possible follow-up work.
Context
This addresses #2862 (Scatter chart performance issues, P1). The root cause is that
ScatterSymbolssubscribed toselectActiveTooltipIndexat the parent level, causing all scatter points to re-render on every tooltip index change. Each re-render created per-point event handler closures, object spreads, andadaptEventsOfChildwrappers — ~20,000 allocations per mouse move with 5,000 points.The fix moves the
isActivecheck into eachScatterPointcomponent via a memoized per-point selector. The parent no longer subscribes to the tooltip index, so it doesn't re-render on hover. Each point's selector is trivial (hasActiveShape && selectActiveTooltipIndex(state) === String(index)) and only triggers a re-render when that specific point's active state changes.Unlike external memoization workarounds that filter out event handler props (as discussed in #2862), this fix addresses the root cause internally.
Note: direct hover within a dense scatter chart (where points overlap) was verified to work correctly — moving between overlapping points updates the tooltip without requiring empty space in between.
Follow-up optimization opportunities
We identified several additional bottlenecks that affect initial render time (1-1.5s for points to appear, 3-5s before tooltips are interactive at 10K points). These are independent of the hover optimization in this PR:
useLayoutEffect+useAppSelectoreven when inactive (zIndex=undefined takes the early-return path). With 10K points, that's ~50,000 hook instances on mount for no benefit. Lazy portal registration (only when a point becomes active) could eliminate this.computeScatterPoints~30 times. Large datasets could benefit from skipping animation or deferring tooltip registration until animation completes.onMouseEnter/onMouseLeave/onClickhandlers with a single delegated handler on the parent<g>would eliminate thousands of closure allocations on initial render. However, this approach conflicts with ZIndexLayer portals (portaled elements leave the delegating parent's DOM subtree), so it would require rethinking the portal strategy first.Test plan
Scatter.spec.tsx)activeShapeprop works correctly (active point renders with custom shape and z-index portal)onMouseEnter,onMouseLeave,onClick,onMouseOver,onMouseOut) still fire with correct(data, index, event)arguments🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Performance
Tests
Documentation