Skip to content

Suspended observer renders can retain reactions despite native finalization #4705

Description

@skovhus

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();
  },
});
  1. Run in a browser with native FinalizationRegistry support and wait for the “Loading” fallback.
  2. Force garbage collection, yielding between collections to allow finalization callbacks to run.
  3. Call inspectSource() and inspect the source’s observer graph. The suspended render’s derivations remain subscribed.
  4. Call changeSource(). In the affected case, the retained derivations run again.
  5. Compare against a build that also applies the existing timed cleanup to uncommitted reactions. After expiry, those subscriptions should be removed.
  6. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions