Skip to content

fix: useObserver pinned every observer's first render result - #4689

Open
gesposito wants to merge 1 commit into
mobxjs:mainfrom
gesposito:fix-useobserver-first-render-retention
Open

fix: useObserver pinned every observer's first render result#4689
gesposito wants to merge 1 commit into
mobxjs:mainfrom
gesposito:fix-useobserver-first-render-retention

Conversation

@gesposito

Copy link
Copy Markdown
Contributor

The problem

Every mounted observer component permanently retains the element tree returned by its first render, along with every fiber and DOM node reachable from it.

useObserver creates adm.subscribe/adm.getSnapshot inside its first invocation (if (!admRef.current)), while renderResult is declared in that same invocation and assigned inside adm.reaction.track(() => { renderResult = render() }). All closures created during the same function invocation share one context object, and variables captured by any of them (here: render and renderResult) live in that shared context. Since useSyncExternalStore holds subscribe for as long as the component is mounted, the first invocation's context (including the first render's result and the first render closure) 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 _owner in development, and in production via props that close over fibers (for example any useState setter 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):

Reaction --onInvalidate_--> closure --context--> Context
  --adm--> Object --subscribe--> closure      <- held by useSyncExternalStore while mounted
  --context--> Context
  --renderResult--> Object                    <- first render output, still in scope
  --props--> ... --> FiberNode
  --stateNode--> native:<div>                 «DETACHED»

The fix

Create the administration object in a module-level factory (createObserverAdministration), so subscribe/getSnapshot close over the administration object and nothing render-related. No behavior change: the subscribe/getSnapshot bodies 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: admRef is no longer in scope where those closures are created.

The test

useObserverRetention.test.tsx mounts an observer, takes a WeakRef to the first returned element tree, re-renders a few times (React double-buffers fibers, so the alternate legitimately keeps the immediately-previous render alive for one extra commit), forces GC with the same expose-gc idiom as strictAndConcurrentModeUsingFinalizationRegistry.test.tsx, and asserts the first tree was collected.

Fails on current main (the retained element surfaces complete with its _owner FiberNode, matching the production heap traces), passes with this change.

There is deliberately no assertion that the first render's props become collectable: a control component wrapped in plain React.memo (no MobX involved) fails such an assertion too, so a test cannot isolate MobX's contribution. The factory removes both renderResult and render from subscribe's scope regardless.

Code change checklist

  • Added/updated unit tests
  • Updated /docs. For new functionality, at least API.md should be updated — not applicable: internal memory fix, no API or documented behavior change
  • Verified that there is no significant performance drop (npm -w mobx run test:performance) — no mobx core changes; mobx-react-lite creates the same objects and closures once per component instance as before

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-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4856893

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
mobx-react-lite Patch

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 MILLERMARRU left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

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