Skip to content

fix(RechartsWrapper): prevent ResizeObserver memory leak on ref update#7161

Merged
ckifer merged 1 commit intorecharts:mainfrom
Om-Mishra09:fix/resize-observer-leak
Mar 24, 2026
Merged

fix(RechartsWrapper): prevent ResizeObserver memory leak on ref update#7161
ckifer merged 1 commit intorecharts:mainfrom
Om-Mishra09:fix/resize-observer-leak

Conversation

@Om-Mishra09
Copy link
Copy Markdown
Contributor

@Om-Mishra09 Om-Mishra09 commented Mar 24, 2026

What is the purpose of this pull request?
This PR fixes a silent memory leak in the RechartsWrapper (specifically within ResponsiveDiv).

Previously, every time the innerRef callback was triggered with a valid DOM node, a new ResizeObserver was instantiated and attached to the node. However, the existing observer stored in observerRef.current was never disconnected, leading to detached observers piling up in memory over the lifecycle of the application if the ref updated frequently.

What does this PR do?

  • Adds explicit cleanup logic (observerRef.current.disconnect()) inside the innerRef callback to destroy the previous observer before initiating a new one.
  • Adds a targeted unit test in RechartsWrapper.spec.tsx that mocks the global ResizeObserver and explicitly asserts that disconnect() is called when the component is forced to re-render and update its ref.

Checklist:

  • I have added tests to cover my changes.
  • All tests pass locally (bypassed unrelated snapshot mismatches on main via CI).
  • I have run the linter and it passes.

Summary by CodeRabbit

  • Bug Fixes

    • Improved resource cleanup for responsive charts to prevent memory leaks when container references change.
  • Tests

    • Added test coverage for resize observer cleanup behavior during component re-renders.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 24, 2026

Walkthrough

The RechartsWrapper component's ref callback now explicitly disconnects any existing ResizeObserver before creating a new observer during ref reassignment. This prevents observer leaks when refs are reassigned.

Changes

Cohort / File(s) Summary
ResizeObserver cleanup in ref callback
src/chart/RechartsWrapper.tsx
Added explicit disconnect() call on existing observer stored in observerRef.current before creating a new observer when the ref node is reassigned. Ensures cleanup occurs at reassignment time in addition to component unmount.
ResizeObserver lifecycle test
test/chart/RechartsWrapper.spec.tsx
Added test that stubs global ResizeObserver, renders the component with a callback ref, re-renders with a new callback ref, and verifies the previous observer's disconnect was called exactly once before creating a new observer.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing a ResizeObserver memory leak on ref update in RechartsWrapper.
Description check ✅ Passed The description covers purpose, implementation details, and testing; however, it lacks some template sections like Related Issue, Motivation and Context, and proper checklist formatting.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
test/chart/RechartsWrapper.spec.tsx (1)

42-82: Add vi.useFakeTimers() to comply with test guidelines.

Per coding guidelines, all tests should use vi.useFakeTimers() due to Redux autoBatchEnhancer dependency on timers and requestAnimationFrame. The existing test in this file uses vi.runOnlyPendingTimers() after renders.

♻️ Proposed fix
   it('should disconnect the previous ResizeObserver before creating a new one when the DOM node changes', () => {
+    vi.useFakeTimers();
     const observeSpy = vi.fn();
     const disconnectSpy = vi.fn();

And add timer handling after renders:

     const { rerender } = render(
       <RechartsWrapper responsive width={100} height={100} ref={() => {}}>
         <div />
       </RechartsWrapper>,
     );
+
+    act(() => {
+      vi.runOnlyPendingTimers();
+    });

     expect(ResizeObserverMock).toHaveBeenCalledTimes(1);
     rerender(
       <RechartsWrapper responsive width={100} height={100} ref={() => {}}>
         <div />
       </RechartsWrapper>,
     );
+
+    act(() => {
+      vi.runOnlyPendingTimers();
+    });

     // The test asserts that the first observer was disconnected before the second one is created

As per coding guidelines: "Use vi.useFakeTimers() in all tests due to Redux autoBatchEnhancer dependency on timers and requestAnimationFrame" and "Call vi.runOnlyPendingTimers() to advance timers after renders when not using createSelectorTestCase helper".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/chart/RechartsWrapper.spec.tsx` around lines 42 - 82, Add fake timers at
the start of this test and run pending timers after renders: call
vi.useFakeTimers() before creating the ResizeObserver mock (e.g. at start of the
"should disconnect the previous ResizeObserver..." test) and call
vi.runOnlyPendingTimers() immediately after render(...) and after rerender(...)
to advance requestAnimationFrame timers; also ensure vi.useRealTimers() or
vi.restoreAllMocks()/vi.unstubAllGlobals() cleanup as appropriate at the end of
the test to avoid leaking fake timers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/chart/RechartsWrapper.spec.tsx`:
- Around line 42-82: Add fake timers at the start of this test and run pending
timers after renders: call vi.useFakeTimers() before creating the ResizeObserver
mock (e.g. at start of the "should disconnect the previous ResizeObserver..."
test) and call vi.runOnlyPendingTimers() immediately after render(...) and after
rerender(...) to advance requestAnimationFrame timers; also ensure
vi.useRealTimers() or vi.restoreAllMocks()/vi.unstubAllGlobals() cleanup as
appropriate at the end of the test to avoid leaking fake timers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 40d80ca9-9bac-4a43-bd45-457fc3c91838

📥 Commits

Reviewing files that changed from the base of the PR and between 00daf0b and 4beb3c3.

📒 Files selected for processing (2)
  • src/chart/RechartsWrapper.tsx
  • test/chart/RechartsWrapper.spec.tsx

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 24, 2026

Bundle Report

Changes will decrease total bundle size by 425 bytes (-0.01%) ⬇️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
recharts/bundle-cjs 1.3MB 332 bytes (0.03%) ⬆️
recharts/bundle-es6 1.13MB 332 bytes (0.03%) ⬆️
recharts/bundle-umd 546.91kB 57 bytes (0.01%) ⬆️
recharts/bundle-treeshaking-sankey* 345.54kB -382 bytes (-0.11%) ⬇️
recharts/bundle-treeshaking-treemap* 354.02kB -382 bytes (-0.11%) ⬇️
recharts/bundle-treeshaking-sunburst* 323.22kB -382 bytes (-0.12%) ⬇️

ℹ️ *Bundle size includes cached data from a previous commit

Affected Assets, Files, and Routes:

view changes for bundle: recharts/bundle-cjs

Assets Changed:

Asset Name Size Change Total Size Change (%)
chart/RechartsWrapper.js 332 bytes 15.46kB 2.19%
view changes for bundle: recharts/bundle-treeshaking-sunburst

Assets Changed:

Asset Name Size Change Total Size Change (%)
bundle.js -382 bytes 323.22kB -0.12%
view changes for bundle: recharts/bundle-treeshaking-treemap

Assets Changed:

Asset Name Size Change Total Size Change (%)
bundle.js -382 bytes 354.02kB -0.11%
view changes for bundle: recharts/bundle-umd

Assets Changed:

Asset Name Size Change Total Size Change (%)
Recharts.js 57 bytes 546.91kB 0.01%
view changes for bundle: recharts/bundle-treeshaking-sankey

Assets Changed:

Asset Name Size Change Total Size Change (%)
bundle.js -382 bytes 345.54kB -0.11%
view changes for bundle: recharts/bundle-es6

Assets Changed:

Asset Name Size Change Total Size Change (%)
chart/RechartsWrapper.js 332 bytes 13.61kB 2.5%

@ckifer
Copy link
Copy Markdown
Member

ckifer commented Mar 24, 2026

Did you prove that there were resize observers piling up?

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 24, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.62%. Comparing base (b39edbf) to head (4beb3c3).
⚠️ Report is 9 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7161      +/-   ##
==========================================
+ Coverage   89.61%   89.62%   +0.01%     
==========================================
  Files         536      536              
  Lines       40479    40488       +9     
  Branches     5519     5523       +4     
==========================================
+ Hits        36275    36288      +13     
+ Misses       4196     4192       -4     
  Partials        8        8              

☔ 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.

return () => {
const observer = observerRef.current;
if (observer != null) {
observer.disconnect();
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.

The useEffect here cleans up the observers. I don't think there is a memory leak

@Om-Mishra09
Copy link
Copy Markdown
Contributor Author

Hi @ckifer! That is a great point—you are totally right that the useEffect correctly handles the cleanup when the component unmounts.

However, the memory leak occurs if the innerRef callback gets triggered multiple times while the component is still mounted.

The Mechanism of the Leak:
If a user passes an inline function for the ref (e.g., <ResponsiveContainer ref={() => {}} />), React will execute the innerRef callback on subsequent renders because the function's reference changes.

Every time that callback runs, it instantiates a brand new ResizeObserver and overwrites observerRef.current with the new one. Because the useEffect cleanup only fires on unmount, it will only ever disconnect the very last observer created. All the previous observers get orphaned and continue running in the background.

The Proof:
The new unit test in this PR (test/chart/RechartsWrapper.spec.tsx) explicitly proves this. It renders the component, and then forces a re-render with a new inline ref.

Before this fix: The test output shows observe() being called 2 times, but disconnect() being called 0 times during the update.

After this fix: The old observer is caught and disconnected before being overwritten.

Adding the cleanup directly inside the callback ensures no observers are left behind mid-lifecycle. Let me know if you'd like me to adjust anything!

@ckifer
Copy link
Copy Markdown
Member

ckifer commented Mar 24, 2026

Adding the extra clean up is fine, but want to clarify that this "leak" isn't causing any issues/actual excessive memory usage

@ckifer ckifer merged commit b186929 into recharts:main Mar 24, 2026
52 checks passed
@Om-Mishra09
Copy link
Copy Markdown
Contributor Author

Totally agree! It is definitely more of a "micro-leak" / edge-case cleanup rather than a massive performance blocker in real-world usage. It just ensures things stay perfectly tidy under the hood if the ref happens to get spammed.

Thanks so much for taking the time to review and discuss it! Let me know if there is anything else you need from me here.

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.

2 participants