Skip to content

fix(signals): a reawakened lazy memo rejoins its owner's chain - #3555

Draft
brenelz wants to merge 3 commits into
solidjs:nextfrom
brenelz:claude/issue-3554
Draft

brenelz wants to merge 3 commits into
solidjs:nextfrom
brenelz:claude/issue-3554

Conversation

@brenelz

@brenelz brenelz commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Fixes #3554

What was broken

An auto-dispose memo (createMemo(fn, { lazy: true })) that went dormant was
spliced out of its owner's child chain and never relinked when a later read
reawakened it. Its next dormancy ran the splice again from outside the chain
and wrote a stale _nextSibling into parent._firstChild, orphaning every
sibling created after it: never disposed by the owner, still subscribed to
their sources, still running after the root's dispose(). Dev builds threw
the owner-chain-head invariant from #3552 mid-flush instead.

Root cause

unobserved() calls disposeChildren(node, true), which unlinks the node,
nulls _prevSibling and leaves _nextSibling intact so outer walks still
advance. The reawaken in prepareComputed only called recompute(comp, true),
whose flag wipe cleared REACTIVE_DISPOSED. The node was then live by flags
but outside the chain, so the next splice took the head branch and clobbered
the head with that stale pointer.

What changed

prepareComputed links the node back at the chain head before recomputing, so
"live" again implies "in the chain" and the owner's dispose() and reruns
reach a reawakened memo. The relink is skipped for a zombie, whose splice was
skipped and which still sits on the pending chain, and for a node whose parent
is already disposed, whose chain can never be drained again.

disposeChildren now detaches node._firstChild before its loop and points
each child's _prevSibling at itself, so a node that a cleanup links during a
held-children drain stays on the chain instead of being written back into the
head at its next dormancy.

The creation-time link in createOwner and setupComputedNode and the new
relink share one linkChild helper. The owner-chain-head invariant is
unchanged and pins the regression. The core minified floor moves 25,671 to
25,724, under the 25,750 cap, and is recorded in the treeshake ledger.

The alternative in the issue, skipping the splice for dormant nodes, was
rejected: it leaves disposed members in the live chain for every walker to
tolerate, retains every dormant lazy memo until its owner disposes, and
contradicts the chain invariant the #3552 test encodes.

Verification

cd packages/signals && npx vitest run tests/dormant-memo-chain-3554.test.ts
cd packages/signals && SIGNALS_TIER=observe npx vitest run tests/dormant-memo-chain-3554.test.ts
cd packages/signals && npx vitest run

packages/signals/tests/dormant-memo-chain-3554.test.ts adds four cases. Three
fail on pristine next and all four pass here, under the dev tier and under
SIGNALS_TIER=observe. Full suites: signals 3,468 passed, solid 654 passed,
web 834 passed.

Left open

Dormant off-chain nodes keep CONFIG_AUTO_DISPOSE and still recompute after
their owner dies, the pre-existing #3024 gap. This change neither widens nor
closes it.

A relinked node moves to the chain head, so sibling disposal order for a
reawakened memo is no longer creation order.

…js#3554)

unobserved() splices an auto-dispose memo out of parent._firstChild when
its last subscriber leaves, but the reawaken in prepareComputed only
recomputed it. The node then ran live outside the chain with a null
_prevSibling and a stale _nextSibling, so its next dormancy took the head
branch of the splice and wrote the stale pointer into parent._firstChild,
orphaning every sibling created after it (still subscribed, still running
after the root's dispose()). Dev builds threw the owner-chain-head
invariant from solidjs#3552 mid-flush instead.

The reawaken now links the node back at the chain head before the
recompute, through the same linkChild helper createOwner and
setupComputedNode use for the creation-time link. A zombie is not
relinked: disposeChildren skipped its splice, so it still sits on the
parent's pending chain. Core floor 25,671 -> 25,684 bytes.

Claude-Session: https://claude.ai/code/session_01JH7R7yDguTxuXpDioMzMCT
Co-Authored-By: Claude <noreply@anthropic.com>
A dormant lazy memo read after its owner's dispose() is no longer linked
into that owner's child chain. disposeChildren skips the dormancy splice
when the parent is already REACTIVE_DISPOSED, so a relinked node stayed at
the head and the next reawaken linked it to itself, which sent markDisposal
and DEV.getChildren into an endless loop; the dead chain is also never
drained again, because dispose() early-returns on REACTIVE_DISPOSED.

linkChild resets node._prevSibling itself instead of relying on the caller
having cleared it. Core floor 25,684 -> 25,716 bytes, under the 25,750 cap.

Co-Authored-By: Claude <noreply@anthropic.com>
A lazy memo a cleanup reawakens while its owner's held children are torn
down stays on the owner's chain. recompute drains those children in place
without flagging the owner, so the reawaken relinked the memo, and the
drain's trailing _firstChild = null then dropped it again, live and off
the chain; its next dormancy wrote null into the head and dev builds threw
the owner-chain-head invariant. disposeChildren now detaches the chain
before its loop and points each child's _prevSibling at itself, so the
splice never writes the detached remainder back into the head and a node
linked during the drain survives it. Core floor 25,716 -> 25,724 bytes.

The treeshake ledger records the branch's bump (25,671 -> 25,724).

Co-Authored-By: Claude <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e4a7cb9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed

codspeed Bot commented Sep 19, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 172 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing brenelz:claude/issue-3554 (e4a7cb9) with next (be46a04)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants