Skip to content

fix(z-index): prevent elements from disappearing during dynamic zIndex transitions#7006

Merged
PavelVanecek merged 4 commits into
recharts:mainfrom
VIDHITTS:fix/dynamic-zindex-deferred-unregistration
Mar 7, 2026
Merged

fix(z-index): prevent elements from disappearing during dynamic zIndex transitions#7006
PavelVanecek merged 4 commits into
recharts:mainfrom
VIDHITTS:fix/dynamic-zindex-deferred-unregistration

Conversation

@VIDHITTS

@VIDHITTS VIDHITTS commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Description

When zIndex is changed dynamically (e.g., on legend hover), chart elements briefly disappear because ZIndexLayer returns null during the transition between portals.

Root cause: The original useLayoutEffect cleanup immediately calls unregisterZIndexPortal(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), so portalElement is undefined and ZIndexLayer returns null.

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 ready. During the transition, createPortal continues rendering into the cached old portal element.

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

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?

  • Added test/zIndex/DynamicZIndex.spec.tsx — renders two <Line> components, dynamically changes zIndex on one via rerender, and asserts both lines remain in the DOM
  • All 13,908 existing tests pass with zero regressions
  • Can be verified visually using the DynamicZIndexLineChart website example merged in the previous PR — hover over legend items rapidly and observe lines no longer disappear

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have added tests to cover my changes.
  • I have added a storybook story or VR test, or extended an existing story or VR test to show my changes

Summary by CodeRabbit

  • Bug Fixes

    • Eliminated flicker and remount flashes when portal targets change; elements with dynamic zIndex now transition smoothly and remain stable during reordering.
    • Ensured stale layer registrations are fully cleaned up when portal rendering is toggled or components unmount, preventing leftover state.
  • Tests

    • Added a test verifying rapid dynamic zIndex updates preserve rendered layers and do not remove or hide elements.

…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
@coderabbitai

coderabbitai Bot commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉


Walkthrough

Caches 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

Cohort / File(s) Summary
ZIndex layer implementation
src/zIndex/ZIndexLayer.tsx
Adds lastPortalElementRef and registeredZIndexesRef; reads portalElement via selector and computes a fallback targetElement; splits lifecycle into two layout effects for idempotent registration, deferred unregistration, and full cleanup on unmount or when portal rendering is disabled; returns null on first render if no portal available, otherwise uses createPortal with fallback target.
Regression test
test/zIndex/DynamicZIndex.spec.tsx
New test that renders two lines and simulates rapid zIndex updates with fake timers and Testing Library to ensure elements are not removed from the DOM during quick zIndex 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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

enhancement

Suggested reviewers

  • ckifer
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (70 files):

