fix: useObserver pinned every observer's first render result - #4689
fix: useObserver pinned every observer's first render result#4689gesposito wants to merge 1 commit into
Conversation
subscribe/getSnapshot were created inside useObserver's first invocation and shared that invocation's closure context with the reaction.track callback's captures (render, renderResult). Since useSyncExternalStore holds subscribe for as long as the component is mounted, the element tree returned by the first render (and every fiber and DOM node reachable from it) could never be garbage collected. Create the administration object in a module-level factory instead, so subscribe/getSnapshot close over nothing render-related.
🦋 Changeset detectedLatest commit: 4856893 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
MILLERMARRU
left a comment
There was a problem hiding this comment.
The shared-Context explanation was the part I wanted to actually verify rather than take as given, since "closures from the same invocation can pin each other's captures" is easy to state but not always obviously true depending on how much the engine can split scopes. Reimplemented the before/after shape standalone (subscribe created alongside a sibling render/renderResult in one case, subscribe created via a separate factory call in the other) and ran it under --expose-gc with WeakRef:
BEFORE fix: renderResult still reachable via retained subscribe closure? -> true
AFTER fix: renderResult still reachable via retained subscribe closure? -> false
So the core claim is real on V8, not just plausible-sounding: a closure that never references a sibling variable can still keep it alive as long as both were created in the same function invocation and something holds onto the closure. Moving the object literal into a module-level factory removes the shared invocation entirely, which is the right fix rather than trying to null things out after the fact.
The retention chain in the writeup (_owner in dev, props closing over fiber-capturing setters in prod) matches how this would actually surface as detached DOM in a real app rather than just a synthetic test concern, and removing the admRef comments makes sense since the factory makes that constraint structural instead of relying on convention. Good test too, the WeakRef-plus-several-rerenders approach correctly accounts for the alternate-fiber retention window instead of asserting collection after just one re-render.
The problem
Every mounted
observercomponent permanently retains the element tree returned by its first render, along with every fiber and DOM node reachable from it.useObservercreatesadm.subscribe/adm.getSnapshotinside its first invocation (if (!admRef.current)), whilerenderResultis declared in that same invocation and assigned insideadm.reaction.track(() => { renderResult = render() }). All closures created during the same function invocation share one context object, and variables captured by any of them (here:renderandrenderResult) live in that shared context. SinceuseSyncExternalStoreholdssubscribefor as long as the component is mounted, the first invocation's context (including the first render's result and the firstrenderclosure) can never be collected. Subsequent renders allocate fresh contexts that die normally; only the first invocation's survives.This matters because a React element tree reaches fibers: via
_ownerin development, and in production via props that close over fibers (for example anyuseStatesetter passed down as a prop). A long-lived observer parent therefore pins the child elements it was first rendered with; after a route change those belong to a stale route, holding its fiber tree and detached DOM.Found while tracing detached-DOM growth in a production app. Representative heap-snapshot retainer path (weak edges excluded):
The fix
Create the administration object in a module-level factory (
createObserverAdministration), sosubscribe/getSnapshotclose over the administration object and nothing render-related. No behavior change: thesubscribe/getSnapshotbodies are untouched, they are still created once per component instance, and their identity is still stable across renders.The file already guarded against a sibling hazard by convention ("Do not store
admRef(even as part of a closure!)"); this is the same class of problem, reached through the shared invocation context instead. The factory makes both constraints structural, which is also why the three// Do NOT access admRef here!comments are removed:admRefis no longer in scope where those closures are created.The test
useObserverRetention.test.tsxmounts an observer, takes aWeakRefto the first returned element tree, re-renders a few times (React double-buffers fibers, so thealternatelegitimately keeps the immediately-previous render alive for one extra commit), forces GC with the sameexpose-gcidiom asstrictAndConcurrentModeUsingFinalizationRegistry.test.tsx, and asserts the first tree was collected.Fails on current
main(the retained element surfaces complete with its_ownerFiberNode, matching the production heap traces), passes with this change.There is deliberately no assertion that the first render's
propsbecome collectable: a control component wrapped in plainReact.memo(no MobX involved) fails such an assertion too, so a test cannot isolate MobX's contribution. The factory removes bothrenderResultandrenderfromsubscribe's scope regardless.Code change checklist
/docs. For new functionality, at leastAPI.mdshould be updated — not applicable: internal memory fix, no API or documented behavior changenpm -w mobx run test:performance) — nomobxcore changes;mobx-react-litecreates the same objects and closures once per component instance as before