fix(z-index): prevent elements from disappearing during dynamic zIndex transitions#7006
Conversation
…Index transitions When zIndex changes dynamically, the old portal element was immediately unregistered, causing its <g> DOM node to be removed before the new portal was ready. This left ZIndexLayer with no valid render target, returning null and causing children to disappear. Fix: defer unregistration of old zIndex values until the new portal element becomes available. A Set ref tracks all registered zIndexes, and old entries are only cleaned up once the new portal is confirmed. Two effects with clear separation of concerns: 1. Lifecycle effect — registers new zIndex values and cleans up stale ones once the new portal element is available 2. Unmount effect — unregisters everything when the component is removed Fixes recharts#6789
|
No actionable comments were generated in the recent review. 🎉 WalkthroughCaches the last-known portal DOM node and tracks registered zIndexes; splits registration/unregistration into two layout effects to defer unregisters until a portal target is available; renders into a fallback cached portal during transitions. Adds a regression test for rapid zIndex changes. Changes
Sequence Diagram(s)sequenceDiagram
participant Component as LineChart Component
participant ZLayer as ZIndexLayer
participant Selector as useAppSelector (portalElement)
participant Registry as ZIndex Registry (registeredZIndexesRef)
participant DOM as Portal DOM Node
Component->>ZLayer: render with zIndex prop
ZLayer->>Selector: read portalElement
alt portalElement available
ZLayer->>Registry: register zIndex (idempotent)
Registry->>DOM: attach layer to portalElement
ZLayer->>ZLayer: set lastPortalElementRef = portalElement
else portalElement missing
ZLayer->>ZLayer: use lastPortalElementRef.current as targetElement
ZLayer->>Registry: register zIndex (defer old unregister)
Registry->>DOM: attach layer to cached portal DOM node
end
Component->>ZLayer: rapid zIndex updates
ZLayer->>Registry: repeat idempotent register/unregister (avoid transient unmounts)
ZLayer->>Registry: on unmount or shouldRenderInPortal=false -> unregister all and clear refs
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts (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: 2
🤖 Fix all issues with AI agents
In `@src/zIndex/ZIndexLayer.tsx`:
- Line 105: The forEach callback currently uses an implicit return (z =>
dispatch(unregisterZIndexPortal({ zIndex: z }))) which is misleading because
forEach ignores return values; change both occurrences to use a block body so
the dispatch is a statement (e.g., registered.forEach(z => {
dispatch(unregisterZIndexPortal({ zIndex: z })); })). Locate the callbacks that
call dispatch with unregisterZIndexPortal inside registered.forEach and the
similar occurrence at the second forEach and convert the arrow functions to
brace-enclosed statement bodies to avoid implicit returns.
In `@test/zIndex/DynamicZIndex.spec.tsx`:
- Around line 44-56: After each act(...) block in DynamicZIndex.spec.tsx, call
vi.runOnlyPendingTimers() to advance timers so Redux autoBatchEnhancer and the
registerZIndexPortal/unregisterZIndexPortal store updates flush; specifically,
add vi.runOnlyPendingTimers() immediately after the act that triggers
button.click() both before and after re-querying '.recharts-line', and do not
replace with vi.runAllTimers().
🧹 Nitpick comments (4)
test/zIndex/DynamicZIndex.spec.tsx (2)
7-7:vi.useFakeTimers()should be insidebeforeEachwith correspondingafterEachcleanup.Calling
vi.useFakeTimers()at the describe-level top scope means it runs once when the module is loaded. The conventional pattern is to set up and tear down fake timers per test to avoid leaking timer state between tests:Proposed fix
describe('Dynamic zIndex updates', () => { - vi.useFakeTimers(); + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + });
36-39: Consider addingvi.runOnlyPendingTimers()inside thewaitForor before it.With fake timers,
waitFormay not advance time on its own. If portal registration relies on batched Redux updates that need a timer tick, thiswaitForcould time out. Wrapping avi.runOnlyPendingTimers()call before the assertions (or usingadvanceTimersByTime) ensures the async portal registration completes.src/zIndex/ZIndexLayer.tsx (2)
131-137: Verify the unmount effect captures a live reference to the Set.Line 132 captures
registeredZIndexesRef.currentintoregistered. Because the Set is mutated in place (never replaced with a new Set), this captured reference stays current through the component's lifetime, so the cleanup correctly unregisters all active entries at unmount. This works, but it's subtly relying on the fact thatregisteredZIndexesRef.currentis never reassigned to a new Set. If a future refactor were to replace the Set (e.g.,registeredZIndexesRef.current = new Set()), this cleanup would operate on a stale reference. Consider adding a brief comment noting this assumption.
82-83: Minor: consider documenting thatregisteredZIndexesRefmust never be replaced.The entire cleanup and lifecycle logic depends on
registeredZIndexesRef.currentalways being the sameSetinstance (mutated via.add/.delete/.clear). This invariant is key to correctness — the unmount effect captures this reference once. A brief inline comment would protect against accidental breakage.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #7006 +/- ##
==========================================
+ Coverage 90.09% 90.12% +0.02%
==========================================
Files 523 524 +1
Lines 38921 39098 +177
Branches 5353 5408 +55
==========================================
+ Hits 35067 35236 +169
- Misses 3845 3853 +8
Partials 9 9 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
PavelVanecek
left a comment
There was a problem hiding this comment.
Okay I tried to play with it on my local and it looks like it works better now. That said, it's quite hard to replicate, somewhat easier with 20x slowdown, but still unreliable.
| registeredZIndexesRef.current.add(zIndex); | ||
| } | ||
| /* | ||
| * Because zIndexes are dynamic (meaning, we're not working with a predefined set of layers, |
There was a problem hiding this comment.
Please put this comment back
There was a problem hiding this comment.
have put back the comment
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@test/zIndex/DynamicZIndex.spec.tsx`:
- Line 44: The test file contains a duplicated inline comment "Rapid zIndex
updates should not cause lines to disappear" (appearing twice in
DynamicZIndex.spec.tsx); remove the redundant occurrence so the comment appears
only once (keep one copy immediately above the related test/block) — locate the
duplicated string in the test file and delete the extra comment line.
🧹 Nitpick comments (2)
src/zIndex/ZIndexLayer.tsx (2)
101-136: Lifecycle effect logic is sound; one minor concern with Set mutation during iteration.Lines 129–133 delete entries from
registeredZIndexesRef.currentwhile iterating it withforEach. Per the ES spec this is safe (already-visited and currently-visited entries can be deleted without affecting remaining iteration), but it's a pattern that surprises readers. Consider collecting stale keys first and deleting in a second pass for clarity—though this is purely a readability nit.Optional: two-pass cleanup for readability
if (portalElement) { lastPortalElementRef.current = portalElement; const registered = registeredZIndexesRef.current; - registered.forEach(z => { - if (z !== zIndex) { - dispatch(unregisterZIndexPortal({ zIndex: z })); - registered.delete(z); - } - }); + const stale = [...registered].filter(z => z !== zIndex); + stale.forEach(z => { + dispatch(unregisterZIndexPortal({ zIndex: z })); + registered.delete(z); + }); }
138-147: Unmount cleanup relies ondispatchstability—verify this holds.The effect captures
registeredZIndexesRef.currenton setup (Line 140) and the cleanup closure referencesdispatch. Since Redux'suseDispatchreturns a stable reference this works correctly, but if a future refactor wraps dispatch the[dispatch]dep would cause the cleanup to fire mid-lifecycle, unregistering everything. A brief inline comment noting the stability assumption would help future maintainers.
Description
When
zIndexis changed dynamically (e.g., on legend hover), chart elements briefly disappear because ZIndexLayer returnsnullduring the transition between portals.Root cause: The original
useLayoutEffectcleanup immediately callsunregisterZIndexPortal(oldZIndex), which deletes the entry from Redux and removes the old<g>DOM node from the SVG. The new portal isn't ready yet (it requires a full render cycle through AllZIndexPortals → ZIndexSvgPortal), soportalElementisundefinedand ZIndexLayer returnsnull.Fix: Defer unregistration of old zIndex values until the new portal element becomes available. A
Setref tracks all registered zIndexes, and old entries are only cleaned up once the new portal is confirmed ready. During the transition,createPortalcontinues rendering into the cached old portal element.Two effects with clear separation of concerns:
Related Issue
Fixes #6789
Motivation and Context
Users who dynamically change
zIndex(e.g., highlighting a line on legend hover) see chart elements vanish and not come back. This is a regression-free fix that addresses the gap in the portal lifecycle without changing any other component.How Has This Been Tested?
<Line>components, dynamically changeszIndexon one viarerender, and asserts both lines remain in the DOMDynamicZIndexLineChartwebsite example merged in the previous PR — hover over legend items rapidly and observe lines no longer disappearTypes of changes
Checklist:
Summary by CodeRabbit
Bug Fixes
Tests