Skip to content

fix(signals): skip unchanged presence writes in store folds - #3744

Closed
brenelz wants to merge 2 commits into
solidjs:nextfrom
brenelz:claude/issue-3743
Closed

brenelz wants to merge 2 commits into
solidjs:nextfrom
brenelz:claude/issue-3743

Conversation

@brenelz

@brenelz brenelz commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #3743

What was broken

A reconcile() outside a pending action held unrelated updates (a.value stayed stale) when a separate in expression observed a key that the action had deleted and the incoming snapshot also left out.

Root cause

The presence loops in notifyFold and notifyFoldTail wrote every observed in node without comparing old and new presence. setSignal joins the node's holding transaction before its equality gate rejects the repeated value, so the reconcile was tied to the deleting action.

What changed

  • The presence loops write only when key in old differs from key in neu, matching the value loop. When old is a live chained store proxy, they still write unconditionally, because the proxy reflects the inner store rather than what the node was last told.
  • applyAdopt materializes a prototype-overlay pending backing before using it as the diff base. Without this, a key deleted in a wide (more than 32 keys) owned draft still reads as present.

A real presence change still notifies and still proposes on a held node, so deleting a prop override can still select another merge source.

Verification

  • cd packages/signals && npx vitest run tests/store/unchanged-presence-no-hold-3743.test.ts: 5 tests. The issue scenario, its wide-object version and the chained-projection case fail without the fix.
  • cd packages/signals && npx vitest run: all pass.
  • cd packages/solid && npx vitest run: 819 passed.
  • cd packages/web && npx vitest run --config vite.config.mjs, vite.config.hydrate.mjs and vite.config.server.mjs: no new failures. One server failure in server-functions-adapter-request.spec.tsx is pre-existing on next.

Left open

The setter-path case from the issue comment (notifyWrites re-notifying keys kept in the cumulative wk) is not addressed here.

…lidjs#3743)

The fold's presence loops (notifyFold, notifyFoldTail) wrote every
observed `in` node without comparing old and new presence, while the
value loop already diffs. setSignal joins the node's holding transaction
before its equality gate rejects the repeat, so a reconcile() outside an
action that had deleted an observed key entangled the whole tick with
that action: an unrelated `a.value` stayed stale until the action
settled. The presence loops now write only when `key in old` differs
from `key in neu`; `old` is the view the nodes were last told (solidjs#3296),
so a real presence change still notifies and still proposes on a held
node.
applyAdopt materializes a prototype-overlay pending backing before taking
its diff base. A nested object wide enough to draft as an overlay (more
than 32 keys, owned) read a key the draft had deleted as still present
through the overlay's prototype, so the fold's presence skip fired on a
real deletion: an unrelated value stayed stale until the action settled,
and a reconcile restoring the key never proposed on the held node.

The presence loops in notifyFold and notifyFoldTail write unconditionally
when the old base is a live store proxy (a chained backing). `key in old`
there reads the inner store's current state, not what this target's
presence node was last told, so a projection adopting away from a chained
store and then back to a key the inner store had deleted left the node
stale and skipped the notification.

The tests add the wide-object (overlay) versions of both solidjs#3743 scenarios
and the chained adoption case.
@changeset-bot

changeset-bot Bot commented Oct 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9c491aa

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

This PR includes changesets to release 12 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
todos-server-example 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 Oct 2, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 185 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing brenelz:claude/issue-3743 (9c491aa) with next (9a213bb)

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. ↩

@ryansolid

Copy link
Copy Markdown
Member

Thanks for this, @brenelz, and for the clear write-up of the root cause. The fix and the tests for #3743 look good.

I'm going to put this on hold rather than merge it. The reactive system in @solidjs/signals is being rewritten, and I don't want to land more changes in code that's about to be replaced. Your tests in unchanged-presence-no-hold-3743.test.ts will carry over as cases the rewrite has to pass, including the wide-object and chained-projection variants. The setter-path case you left open is noted too.

I'll leave the PR open for now and come back to it once the rewrite lands.

— Claude via Cursor

ryansolid added a commit that referenced this pull request Oct 5, 2026
)

