Skip to content

Fix(z-index): prevent elements from disappearing on rapid zIndex changes #7002

Merged
ckifer merged 1 commit into
recharts:mainfrom
VIDHITTS:fix/dynamic-zindex-flicker
Feb 13, 2026
Merged

Fix(z-index): prevent elements from disappearing on rapid zIndex changes #7002
ckifer merged 1 commit into
recharts:mainfrom
VIDHITTS:fix/dynamic-zindex-flicker

Conversation

@VIDHITTS

@VIDHITTS VIDHITTS commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Description

When zIndex is updated dynamically (e.g., hovering legend items in quick succession), chart elements (curves, bars, etc.) would disappear from the DOM entirely. This was caused by ZIndexLayer returning null while waiting for a new portal element to register after a zIndex change.

This fix updates ZIndexLayer to render children inline (without a portal) as a fallback when the portal element is not yet available, ensuring content remains visible during the async portal registration cycle.

Related Issue

Fixes #6789

Motivation and Context

The portal-based zIndex system in Recharts v3 is inherently asynchronous:

  1. A new zIndex is registered via useLayoutEffect → Redux dispatch.
  2. ZIndexPortals re-renders to create the new portal DOM element.
  3. ZIndexLayer picks up the element and renders via createPortal.

Between steps 1–3, ZIndexLayer was returning null, unmounting the content. With rapid zIndex changes, this caused persistent disappearance of chart elements.

The fix renders children inline during this gap, so the content is always visible — it just temporarily renders outside the portal layer until the portal is ready.

How Has This Been Tested?

  • New regression test (test/zIndex/DynamicZIndex.spec.tsx): Simulates rapid zIndex updates on Line components and asserts that both lines remain in the DOM after each update.
  • Updated existing tests (test/cartesian/CartesianGrid.spec.tsx): Adjusted spy call count expectations to account for the double render (inline → portal) introduced by the fix.
  • Full test suite: All 13,900+ tests pass with zero regressions.

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

  • New Features
    • Added a new interactive LineChart example to the documentation gallery. The example demonstrates legend-driven focus and dynamic layering: hovering over legend items highlights the corresponding data series, adjusts opacity, and brings it to the foreground to improve visibility and facilitate data exploration.

@coderabbitai

coderabbitai Bot commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a new interactive React example component demonstrating Recharts z-index behavior with legend-driven line opacity and stacking control, and registers it in the LineChart documentation examples gallery.

Changes

Cohort / File(s) Summary
Documentation Examples
www/src/docs/exampleComponents/LineChart/DynamicZIndexLineChart.tsx, www/src/docs/exampleComponents/LineChart/index.ts
Introduces a new interactive LineChart example component with state-managed opacity and z-index control triggered by legend hover events. Updates the examples index to export the new component with metadata.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested reviewers

  • PavelVanecek
  • 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 (4 files):

⚔️ src/state/selectors/combiners/combineTooltipPayloadConfigurations.ts (content)
⚔️ src/synchronisation/useChartSynchronisation.tsx (content)
⚔️ test/component/Tooltip/Tooltip.sync.spec.tsx (content)
⚔️ www/src/docs/exampleComponents/LineChart/index.ts (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 Title accurately describes the primary fix: preventing elements from disappearing during rapid zIndex changes.
Description check ✅ Passed Description is comprehensive and complete, covering motivation, context, implementation details, testing approach, and all relevant checklist items.
Linked Issues check ✅ Passed PR fully addresses issue #6789 by preventing curve disappearance during rapid zIndex changes through inline rendering fallback during portal async registration.
Out of Scope Changes check ✅ Passed Changes are in-scope: adds DynamicZIndexLineChart example to demonstrate the fix, and documentation of the zIndex fix implementation aligns with #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-flicker
  • Post resolved changes as copyable diffs in a comment

No actionable comments were generated in the recent review. 🎉


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 `@test/zIndex/DynamicZIndex.spec.tsx`:
- Around line 47-49: The comment in the DynamicZIndex.spec.tsx test is stale and
contradicts the assertion in the test that expects two lines (no flicker);
update or remove the comment so it matches the test intent ("should not remove
data curves") and the PR fix: delete the phrase about expecting a flicker/return
null and replace it with a brief note that the immediate expectation is 2 lines
(no flicker) to reflect the assertion and goal of the change in the test case
(refer to the test named DynamicZIndex and the assertion checking for two
lines).
- Line 1: Add vi fake timers to this test file: import vi from 'vitest' (or
include vi in the existing import), call vi.useFakeTimers() at test setup (e.g.,
top-level beforeAll or immediately inside the describe for DynamicZIndex tests)
and ensure you call vi.runOnlyPendingTimers() after any render/interaction steps
(or in afterEach) to flush timers; also restore timers with vi.useRealTimers()
in teardown if needed.

Comment thread test/zIndex/DynamicZIndex.spec.tsx Outdated
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';

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.

⚠️ Potential issue | 🟠 Major

Missing vi.useFakeTimers() — required per project guidelines.

All tests in this repo must use vi.useFakeTimers() due to Redux autoBatchEnhancer dependency on timers and requestAnimationFrame. Without fake timers, this test may behave non-deterministically. You'll also need to import vi from vitest and call vi.runOnlyPendingTimers() after renders/interactions.

Proposed fix
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import React, { useState } from 'react';
 import { render, act, waitFor } from '@testing-library/react';
 import { LineChart, Line, XAxis, YAxis } from '../../src';
 
 describe('Dynamic zIndex updates', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
   it('should keep the line in the DOM when zIndex changes dynamically', async () => {

As per coding guidelines: "Use vi.useFakeTimers() in all tests due to Redux autoBatchEnhancer dependency on timers and requestAnimationFrame".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React, { useState } from 'react';
import { render, act, waitFor } from '@testing-library/react';
import { LineChart, Line, XAxis, YAxis } from '../../src';
describe('Dynamic zIndex updates', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should keep the line in the DOM when zIndex changes dynamically', async () => {
🤖 Prompt for AI Agents
In `@test/zIndex/DynamicZIndex.spec.tsx` at line 1, Add vi fake timers to this
test file: import vi from 'vitest' (or include vi in the existing import), call
vi.useFakeTimers() at test setup (e.g., top-level beforeAll or immediately
inside the describe for DynamicZIndex tests) and ensure you call
vi.runOnlyPendingTimers() after any render/interaction steps (or in afterEach)
to flush timers; also restore timers with vi.useRealTimers() in teardown if
needed.

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

codecov Bot commented Feb 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.29412% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.09%. Comparing base (d705e66) to head (5764b4a).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
...pleComponents/LineChart/DynamicZIndexLineChart.tsx 83.60% 10 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7002      +/-   ##
==========================================
- Coverage   90.10%   90.09%   -0.01%     
==========================================
  Files         522      523       +1     
  Lines       38848    38921      +73     
  Branches     5347     5353       +6     
==========================================
+ Hits        35004    35067      +63     
- Misses       3835     3845      +10     
  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

Copy link
Copy Markdown
Collaborator

Are you sure this is the fix? Last time I checked, moving from DOM to Portal triggers unmount anyway. So we would end up with the exact same problem, but this time with a flash of content in the wrong z-index position.

@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 `@src/zIndex/ZIndexLayer.tsx`:
- Around line 93-106: When shouldRenderInPortal becomes false we must unregister
any previously registered zIndexes to avoid leaks: in the useLayoutEffect that
currently returns noop, add a branch for !shouldRenderInPortal that iterates
registeredZIndexesRef.current, dispatches unregisterZIndexPortal({ zIndex:
<each> }) for each registered zIndex, and clears the set
(registeredZIndexesRef.current.clear()); keep the existing registration logic
when shouldRenderInPortal is true and still guard with
registeredZIndexesRef.current.has(zIndex) before dispatching
registerZIndexPortal.
🧹 Nitpick comments (1)
src/zIndex/ZIndexLayer.tsx (1)

124-137: Mutating Set during forEach — safe but worth a brief note.

Deleting entries from a Set during forEach is well-defined per the ES spec, so this is correct. A small inline comment could prevent future maintainers from second-guessing it.

Comment thread src/zIndex/ZIndexLayer.tsx Outdated
@VIDHITTS
VIDHITTS force-pushed the fix/dynamic-zindex-flicker branch from bfb2d0e to 27d635d Compare February 13, 2026 11:46

@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 `@src/zIndex/ZIndexLayer.tsx`:
- Around line 103-108: The forEach callback is implicitly returning
dispatch(...) which triggers a static-analysis warning; change the arrow
callbacks to use a block body that calls dispatch without returning (e.g.,
replace z => dispatch(unregisterZIndexPortal({ zIndex: z })) with z => {
dispatch(unregisterZIndexPortal({ zIndex: z })); } ) for the cleanup using
registeredZIndexesRef.current and do the same for the similar usage in the
unmount cleanup (the place that calls dispatch(register/unregisterZIndexPortal)
and touches lastPortalElementRef.current). Ensure no value is returned from the
forEach callbacks.
🧹 Nitpick comments (1)
src/zIndex/ZIndexLayer.tsx (1)

143-150: Rendering into a stale portal during transitions — intentional trade-off worth documenting.

When zIndex changes rapidly, children render into the old portal <g> (at the previous z-order) until the new one is ready. This avoids the disappearing-content bug but produces a brief frame where content sits at the wrong layer. The reviewer PavelVanecek flagged a similar concern.

This is a reasonable trade-off for the fix, but consider adding a brief inline comment noting that the stale-portal render is at the wrong z-order intentionally, so future maintainers don't treat it as a bug.

Comment thread src/zIndex/ZIndexLayer.tsx Outdated
Comment on lines +103 to +108
// Portal rendering was disabled — clean up any stale registrations
const registered = registeredZIndexesRef.current;
registered.forEach(z => dispatch(unregisterZIndexPortal({ zIndex: z })));
registered.clear();
lastPortalElementRef.current = undefined;
return;

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.

⚠️ Potential issue | 🟡 Minor

Static analysis: forEach callback should not return a value.

dispatch() returns a value, and the arrow shorthand z => dispatch(...) implicitly returns it. Biome flags this as useIterableCallbackReturn. Same issue on line 134.

Proposed fix
-      registered.forEach(z => dispatch(unregisterZIndexPortal({ zIndex: z })));
+      registered.forEach(z => {
+        dispatch(unregisterZIndexPortal({ zIndex: z }));
+      });

And similarly for the unmount cleanup:

-      registered.forEach(z => dispatch(unregisterZIndexPortal({ zIndex: z })));
+      registered.forEach(z => {
+        dispatch(unregisterZIndexPortal({ zIndex: z }));
+      });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Portal rendering was disabled — clean up any stale registrations
const registered = registeredZIndexesRef.current;
registered.forEach(z => dispatch(unregisterZIndexPortal({ zIndex: z })));
registered.clear();
lastPortalElementRef.current = undefined;
return;
// Portal rendering was disabled — clean up any stale registrations
const registered = registeredZIndexesRef.current;
registered.forEach(z => {
dispatch(unregisterZIndexPortal({ zIndex: z }));
});
registered.clear();
lastPortalElementRef.current = undefined;
return;
🧰 Tools
🪛 Biome (2.3.14)

[error] 105-105: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@src/zIndex/ZIndexLayer.tsx` around lines 103 - 108, The forEach callback is
implicitly returning dispatch(...) which triggers a static-analysis warning;
change the arrow callbacks to use a block body that calls dispatch without
returning (e.g., replace z => dispatch(unregisterZIndexPortal({ zIndex: z }))
with z => { dispatch(unregisterZIndexPortal({ zIndex: z })); } ) for the cleanup
using registeredZIndexesRef.current and do the same for the similar usage in the
unmount cleanup (the place that calls dispatch(register/unregisterZIndexPortal)
and touches lastPortalElementRef.current). Ensure no value is returned from the
forEach callbacks.

@VIDHITTS

Copy link
Copy Markdown
Contributor Author

Hi @PavelVanecek , thanks for pointing that out and you're absolutely right about the version of this fix. Rendering inline as a fallback would still trigger an unmount when transitioning from inline to portal, just trading "flash of nothing" for "flash at wrong position."

I've since reworked the approach. Instead of falling back to inline rendering, the fix now keeps the old portal alive during the transition:

When zIndex changes (e.g., 10 to 11), we register the new zIndex without immediately unregistering the old one
This keeps the old element in the DOM, so we continue rendering via createPortal into the old portal so no unmount at all
Once the new portal for zIndex 11 is ready, we switch to it and clean up the old registration
This way there's no DOM → Portal transition, it's always Portal → Portal. The content stays mounted the entire time , it just briefly lives in the old z-index layer until the new one is available.

Happy to discuss if you see any issues with this approach or if you'd prefer a different direction entirely!

@PavelVanecek

Copy link
Copy Markdown
Collaborator

That might work. Let's also add a new website example so that we can test this in browser. Undo the current changes and push the example chart only so that we can compare before & after.

Adds a website example where hovering over legend items dynamically
changes the zIndex of lines. This reproduces the issue where chart
elements disappear during rapid zIndex transitions.

The example uses three Line components with legend-driven opacity
and zIndex changes. Hover over legend items in quick succession to
observe the bug — lines vanish from the chart.
@VIDHITTS
VIDHITTS force-pushed the fix/dynamic-zindex-flicker branch from 27d635d to 5764b4a Compare February 13, 2026 12:34
@VIDHITTS

Copy link
Copy Markdown
Contributor Author

I've pushed just the website example for now — it's a DynamicZIndexLineChart under the LineChart examples. You can hover over the legend items rapidly to see the lines disappear.

All fix-related changes have been reverted so this commit shows the "before" state. Once you've confirmed the example reproduces the issue well, I'll add the fix as a separate commit so we can compare before & after.

Let me know if the example needs any changes!

@ckifer ckifer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Example LGTM. I'll merge this and you can make a new pr with the changes

@ckifer
ckifer merged commit 592ddae into recharts:main Feb 13, 2026
39 of 41 checks passed
@codecov

codecov Bot commented Feb 13, 2026

Copy link
Copy Markdown

Bundle Report

Bundle size has no change ✅

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

3 participants