⚔️ scripts/snapshots/es6Files.txt (content)
⚔️ scripts/snapshots/libFiles.txt (content)
⚔️ scripts/snapshots/typesFiles.txt (content)
⚔️ src/cartesian/Bar.tsx (content)
⚔️ src/cartesian/CartesianAxis.tsx (content)
⚔️ src/cartesian/XAxis.tsx (content)
⚔️ src/cartesian/YAxis.tsx (content)
⚔️ src/hooks.ts (content)
⚔️ src/polar/Pie.tsx (content)
⚔️ src/state/selectors/axisSelectors.ts (content)
⚔️ src/state/selectors/combiners/combineTooltipPayload.ts (content)
⚔️ src/state/store.ts (content)
⚔️ src/zIndex/ZIndexLayer.tsx (content)
⚔️ test-vr/__snapshots__/tests/www/ActiveIndex.spec-vr.tsx-snapshots/PieChartDefaultIndex-1-chromium-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ActiveIndex.spec-vr.tsx-snapshots/PieChartDefaultIndex-1-firefox-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ActiveIndex.spec-vr.tsx-snapshots/PieChartDefaultIndex-1-webkit-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/CoordinateSystems.spec-vr.tsx-snapshots/AxisTickSnapExample-1-chromium-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/CoordinateSystems.spec-vr.tsx-snapshots/AxisTickSnapExample-1-firefox-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/CoordinateSystems.spec-vr.tsx-snapshots/AxisTickSnapExample-1-webkit-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/CoordinateSystems.spec-vr.tsx-snapshots/DataSnapExample-1-chromium-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/CoordinateSystems.spec-vr.tsx-snapshots/DataSnapExample-1-firefox-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/CoordinateSystems.spec-vr.tsx-snapshots/DataSnapExample-1-webkit-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/LineChartApiExamples.spec-vr.tsx-snapshots/HighlightAndZoomLineChart-1-webkit-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/PieChartApiExamples.spec-vr.tsx-snapshots/PieWithGradient-1-chromium-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/PieChartApiExamples.spec-vr.tsx-snapshots/PieWithGradient-1-firefox-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/PieChartApiExamples.spec-vr.tsx-snapshots/PieWithGradient-1-webkit-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/PieChartApiExamples.spec-vr.tsx-snapshots/TwoLevelPieChart-1-chromium-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/PieChartApiExamples.spec-vr.tsx-snapshots/TwoLevelPieChart-1-firefox-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/PieChartApiExamples.spec-vr.tsx-snapshots/TwoLevelPieChart-1-webkit-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/RadialBarChartApiExamples.spec-vr.tsx-snapshots/RadialBarChartClickToFocusLegendExample-1-webkit-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ScatterChartApiExamples.spec-vr.tsx-snapshots/JointLineScatterChart-1-chromium-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ScatterChartApiExamples.spec-vr.tsx-snapshots/JointLineScatterChart-1-firefox-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ScatterChartApiExamples.spec-vr.tsx-snapshots/JointLineScatterChart-1-webkit-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ScatterChartApiExamples.spec-vr.tsx-snapshots/ScatterChartWithLabels-1-chromium-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ScatterChartApiExamples.spec-vr.tsx-snapshots/ScatterChartWithLabels-1-firefox-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ScatterChartApiExamples.spec-vr.tsx-snapshots/ScatterChartWithLabels-1-webkit-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ScatterChartApiExamples.spec-vr.tsx-snapshots/SimpleScatterChart-1-chromium-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ScatterChartApiExamples.spec-vr.tsx-snapshots/SimpleScatterChart-1-firefox-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ScatterChartApiExamples.spec-vr.tsx-snapshots/SimpleScatterChart-1-webkit-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ScatterChartApiExamples.spec-vr.tsx-snapshots/ThreeDimScatterChart-1-chromium-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ScatterChartApiExamples.spec-vr.tsx-snapshots/ThreeDimScatterChart-1-firefox-linux.png (content)
⚔️ test-vr/__snapshots__/tests/www/ScatterChartApiExamples.spec-vr.tsx-snapshots/ThreeDimScatterChart-1-webkit-linux.png (content)
⚔️ test/cartesian/Bar.truncateByDomain.spec.tsx (content)
⚔️ test/cartesian/Bar/Bar.spec.tsx (content)
⚔️ test/cartesian/ErrorBar.spec.tsx (content)
⚔️ test/cartesian/ReferenceArea.spec.tsx (content)
⚔️ test/cartesian/ReferenceDot.spec.tsx (content)
⚔️ test/cartesian/ReferenceLine/ReferenceLine.spec.tsx (content)
⚔️ test/cartesian/XAxis/XAxis.barSize.spec.tsx (content)
⚔️ test/cartesian/XAxis/XAxis.padding.spec.tsx (content)
⚔️ test/cartesian/XAxis/XAxis.state.spec.tsx (content)
⚔️ test/cartesian/YAxis/YAxis.spec.tsx (content)
⚔️ test/cartesian/ZAxis.spec.tsx (content)
⚔️ test/chart/AreaChart.spec.tsx (content)
⚔️ test/chart/BarChart.spec.tsx (content)
⚔️ test/component/Tooltip/Tooltip.multipleDataArrays.spec.tsx (content)
⚔️ test/component/Tooltip/defaultIndex.spec.tsx (content)
⚔️ test/component/Tooltip/itemSorter.spec.tsx (content)
⚔️ test/hooks/useAxisTicks.spec.tsx (content)
⚔️ test/hooks/useOffset.spec.tsx (content)
⚔️ test/polar/Pie/Pie.spec.tsx (content)
⚔️ test/state/selectors/areaSelectors.spec.tsx (content)
⚔️ test/state/selectors/axisSelectors.spec.tsx (content)
⚔️ test/state/selectors/lineSelectors.spec.tsx (content)
⚔️ test/state/selectors/pieSelectors.spec.tsx (content)
⚔️ test/state/selectors/selectAxisScale.spec.tsx (content)
⚔️ test/state/selectors/selectDisplayedData.spec.tsx (content)
⚔️ test/state/selectors/selectors.spec.tsx (content)
⚔️ www/src/docs/apiExamples/useAxisInverseDataSnapScale/DataSnapExample.tsx (content)
⚔️ www/src/docs/apiExamples/useAxisInverseTickSnapScale/AxisTicksSnapExample.tsx (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing a bug where elements disappear during dynamic zIndex transitions, which aligns with the core fix in ZIndexLayer.tsx.
Description check ✅ Passed The description covers all required template sections: detailed problem description, related issue link (#6789), motivation/context, testing approach, and completed checklist items.
Linked Issues check ✅ Passed Code changes fully address the linked issue #6789 objectives: the PR prevents chart elements from disappearing during rapid zIndex transitions by deferring unregistration until the new portal is ready.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the zIndex portal transition bug: ZIndexLayer.tsx modifications implement the fix, and DynamicZIndex.spec.tsx tests the specific scenario reported in #6789.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch fix/dynamic-zindex-deferred-unregistration
  • Post resolved changes as copyable diffs in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 inside beforeEach with corresponding afterEach cleanup.

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 adding vi.runOnlyPendingTimers() inside the waitFor or before it.

With fake timers, waitFor may not advance time on its own. If portal registration relies on batched Redux updates that need a timer tick, this waitFor could time out. Wrapping a vi.runOnlyPendingTimers() call before the assertions (or using advanceTimersByTime) 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.current into registered. 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 that registeredZIndexesRef.current is 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 that registeredZIndexesRef must never be replaced.

The entire cleanup and lifecycle logic depends on registeredZIndexesRef.current always being the same Set instance (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.

Comment thread src/zIndex/ZIndexLayer.tsx Outdated
Comment thread test/zIndex/DynamicZIndex.spec.tsx
@codecov

codecov Bot commented Feb 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.12%. Comparing base (bd9869c) to head (164987a).
⚠️ Report is 65 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@PavelVanecek PavelVanecek left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please put this comment back

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have put back the comment

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.current while iterating it with forEach. 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 on dispatch stability—verify this holds.

The effect captures registeredZIndexesRef.current on setup (Line 140) and the cleanup closure references dispatch. Since Redux's useDispatch returns 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.

Comment thread test/zIndex/DynamicZIndex.spec.tsx
Comment thread test/zIndex/DynamicZIndex.spec.tsx
@PavelVanecek
PavelVanecek merged commit 051e47b into recharts:main Mar 7, 2026
41 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request May 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

zIndex property doesn't like to be changed in quick succession

2 participants