Describe the bug
Disposing a root while an action is pending skips cleanup callbacks belonging to the previous memo branch:
- Its
onCleanup callback does not run.
- Its child effect’s returned cleanup does not run.
- Neither callback runs when the action subsequently settles.
Your Example Website or App
see below
Steps to Reproduce the Bug or Issue
In an empty directory:
pnpm add @solidjs/signals@2.0.0-rc.9
node --conditions=development repro.mjs
repro.mjs:
import {
action,
createEffect,
createMemo,
createRoot,
createSignal,
flush,
onCleanup,
} from "@solidjs/signals";
let visible;
let setVisible;
let dispose;
let frameCleanups = 0;
let childCleanups = 0;
const pending = Promise.withResolvers();
createRoot((release) => {
dispose = release;
[visible, setVisible] = createSignal(true);
const branch = createMemo(() => {
if (!visible()) return false;
onCleanup(() => frameCleanups++);
createEffect(
() => visible(),
() => () => childCleanups++,
);
return true;
});
// Keep the branch observed.
createEffect(branch, () => {});
});
flush();
const change = action(function* () {
setVisible(false);
yield pending.promise;
});
const changed = change();
flush();
console.log("pending:", { frameCleanups, childCleanups });
dispose();
flush();
console.log("disposed:", { frameCleanups, childCleanups });
pending.resolve();
await changed;
flush();
console.log("settled:", { frameCleanups, childCleanups });
Expected behavior
Actual result
pending: { frameCleanups: 0, childCleanups: 0 }
disposed: { frameCleanups: 0, childCleanups: 0 }
settled: { frameCleanups: 0, childCleanups: 0 }
Expected result
Both callbacks run exactly once when the root is disposed. Subsequent action settlement does not invoke them again:
pending: { frameCleanups: 0, childCleanups: 0 }
disposed: { frameCleanups: 1, childCleanups: 1 }
settled: { frameCleanups: 1, childCleanups: 1 }
Retaining the old branch while the action is pending is expected. The problem is that its cleanup is permanently omitted after the root is disposed, including after the action finishes.
Screenshots or Videos
No response
Platform
Additional context
Control cases
The following variants were tested against the unpatched GitHub build:
| Variant |
Result |
| Dispose while the action is pending, then settle it |
Both cleanup counts remain 0 |
| Move the signal outside the root |
Same failure |
Use an async generator with await pending.promise; yield; |
Same failure |
| Update the signal without an action, then flush and dispose |
Both callbacks run exactly once |
| Settle and flush the action before disposing |
Both callbacks run exactly once |
The memo and child effect are created under the root. The child effect uses Solid 2’s two-function API and returns its cleanup from the effect callback. All flush() calls occur outside the action body.
Cleanup contract
The custom primitives guide states that:
onCleanup attaches to the current owner and runs when that owner is disposed.
- An effect’s returned cleanup runs before its next execution and on disposal.
createRoot provides an owner and disposer for standalone reactive integrations and tests.
The source documentation for createRoot likewise says its disposer tears down the signals, memos, effects, and cleanup callbacks registered inside the root.
Source investigation
The relevant paths appear to be:
- Recomputing under a pending action parks the previous memo frame’s children and cleanup in
_pendingFirstChild and _pendingDisposal.
- Root disposal marks the memo disposed but traverses only its current children and cleanup.
- Action settlement subsequently attempts to retire the parked frame.
disposeChildren returns immediately because the memo is already marked REACTIVE_DISPOSED.
See packages/signals/src/core/owner.ts.
The tested GitHub commit includes #3547’s fix preserving REACTIVE_ZOMBIE through recomputation. This root-disposal reproduction still fails with that fix present.
This report concerns permanently missed cleanup callbacks, rather than the observer accumulation described in #3543.
Describe the bug
Disposing a root while an action is pending skips cleanup callbacks belonging to the previous memo branch:
onCleanupcallback does not run.Your Example Website or App
see below
Steps to Reproduce the Bug or Issue
In an empty directory:
repro.mjs:Expected behavior
Actual result
Expected result
Both callbacks run exactly once when the root is disposed. Subsequent action settlement does not invoke them again:
Retaining the old branch while the action is pending is expected. The problem is that its cleanup is permanently omitted after the root is disposed, including after the action finishes.
Screenshots or Videos
No response
Platform
@solidjs/signals@2.0.0-rc.9next, commitbe46a04de7235607e83658627e82b0b3d30003d9, built from unmodified sourceAdditional context
Control cases
The following variants were tested against the unpatched GitHub build:
0await pending.promise; yield;The memo and child effect are created under the root. The child effect uses Solid 2’s two-function API and returns its cleanup from the effect callback. All
flush()calls occur outside the action body.Cleanup contract
The custom primitives guide states that:
onCleanupattaches to the current owner and runs when that owner is disposed.createRootprovides an owner and disposer for standalone reactive integrations and tests.The source documentation for
createRootlikewise says its disposer tears down the signals, memos, effects, and cleanup callbacks registered inside the root.Source investigation
The relevant paths appear to be:
_pendingFirstChildand_pendingDisposal.disposeChildrenreturns immediately because the memo is already markedREACTIVE_DISPOSED.See
packages/signals/src/core/owner.ts.Relationship to #3543 / #3547
The tested GitHub commit includes #3547’s fix preserving
REACTIVE_ZOMBIEthrough recomputation. This root-disposal reproduction still fails with that fix present.This report concerns permanently missed cleanup callbacks, rather than the observer accumulation described in #3543.