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:
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.
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, andrecompute()overwrites its flags, clearingREACTIVE_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:solid/packages/solid-signals/src/core/owner.ts
Lines 74 to 88 in a205730
solid/packages/solid-signals/src/core/core.ts
Lines 165 to 201 in a205730
solid/packages/solid-signals/src/core/core.ts
Lines 290 to 297 in a205730
Reproduction
Actual result:
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
_depsis non-null, and a disposed computation should never be recomputed.Expected observable result before the final read is:
Platform
@solidjs/signals@2.0.0-beta.32Additional context
Existing root-disposal coverage uses a memo with a signal dependency. That gives the child a non-null
_depslist and exercises the working heap-removal branch, so the dependency-free case is missed.