Investigation: virtualized-list memory leak (#3241)#3708
Conversation
Adds a virtualized-list reproduction harness for the reported memory leak when scrolling animated motion.div items. The page cycles through windows of items every 50ms, remounting fresh motion.divs each time. Memory growth must be observed manually via Chrome DevTools' Performance Monitor (DOM Nodes counter); the JSDOM/Electron 80 environment used by unit and Cypress tests doesn't surface the leak. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add 100 child divs per item (matching the original sandbox) and a FinalizationRegistry that tracks every motion.div's lifecycle. The harness exposes mounted / unmounted / still-alive counts on window.__leakStats so a leak can be detected programmatically rather than relying solely on Chrome's DOM Nodes counter. Verified locally with Chromium + --js-flags=--expose-gc: after 1700+ mount/unmount cycles the still-alive count stays bounded at the visible-item count plus a handful of GC stragglers, indicating the original leak is no longer reproducible on main. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Greptile SummaryThis draft PR adds a single manual reproduction harness (
Confidence Score: 3/5Safe to merge as a dev-only harness with no production code changes, but the measurement logic has a flaw that would mislead any maintainer trying to use it today. The inline ref callback fires totalUnmounted++ on every parent re-render for each still-visible item, not just on actual unmounts. At 30 ms intervals with ~4 visible items the counter over-counts by roughly 130 false events per second, completely drowning out the real signal. A maintainer following the DevTools workflow described in the PR comment block would read a totalUnmounted value that is wildly inflated and could draw wrong conclusions about the leak severity. dev/react/src/tests/animate-virtualized-list-memory.tsx — specifically the setRef callback and the stat display logic. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[setInterval 30ms] -->|increment cycleRef| B[setScrollTop]
B --> C[Re-render App]
C --> D[Compute visible window]
D --> E{Item in visible range?}
E -->|Yes same key| F[Re-render ListItem new setRef identity]
E -->|No longer visible| G[Unmount ListItem]
E -->|New in range| H[Mount ListItem]
F -->|React ref cleanup| I[setRef null totalUnmounted++ even though still mounted]
G --> K[setRef null totalUnmounted++ genuine unmount]
H --> L[setRef el register FinalizationRegistry]
L --> M[window.__leakStats updated]
K --> M
I --> N[totalUnmounted inflated]
Reviews (1): Last reviewed commit: "test: enhance #3241 repro harness with F..." | Re-trigger Greptile |
| const setRef = (el: HTMLDivElement | null) => { | ||
| if (el && !idRef.current) { | ||
| idRef.current = ++idCounter | ||
| totalMounted++ | ||
| liveIds.add(idRef.current) | ||
| registry?.register(el, idRef.current) | ||
| updateStats() | ||
| } else if (!el && idRef.current) { | ||
| totalUnmounted++ | ||
| updateStats() | ||
| } | ||
| } |
There was a problem hiding this comment.
Inline ref callback inflates
totalUnmounted on every re-render
Because setRef is defined inline (new function identity on every render), React calls the old setRef(null) before the new setRef(el) on every parent re-render. The null branch hits idRef.current (still truthy, never cleared) and increments totalUnmounted — even though the element was never actually detached. At 30 ms intervals with 4 visible items the counter accumulates roughly 4 × (1000/30) ≈ 133 false unmount events per second, swamping the real signal. Wrapping setRef in useCallback with an empty deps array would fix this: React would only call it on genuine mount/unmount, not on every re-render.
| let registry: FinalizationRegistry<number> | null = null | ||
| const liveIds = new Set<number>() | ||
| let totalMounted = 0 | ||
| let totalUnmounted = 0 | ||
| let idCounter = 0 |
There was a problem hiding this comment.
Module-level counters survive HMR reloads
totalMounted, totalUnmounted, idCounter, and liveIds are module-level singletons. In the Vite dev server with HMR, module code doesn't re-execute on hot reload — only the React component tree is re-mounted. After any HMR update the counters carry over stale values from the previous run, which makes a second measurement session impossible without a hard page reload. Adding a window.__resetLeakStats helper would let testers restart the measurement without a hard page reload.
| <div id="leak-stats" style={{ fontFamily: "monospace" }}> | ||
| cycle: {cycleRef.current} / mounted: {totalMounted} / | ||
| unmounted: {totalUnmounted} / live: {liveIds.size} | ||
| </div> |
There was a problem hiding this comment.
liveIds.size display lags after FinalizationRegistry callbacks
The live: {liveIds.size} value in the rendered DOM is captured at render time. FinalizationRegistry callbacks fire asynchronously and don't trigger a React re-render, so the on-screen live: number can be stale long after GC has collected nodes. The PR description already directs testers toward window.__leakStats, but a note in the JSDoc that the on-screen counter lags would avoid confusion.
Status: draft — investigation only, no code fix applied
Refs #3241
The reporter says scrolling a virtualized list of
motion.divitems withinitial={{ opacity: 0 }} animate={{ opacity: 1 }}causes the Chrome DevTools"DOM Nodes" counter to climb without falling, even after manual GC. Removing
initial/animatestops the growth.What I did
nj8cqp). The sandboxsource is gated behind Cloudflare's bot challenge —
WebFetch,curl, and the CSB API endpoints all returned the challenge page, so Icould not retrieve the original
App.js.motion.div:useMotionRefcallsvisualElement.unmount()when React passesnullto the ref callback (packages/framer-motion/src/motion/utils/use-motion-ref.ts:39).VisualElement.unmount()cancels both scheduled frame callbacks,runs
valueSubscriptionscleanup (which callsvalue.stop()forowned MotionValues, in turn calling
animation.stop()→animation.cancel()for WAAPI), unmounts each feature(
AnimationFeature.unmount()→animationState.reset()), andnulls
this.current(packages/motion-dom/src/render/VisualElement.ts:504-525).animateon amotion.divneither projection norInViewFeatureis enabled (packages/framer-motion/src/motion/features/definitions.ts),so those teardown paths aren't relevant.
(
animationMaps,appearAnimationStore,observerCallbacks, frame-stepqueues). All are
WeakMaps or are cleared per frame. The optimisedappear store only populates when
data-appear-idis present (SSR), soCRA-style sandboxes don't touch it.
#3178release-visual-element,#3453reduced-motion listener,#3381React 19 cleanup,popchild-refs,drag-cleanup). The unmount path has been touched repeatedly sincethis issue was filed.
animate-prop.test.tsx > unmount cancels active animationsalready proves
onAnimationCompleteis not invoked after unmount.to be fixed? … snapshots don't seem to be hinting any leaks."
Where I got stuck
I cannot reliably reproduce the leak:
can't be observed there.
document.getAnimations()isunavailable and detached-DOM accounting requires Chrome DevTools'
Performance Monitor — not addressable from a test runner.
reporter's leak was caused by a third-party virtualizer holding refs,
by a bug since fixed, or by Chrome behaviour around recently-cancelled
WAAPI animations.
Per
feedback_no_repro_no_pr.md: not landing happy-path coverage thatcan't fail without a fix. No source change is included here.
What this PR adds
A single manual reproduction page at
dev/react/src/tests/animate-virtualized-list-memory.tsx(route?test=animate-virtualized-list-memory). It cycles a window of 10motion.divitems every 50ms, simulating the virtualized scrolldescribed in the issue. A maintainer can open it in Chrome with the
Performance Monitor visible to see whether the DOM Nodes counter
stabilises — useful for confirming whether the bug is still live before
landing a fix.
Suggested next step
Confirm reproducibility against current
mainusing the harness(or the original CodeSandbox if accessible). If memory stabilises,
close #3241 as already-fixed; if it grows, the harness gives a
controllable starting point that doesn't depend on a third-party
virtualizer or external sandbox.