Port of #3744 (brenelz) onto L2 (#3774). The fold's presence loop
(`notifyFoldTail`) wrote every observed `in` node with `key in neu`;
`setSignal` joins a held node's transaction before its equality gate
(A34 (1): a write to a held node is a proposal, the same value or
another), so a `reconcile()` outside an action that had deleted an
observed key — the snapshot leaving it absent too — made the whole
mainline tick the action's: an unrelated `a.value` stayed stale until
the action settled. Presence is now diffed `old` -> `neu` like the
leaves (the fold's contract, #3296: the view the nodes were last told);
a live chained `old` (a store proxy) is written unconditionally as
before. `notifyFold` shares the tail.

`applyAdopt` materializes a nested overlay draft before taking its diff
base (`adoptPB` did so after it was taken): through the overlay's
prototype a deleted key still read as present, so the presence skip
fired on a real deletion, and a reconcile restoring the key never
re-proposed on the held leaf, which committed the draft's `undefined`
beside a backing that had the key.

Two L2 fixes the diff uncovered, pinned by the existing #2719/#3164 and
Q-D twin tests and a new leaf-only reader case: a container carrying an
arrangement guess is told of an arrangement change whether or not
anything subscribes to it (`notifyFoldTail`/`notifyWrites`) — for a
guessed container the write is the landing that judges the guess
(Q-D, plan sec. 39), and before only the unconditional presence write
reached the lane, so a newer question's rows landing beneath an
optimistic push published beside it for a reader of leaves alone; and
`_laneRebase` no longer re-stages a slot whose row the committed
backing already shows by key, nor an unchanged `length` (a spurious
identical frame).

Signals 4900 / 0 / 3 expected fails (+9 pins: 8 green, one `it.fails`
for the open setter-path case — `t.wk` is cumulative, not per-setter),
solid 819 / 0, web 1132 / 1411 / 275, 0
failures. Size (br): + createStore 14517 -> 14523, every store family
28770 -> 28765, page base 44017 -> 44028, page live 47656 -> 47668
(minified -97..-100 B each); every scenario within its cap.

Co-authored-by: Brenley Dueck <brenleydueck@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Oct 5, 2026
The port of #3744 measures 44,031 B brotli on the base server-components
page against next @ 1a3f87f's 44,029 (+2 B, 1 B over the cap) while the
same bundle is -97 B minified: brotli layout on the 150 KB bundle, not
code. Cap set at measured + 10 B rounded up to 0.01 KB; dated note in
scenarios.js beside the page-base notes.

Size-Exception: page: base server components 44,029 -> 44,031 B (cap 44.03 -> 44.05 KB) — presence diff in store folds (#3743); source −97 B minified; accepted by the maintainer (2026-10-04)
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Oct 5, 2026
, supersedes #3744) (#3791)

* fix(signals): a fold repeats no unchanged presence to a held node (#3743)

Port of #3744 (brenelz) onto L2 (#3774). The fold's presence loop
(`notifyFoldTail`) wrote every observed `in` node with `key in neu`;
`setSignal` joins a held node's transaction before its equality gate
(A34 (1): a write to a held node is a proposal, the same value or
another), so a `reconcile()` outside an action that had deleted an
observed key — the snapshot leaving it absent too — made the whole
mainline tick the action's: an unrelated `a.value` stayed stale until
the action settled. Presence is now diffed `old` -> `neu` like the
leaves (the fold's contract, #3296: the view the nodes were last told);
a live chained `old` (a store proxy) is written unconditionally as
before. `notifyFold` shares the tail.

`applyAdopt` materializes a nested overlay draft before taking its diff
base (`adoptPB` did so after it was taken): through the overlay's
prototype a deleted key still read as present, so the presence skip
fired on a real deletion, and a reconcile restoring the key never
re-proposed on the held leaf, which committed the draft's `undefined`
beside a backing that had the key.

Two L2 fixes the diff uncovered, pinned by the existing #2719/#3164 and
Q-D twin tests and a new leaf-only reader case: a container carrying an
arrangement guess is told of an arrangement change whether or not
anything subscribes to it (`notifyFoldTail`/`notifyWrites`) — for a
guessed container the write is the landing that judges the guess
(Q-D, plan sec. 39), and before only the unconditional presence write
reached the lane, so a newer question's rows landing beneath an
optimistic push published beside it for a reader of leaves alone; and
`_laneRebase` no longer re-stages a slot whose row the committed
backing already shows by key, nor an unchanged `length` (a spurious
identical frame).

Signals 4900 / 0 / 3 expected fails (+9 pins: 8 green, one `it.fails`
for the open setter-path case — `t.wk` is cumulative, not per-setter),
solid 819 / 0, web 1132 / 1411 / 275, 0
failures. Size (br): + createStore 14517 -> 14523, every store family
28770 -> 28765, page base 44017 -> 44028, page live 47656 -> 47668
(minified -97..-100 B each); every scenario within its cap.

Co-authored-by: Brenley Dueck <brenleydueck@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* size: page base cap 44.03 -> 44.05 KB for #3743's fold presence diff

The port of #3744 measures 44,031 B brotli on the base server-components
page against next @ 1a3f87f's 44,029 (+2 B, 1 B over the cap) while the
same bundle is -97 B minified: brotli layout on the 150 KB bundle, not
code. Cap set at measured + 10 B rounded up to 0.01 KB; dated note in
scenarios.js beside the page-base notes.

Size-Exception: page: base server components 44,029 -> 44,031 B (cap 44.03 -> 44.05 KB) — presence diff in store folds (#3743); source −97 B minified; accepted by the maintainer (2026-10-04)
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Brenley Dueck <brenleydueck@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid

Copy link
Copy Markdown
Member

Thanks for this, @brenelz — superseded by #3791 (924d909d9), now merged into next.

Your tests landed verbatim with Co-authored-by credit. The fix was re-implemented in L2's fold path: notifyFoldTail diffs presence old → neu so a fold repeats no unchanged presence to a held node, and applyAdopt materializes an overlay draft before capturing its diff base.

Working through the diff surfaced two further latent bugs, both fixed in the same PR: a container's arrangement guess is now judged by every landing that changes the arrangement whether or not anything subscribes to it, and _laneRebase compares by key rather than raw identity.

Closing in favour of #3791.

— Claude via Cursor

@ryansolid ryansolid closed this Oct 5, 2026
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