Skip to content

Dependency-free computations can be resurrected after disposal #2983

Description

@brenelz

Describe the bug

A dependency-free computation queued by refresh() can run after its owner root has been disposed and lose its disposed state.

During owner disposal, queued children are removed from the scheduler heap only inside the if (child._deps) branch. A memo with no reactive dependencies therefore remains queued. The later scheduler flush recomputes it, and recompute() overwrites its flags, clearing REACTIVE_DISPOSED.

This violates the disposal invariant: user code and async work can start after unmount, the accessor becomes readable again, and cleanups registered by the post-disposal run are leaked.

Source locations at a2057304:

  • Heap deletion is conditional on the child having dependencies:
    let child = zombie ? (node._pendingFirstChild as Owner) : node._firstChild;
    while (child) {
    const nextChild = child._nextSibling;
    if ((child as Computed<unknown>)._deps) {
    const n = child as Computed<unknown>;
    deleteFromHeap(n, queueFor(n));
    let toRemove = n._deps;
    do {
    toRemove = unlinkSubs(toRemove!);
    } while (toRemove !== null);
    n._deps = null;
    n._depsTail = null;
    }
    disposeChildren(child, true);
    child = nextChild;
  • Recompute replaces the node flags after executing it:
    export function recompute(el: Computed<any>, create: boolean = false): void {
    const isEffect = (el as any)._type;
    if (!create) {
    if (el._transition && (!isEffect || activeTransition) && activeTransition !== el._transition)
    globalQueue.initTransition(el._transition);
    deleteFromHeap(el, queueFor(el));
    el._inFlight = null;
    // Tracked effects run after finalizePureQueue, so dispose immediately instead of deferring
    if (el._transition || isEffect === EFFECT_TRACKED) disposeChildren(el);
    else if (el._firstChild !== null || el._disposal !== null) {
    markDisposal(el);
    el._pendingDisposal = el._disposal;
    el._pendingFirstChild = el._firstChild;
    el._disposal = null;
    el._firstChild = null;
    el._childCount = 0;
    if (__DEV__) clearSignals(el);
    } else if (__DEV__) clearSignals(el);
    }
    let isOptimisticDirty = !!(el._flags & REACTIVE_OPTIMISTIC_DIRTY);
    const hasOverride = el._overrideValue !== undefined && el._overrideValue !== NOT_PENDING;
    const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED);
    // Outgoing error, captured before the compute clears status: if this run
    // recovers to an unchanged value, dependents still holding this object must
    // be swept (settleErroredDependents, #2949).
    const outgoingError = el._statusFlags & STATUS_ERROR ? el._error : undefined;
    // Re-ask classification lives in the verdict module; capture the flag before
    // the recompute wipes _flags below.
    const hadReask = (el._flags & REACTIVE_REASK) !== 0;
    const oldcontext = context;
    context = el;
    el._depsTail = null;
    el._depGen++;
    el._flags = REACTIVE_RECOMPUTING_DEPS;
    el._time = clock;
    and
    } finally {
    tracking = prevTracking;
    latestReadActive = prevLatestRead;
    if (__DEV__) strictRead = prevStrictRead;
    if (isStaleEffect) stale = prevStale;
    el._flags = REACTIVE_NONE | (create ? el._flags & REACTIVE_SNAPSHOT_STALE : 0);
    context = oldcontext;
    }

Reproduction

import {
  createMemo,
  createRoot,
  flush,
  onCleanup,
  refresh
} from "@solidjs/signals";

let runs = 0;
let cleanups = 0;
let read!: () => number;
let dispose!: () => void;

createRoot(d => {
  dispose = d;
  read = createMemo(() => {
    runs++;
    onCleanup(() => cleanups++);
    return runs;
  });
});

refresh(read); // queue the dependency-free memo
dispose();     // dispose before the queued flush
flush();

console.log({ runs, cleanups, value: read() });

Actual result:

{ runs: 2, cleanups: 1, value: 2 }

The memo ran after disposal, became readable again, and the cleanup created by the second run was not executed.

Expected behavior

Disposal should remove the computation from every scheduler heap regardless of whether _deps is non-null, and a disposed computation should never be recomputed.

Expected observable result before the final read is:

{ runs: 1, cleanups: 1 }

Platform

  • OS: macOS and Linux
  • Runtime: Node.js
  • Version: @solidjs/signals@2.0.0-beta.32

Additional context

Existing root-disposal coverage uses a memo with a signal dependency. That gives the child a non-null _deps list and exercises the working heap-removal branch, so the dependency-free case is missed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions