Intended outcome:
Reactions created by observer renders that never commit should eventually release their observable dependencies. If a suspended component later retries, it should render the latest value and remain reactive.
We encountered this while investigating retained memory after navigation in the Linear client using mobx-react-lite.
Actual outcome:
Some uncommitted observer reactions remain subscribed after garbage collection. Their computed derivations and captured React state remain reachable, and changing an observable source causes those derivations to run again.
In our local React/MobX regression harness, native-only finalization retained two derivation callbacks after repeated garbage collection. Enabling the existing timer-based cleanup alongside native finalization released both. Resuming the component then rendered the latest value and subsequent updates worked.
The retention path we investigated was:
FinalizationRegistry held administration
→ reaction
→ observed computed
→ derivation closure
→ React state setter
→ pending React fiber
→ administration ref registered as the finalization target
An observable source can also retain the reaction through its observer graph. Native finalization cannot clean up a target while it remains strongly reachable.
How to reproduce the issue:
The following illustrates the case covered by our local regression harness. A standalone runnable reproduction still needs to be attached before filing.
import * as React from "react";
import { createRoot } from "react-dom/client";
import { computed, getObserverTree, observable, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
const source = observable.box(1);
let calculations = 0;
let ready = false;
let resume!: () => void;
const pending = new Promise<void>(resolve => {
resume = resolve;
});
const View = observer(function View() {
const [, setState] = React.useState(0);
const derived = React.useMemo(
() =>
computed(() => {
// Capture the state setter from this render.
void setState;
calculations++;
return source.get();
}),
[]
);
const value = derived.get();
if (!ready) {
throw pending;
}
return <span>{value}</span>;
});
const root = createRoot(document.getElementById("root")!);
root.render(
<React.Suspense fallback="Loading">
<View />
</React.Suspense>
);
// Expose operations without retaining the component or its computed value.
Object.assign(window, {
inspectSource: () => ({
calculations,
observers: getObserverTree(source),
}),
changeSource: () => runInAction(() => source.set(source.get() + 1)),
resumeView: () => {
ready = true;
resume();
},
});
- Run in a browser with native
FinalizationRegistry support and wait for the “Loading” fallback.
- Force garbage collection, yielding between collections to allow finalization callbacks to run.
- Call
inspectSource() and inspect the source’s observer graph. The suspended render’s derivations remain subscribed.
- Call
changeSource(). In the affected case, the retained derivations run again.
- Compare against a build that also applies the existing timed cleanup to uncommitted reactions. After expiry, those subscriptions should be removed.
- Call
resumeView(), then changeSource(). The view should show the latest value and continue updating.
GC timing alone does not establish the retention problem; inspect the heap’s strong reference paths as well.
One possible fix is to retain native finalization for early cleanup while also applying the existing timed fallback to uncommitted reactions. Subscription would unregister both cleanup paths. It adds registration and cleanup work, and our application-level CPU results were mixed. Any fix should also cover StrictMode, delayed retries, cleanup before first subscription, and suspended transitions with an active committed view.
Versions
mobx: 7.0.3
mobx-react-lite: 5.0.3
react: 19.2.8
react-dom: 19.2.8
- Local lifecycle/GC harness: Node.js 24.18.0 with
--expose-gc
Experimental patch
We tested combining native finalization with the existing timed fallback. Either cleanup path unregisters the other before disposing the reaction. Subscription unregisters both, and the timer stops when no pending registrations remain.
This addresses the retention case in our local tests, but CPU results were mixed, so we have not treated it as a final upstream solution. The existing expiry threshold and sweep interval remain unchanged.
| Measure |
Unpatched |
Patch |
Change |
95% interval for change |
| Retained JS heap after GC |
423.1 MB |
330.0 MB |
−22.01% |
−24.12% to −19.89% |
| Renderer task CPU |
10.770 s |
10.723 s |
−0.44% |
−4.09% to +3.21% |
Source changes below; generated bundles are omitted.
--- a/src/utils/UniversalFinalizationRegistry.ts
+++ b/src/utils/UniversalFinalizationRegistry.ts
@@
unregister(token: unknown) {
this.registrations.delete(token)
+ if (this.registrations.size === 0) {
+ clearTimeout(this.sweepTimeout)
+ this.sweepTimeout = undefined
+ }
}
Replace src/utils/observerFinalizationRegistry.ts with:
import type { Reaction } from "mobx"
const FINALIZE_AFTER = 10_000
const SWEEP_INTERVAL = 10_000
const SWEEP_BATCH_SIZE = 128
const SWEEP_TIME_BUDGET = 2
const now = typeof performance === "undefined" ? Date.now : () => performance.now()
type ObserverAdministration = { reaction: Reaction | null }
type Sweep = {
entries: IterableIterator<[ObserverAdministration, number]>
remaining: number
cutoff: number
}
class ObserverFinalizationRegistry {
constructor() {
this.native = typeof FinalizationRegistry === "undefined"
? undefined
: new FinalizationRegistry(adm => {
this.remove(adm)
disposeReaction(adm)
})
}
// useObserver passes the administration as both the held value and unregister token.
register(target: object, adm: ObserverAdministration, token = adm) {
if (this.remove(token)) {
this.native?.unregister(token)
}
// Most renders subscribe in this task. Only pending renders need finalization.
// Keep the target alive until the microtask transfers it to the native registry.
this.staged.set(token, target)
if (!this.flushScheduled) {
this.flushScheduled = true
Promise.resolve().then(this.flushRegistrations)
}
}
unregister(token: ObserverAdministration) {
if ((this.staged.size === 0 || !this.staged.delete(token)) && this.remove(token)) {
this.native?.unregister(token)
}
}
// Bound so clearTimers can export it directly.
finalizeAllImmediately = () => {
this.flushRegistrations()
this.cancelSweep()
const registrations = this.registrations
this.registrations = new Map()
for (const adm of registrations.keys()) {
this.native?.unregister(adm)
disposeReaction(adm)
}
}
private staged = new Map<ObserverAdministration, object>()
private flushScheduled = false
private registrations = new Map<ObserverAdministration, number>()
private readonly native: FinalizationRegistry<ObserverAdministration> | undefined
private timeout: ReturnType<typeof setTimeout> | undefined
private currentSweep: Sweep | undefined
private channel: MessageChannel | undefined
private flushRegistrations = () => {
this.flushScheduled = false
if (this.staged.size === 0) {
return
}
const registeredAt = Date.now()
this.staged.forEach((target, adm) => {
this.registrations.set(adm, registeredAt)
this.native?.register(target, adm, adm)
})
this.staged.clear()
this.scheduleSweep()
}
private remove(adm: ObserverAdministration) {
const removed = this.registrations.delete(adm)
if (removed && this.registrations.size === 0) {
this.cancelSweep()
}
return removed
}
private cancelSweep() {
if (this.timeout !== undefined) {
clearTimeout(this.timeout)
this.timeout = undefined
}
this.closeChannel()
this.currentSweep = undefined
}
private closeChannel() {
if (this.channel) {
this.channel.port1.onmessage = null
this.channel.port1.close()
this.channel.port2.close()
this.channel = undefined
}
}
private scheduleContinuation() {
// Message tasks avoid the delay imposed on nested timers in background tabs.
if (typeof MessageChannel === "undefined") {
this.timeout = setTimeout(this.sweep, 0)
return
}
if (!this.channel) {
const channel = this.channel = new MessageChannel()
channel.port1.onmessage = () => {
if (this.channel === channel) {
this.sweep()
}
}
}
this.channel.port2.postMessage(undefined)
}
private scheduleSweep() {
if (this.timeout === undefined && this.currentSweep === undefined) {
this.timeout = setTimeout(this.sweep, SWEEP_INTERVAL)
}
}
// Yield between batches so abandoned trees do not all dispose in one task.
// A single reaction disposal can exceed the time budget.
private sweep = () => {
this.timeout = undefined
const sweep = this.currentSweep ??= {
entries: this.registrations.entries(),
remaining: this.registrations.size,
cutoff: Date.now() - FINALIZE_AFTER
}
const start = now()
let processed = 0
while (sweep.remaining > 0) {
const entry = sweep.entries.next()
if (entry.done) {
break
}
sweep.remaining--
const [adm, registeredAt] = entry.value
if (registeredAt <= sweep.cutoff) {
this.registrations.delete(adm)
this.native?.unregister(adm)
disposeReaction(adm)
}
// Disposal may synchronously remove or replace pending registrations.
if (this.currentSweep !== sweep) {
return
}
processed++
if (sweep.remaining > 0 && (processed >= SWEEP_BATCH_SIZE ||
now() - start >= SWEEP_TIME_BUDGET)) {
this.scheduleContinuation()
return
}
}
this.currentSweep = undefined
this.closeChannel()
if (this.registrations.size > 0) {
this.scheduleSweep()
}
}
}
function disposeReaction(adm: ObserverAdministration) {
const reaction = adm.reaction
adm.reaction = null
reaction?.dispose()
}
export const observerFinalizationRegistry = new ObserverFinalizationRegistry()
This registry relies on useObserver passing the administration as both the held value and unregister token. It is not a general-purpose replacement for FinalizationRegistry.
Intended outcome:
Reactions created by observer renders that never commit should eventually release their observable dependencies. If a suspended component later retries, it should render the latest value and remain reactive.
We encountered this while investigating retained memory after navigation in the Linear client using
mobx-react-lite.Actual outcome:
Some uncommitted observer reactions remain subscribed after garbage collection. Their computed derivations and captured React state remain reachable, and changing an observable source causes those derivations to run again.
In our local React/MobX regression harness, native-only finalization retained two derivation callbacks after repeated garbage collection. Enabling the existing timer-based cleanup alongside native finalization released both. Resuming the component then rendered the latest value and subsequent updates worked.
The retention path we investigated was:
An observable source can also retain the reaction through its observer graph. Native finalization cannot clean up a target while it remains strongly reachable.
How to reproduce the issue:
The following illustrates the case covered by our local regression harness. A standalone runnable reproduction still needs to be attached before filing.
FinalizationRegistrysupport and wait for the “Loading” fallback.inspectSource()and inspect the source’s observer graph. The suspended render’s derivations remain subscribed.changeSource(). In the affected case, the retained derivations run again.resumeView(), thenchangeSource(). The view should show the latest value and continue updating.GC timing alone does not establish the retention problem; inspect the heap’s strong reference paths as well.
One possible fix is to retain native finalization for early cleanup while also applying the existing timed fallback to uncommitted reactions. Subscription would unregister both cleanup paths. It adds registration and cleanup work, and our application-level CPU results were mixed. Any fix should also cover StrictMode, delayed retries, cleanup before first subscription, and suspended transitions with an active committed view.
Versions
mobx: 7.0.3mobx-react-lite: 5.0.3react: 19.2.8react-dom: 19.2.8--expose-gcExperimental patch
We tested combining native finalization with the existing timed fallback. Either cleanup path unregisters the other before disposing the reaction. Subscription unregisters both, and the timer stops when no pending registrations remain.
This addresses the retention case in our local tests, but CPU results were mixed, so we have not treated it as a final upstream solution. The existing expiry threshold and sweep interval remain unchanged.
Source changes below; generated bundles are omitted.
Replace
src/utils/observerFinalizationRegistry.tswith:This registry relies on
useObserverpassing the administration as both the held value and unregister token. It is not a general-purpose replacement forFinalizationRegistry.