Skip to content

fix(signals): unchanged store key read under an adoption hold does not hold the reader (#3706) - #3707

Merged
ryansolid merged 5 commits into
solidjs:nextfrom
brenelz:investigate/optimistic-confirm-holds-signal-3706
Sep 29, 2026
Merged

ryansolid merged 5 commits into
solidjs:nextfrom
brenelz:investigate/optimistic-confirm-holds-signal-3706

Conversation

@brenelz

@brenelz brenelz commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #3706.

Confirming one optimistic move while another was still pending held a later, independent drag write for the second move's lifetime: latest(drag) became "0", drag() stayed unset and isPending(drag) went true. The second reproduction on the issue (a nested <Show> preview over cards.find(...)) is the same defect reached through an inherited array method.

Root cause: when a derived store adopts a new backing while a transaction is still open, the whole container goes under an adoption hold, and the adoption-hold arm of readSource entered every deriving read of that container into the transaction, whichever key was read. The fold-hold gate gained per-key precision in #3688 through the trap's written-key record; the adoption hold had no such scoping. In the reproduction the open transaction comes from the two moves being started together, so the second had joined the first's transaction and the first's confirmation landed the authoritative array under a transaction that stayed pending. The memo read cards[0].id, a key the adoption did not change, was held anyway, and took the whole flush, including the drag write, with it.

Change: the adoption hold is key-scoped the way the fold hold is — the unit of a store hold is the key the transaction touched, not the container. adoptPB records the adopted object (heldKeys, a WeakMap keyed on the target; the target shape's named-field cap rules out a new field), and the keys that adoption changed against the held view are diffed against that object once, on the first held read, then replace the entry. readSource answers a get/has/descriptor read with one set lookup, the same has(key) shape as the fold gate's wk check, and a key the adoption left unchanged is served the backing for every reader (including context-free and children-forbidden ones) and is not born holding in getNode. "Changed" is decided once: own on both backings, never an accessor, same enumerability, the store's slot equality so a re-ingested row is the same logical slot. The diff runs on the first held read rather than inside adoptPB because a parent's adoption runs before its children are re-pointed and a diff taken there would call every re-ingested row changed; it runs against the adopted object rather than the live backing because a mainline setter write during the hold replaces the backing, and its key is not the adoption's. Diffing against the held view (the pre-hold committed backing) on each re-adoption gives the union directly: a key that changed and changed back reads the same from either backing. Keyless reads (ownKeys, $TRACK, deep()), chained backings, a chained held view, optimistic families and a swapped or non-plain prototype (inherited accessors read through this) hold the whole container. Descriptor enumerability and inherited accessors are covered, with pins; writable alone is not compared.

Finished on the branch (maintainer side)

The maintainer chose the recorded-keys variant (one has(key) rule shared with wk, plus the enumerability and inherited-accessor coverage) and accepted its size cost. Review found one blocker, fixed here rather than sent back:

  • Mainline writes during a hold were held with the adopting transaction. The record was recomputed whenever target.v changed identity, so it measured "changed since the hold began", not "changed by the adoption". With createStore(() => server(), …) adopting saved: true under an open action, setStore(s => void (s.stable = "edited")) left untrack(() => store.stable) at "same", and a memo reading ${n()}:${store.stable} made an unrelated n pending. Fixed by anchoring the record to the adopted object, consulting it on the born-holding path in getNode, and checking the key before readSource's context-free / children-forbidden arm. Three pins (tracked memo, untracked read, fresh node after the reader drops and re-reads the key) fail on the previous source; each of the three fix parts is load-bearing for at least one of them.
  • INTERNALS-STORE-STATE.md states the key scope of the adoption hold; docblock and comment fixes in store.ts; the third-shape expected-failure pins reference the Confirming one overlapping optimistic-store action holds an independent signal update #3706 follow-up.
  • Merged next (frames cap bump, published-declarations timeout fix) and re-measured the size ledger.

Public API changes

None.

Sizes

Final, Rolldown harness against next @ 7f9bd7a:

scenario next this PR cap
minified, all four — +578..582 B —
+ createStore 16,708 B 16,848 B (+140 B) 16.71 -> 16.85 KB
hydrating + every store 30,608 B 30,738 B (+130 B) 30.63 -> 30.74 KB
base page (frozen) 45,761 B 45,904 B (+143 B) 45.77 -> 45.91 KB
live page (frozen) 49,955 B 50,085 B (+130 B) 49.96 -> 50.09 KB

Core floor 0 B; every other scenario unchanged.

Variant comparison from the original investigation (against next @ fb4e637, before the anchoring fix):

scenario per-read compare recorded keys
minified, all four +327 B +554 B
+ createStore (16,708 B) +59 B, 16.77 KB +145 B, 16.86 KB
hydrating + every store (30,608 B) +88 B, 30.70 KB +146 B, 30.76 KB
base page (45,761 B) +107 B, 45.87 KB +89 B, 45.85 KB
live page (49,955 B) +50 B, 50.01 KB +203 B, 50.16 KB

The recorded-keys variant is the cheaper read path (one set lookup per held read against two hasOwn, up to four accessor probes and two WeakMap gets) and covers two more cases; the per-read variant is the smaller bundle.

Size-Exception: adoption-hold key record on the store read path, accepted by the maintainer — +578..582 B minified, brotli +130..+143 B across the four store-bearing scenarios; frozen page caps 45.77 -> 45.91 KB and 49.96 -> 50.09 KB.

Not covered, pinned as an expected failure and tracked as the #3706 follow-up: the third reproduction on the issue, one action writing the rows before its yield with the preview mounting afterwards, so the row is first read after the adoption inside a deriving memo (<Show when={cards.find(...)}>). It has no store target on either side, so slot equality cannot call it the same row and the container hold stands. I tried a positional lazy materialization (create the child under the parent's hold with the old raw as its held view, recognize it in sameLogicalSlot, seed the parent's slot node with the adopted raw); it passed the pins, but review reproduced three defects against it: a reorder of unread rows read [a, a] from a handler, cards[0] resolved to different proxies by reader context because committed-view readers wrap the old raw, and a child created under a latest() hold never cleared it. The same read from a render effect (a stale reader) publishes fine; only the deriving-memo form holds.

How did you test this change?

Signals pin packages/signals/tests/adoption-unchanged-key-read-3706.test.ts (19 cases: 17 passing, 2 expected failures). Passing: the issue shape, the cards.find form, a stale render effect, presence reads, a plain derived store, a mainline setter write during the held adoption in three forms (tracked memo, untracked read, fresh node), A29 contrasts for a changed key, a changed leaf of a lazily read row, an added or deleted key, a prototype swap, reordered unread rows, a shallow store, a descriptor read whose enumerability changed and an inherited accessor reading this, plus a no-store-read control. Expected failures: the third shape in both forms. Web specs: packages/web/test/optimistic-confirm-holds-signal-3706.spec.tsx (first playground as a memo x confirm-first matrix; only memo + confirm-first failed before), packages/web/test/optimistic-nested-show-drag-3706.spec.tsx (second playground; the find variant failed against the base store source, the plain-span control passed on both) and packages/web/test/optimistic-lazy-show-preview-3706.spec.tsx (third playground; find expected-fail, cards[0].id from the insert effect and a plain control pass).

pnpm build
cd packages/signals && npx vitest run            # 249 files, 4791 passed, 3 expected fail, 2 skipped
cd packages/solid && pnpm test && pnpm test-types # 811 passed
cd packages/web && pnpm test && pnpm test-types   # client 1101 + 1 expected fail, server 1351, hydrate 267
cd scripts/size && node size.mjs && SIZE_EXCEPTION="Size-Exception: …" node check-floor-caps.mjs origin/next
node packages/signals/scripts/rules-index.mjs

Five review rounds plus four codex exec review --base origin/next runs on the original branch, then a maintainer-side review. Findings that reproduced against the gate (a setter write to another key during the held adoption being hidden, prototype swaps, a mainline write during the hold being held) are fixed and pinned; the findings against the lazy materialization (reorder aliasing, split proxy identity, an uncleared latest-pull hold) are why it was dropped.

🤖 Generated with Claude Code; finished with Cursor

@changeset-bot

changeset-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8187029

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 Sep 29, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 185 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing brenelz:investigate/optimistic-confirm-holds-signal-3706 (8187029) with next (123353a)

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

@brenelz
brenelz force-pushed the investigate/optimistic-confirm-holds-signal-3706 branch 3 times, most recently from 4db98b4 to 207e88a Compare September 29, 2026 02:49
@ryansolid

Copy link
Copy Markdown
Member

Thanks for this. The diagnosis is right, and the direction matches a ruling from earlier today: when #3693 landed for #3688, the maintainer confirmed that the unit of a store hold is the key the transaction touched, not the container. The adoption hold missing that precision is the same inconsistency, so this is in scope and wanted.

The maintainer agrees with the alternative you offered at the end of the body, and would like the PR switched to it:

  • Record the changed keys once, at adoption. Make it an adoption-time twin of wk, unioned across re-adoptions under the same hold. readSource can then answer "did the hold touch this key?" with the same has(key) check for both the fold hold and the adoption hold, so the gate reads as one rule instead of two mechanisms.
  • Why: the per-read compare costs +327 B minified on the store read path (every store user pays it, and it raises both frozen page caps), and it runs on every held read. The recorded-keys version pays once per adoption and turns the read into a set lookup. "Which keys changed" is still decided by the same logic (own on both with slot equality, or absent on both with the same prototype); it just runs at adoption instead of on each read.
  • Side benefit worth checking: since the compare runs once per adoption, adding the descriptor-flags case there (which you measured at ~160 B on the read path) may now be affordable. Also check whether the inherited-accessor case can be excluded there cheaply. If either still isn't worth it, keeping them as documented limitations is fine.
  • Please post sizes for both variants (per-read compare vs recorded keys) under the Rolldown harness (scripts/size/size.mjs) against current next, so the choice is made on numbers. Keep the keyless-read exclusions (ownKeys, $TRACK, deep()), chained backings and optimistic families exactly as they are, and keep your A29 contrasts (changed key, added/deleted key, prototype swap, reordered unread rows) green.

On the third repro (a row first read after the adoption inside a deriving memo, <Show when={cards.find(...)}>): agreed that it's out of reach for this PR. As you found, freeing it needs the adoption itself to carry per-child held views, which effectively means reconciling rows by identity rather than swapping the backing. That's a larger design question about adoption and should be tracked separately. Keeping those expected-failure pins in this PR is useful; they'll mark the gap until it's decided. The lazy-materialization attempt and the three defects review found against it are good context for that follow-up, so please keep them in the body.

Once it's switched and measured, mark it ready and it'll get a full review.

— Claude via Cursor

…t hold the reader (solidjs#3706)

A derived store adopting a new backing while a transaction is still open
puts the whole container under an adoption hold, and readSource's
adoption-hold arm entered every deriving read of that container into the
transaction, whichever key was read. In the report, confirming one
optimistic move while another was pending landed the authoritative array
under the still-open shared transaction, and a memo reading
`drag() === cards[0].id` beside an independent `drag` signal was held until
the second move settled, so isPending(drag) went true. The second
reproduction (a nested <Show> preview over `cards.find(...)`) reaches the
same arm through an inherited array method.

The adoption hold is now key-scoped like the fold hold (solidjs#3688): the keys
an adoption changed against the held view are recorded once per adoption
(heldKeys, computed on the first held read so re-pointed children compare
as one slot: own on both backings, never an accessor, same enumerability,
the store's slot equality) and readSource answers a get/has/descriptor
read with one set lookup; a key the adoption left alone derives nothing
from the hold, no transaction entry and no stale replay. Keyless reads,
chained backings, a chained held view, optimistic families and a swapped
or non-plain prototype (inherited accessors read through `this`) hold the
whole container.

Not covered, pinned as an expected failure: a row first read after the
adoption inside a deriving memo (the issue's third reproduction) has no
target for slot equality, so the container hold stands.

Size: +554 B minified on the store read path (the per-read compare variant
measured +327 B); brotli +89..+203 B, four caps ratcheted (+ createStore
16.71 -> 16.86 KB, hydrating + every store 30.63 -> 30.76 KB, base page
45.77 -> 45.85 KB, live page 49.96 -> 50.16 KB).
@brenelz
brenelz force-pushed the investigate/optimistic-confirm-holds-signal-3706 branch from 207e88a to 1d62e4b Compare September 29, 2026 16:54
@brenelz
brenelz marked this pull request as ready for review September 29, 2026 16:54
@brenelz

brenelz commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Switched to the recorded-keys variant and rebased onto next @ fb4e637. Sizes for both under scripts/size/size.mjs against that base:

scenario per-read compare recorded keys (now in the PR)
minified, all four +327 B +554 B
+ createStore +59 B +145 B
hydrating + every store +88 B +146 B
base page +107 B +89 B
live page +50 B +203 B

The recorded-keys version is the cheaper read path (one set lookup per held read) and now covers descriptor enumerability and inherited accessors, both pinned; the per-read version is the smaller bundle. One note on placement: the record is computed on the first held read after each adoption rather than inside adoptPB, because a parent's adoption runs before its children are re-pointed, and a diff taken there calls every re-ingested row changed. Diffing against the held view on each re-adoption gives the union directly. Exclusions and the A29 contrasts are unchanged and green; the third-shape pins stay as expected failures with the materialization write-up in the body. Marked ready.

ryansolid and others added 3 commits September 29, 2026 12:41
…tes since the hold began (solidjs#3706)

The record of keys an adoption changed was recomputed whenever the target's
backing changed identity. A mainline setter write during the hold folds on
the clone path and replaces the backing, so the key it wrote was counted as
the adoption's and held with the adopting transaction: untracked reads kept
returning the pre-hold value, and a memo reading the key made an independent
signal pending.

- adoptPB records the adopted object; the keys are diffed against that
  object, once, on the first held read (its children are re-pointed by then,
  so slot equality holds), and replace the entry, so the record no longer
  retains a backing once computed.
- getNode's born-holding path consults the record: a fresh node for a key
  the adoption left unchanged is not born holding.
- readSource checks the key before the context-free / children-forbidden
  arm, so every reader of an unchanged key is served the backing.

Pins for the mainline-write case in three forms (tracked memo, untracked
read, a fresh node after the reader drops and re-reads the key); each fails
on the previous source. INTERNALS-STORE-STATE states the key scope of the
adoption hold. Docblock and comment fixes. The third-shape expected-failure
pins reference the solidjs#3706 follow-up.

Size ledger re-measured against next @ 7f9bd7a (+578..582 B minified on
the store read path); the frozen page caps rise to 45.91 / 50.09 KB,
accepted by the maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid

Copy link
Copy Markdown
Member

Thanks @brenelz — this was a careful investigation, and the write-up (the third shape and the materialization attempt especially) made the review straightforward.

Ryan went with the recorded-keys variant: it gives the store one has(key) rule shared with the fold hold's wk record, and it covers enumerability and inherited accessors. He accepted the size cost. Our earlier size prediction was wrong, so that call was his to make on the real numbers. We finished the PR on your branch instead of sending it back:

  • Anchoring fix for mainline writes during a hold. The key record was recomputed whenever the backing changed identity, and a mainline setter write folds on the clone path, which replaces it. So the record measured "changed since the hold began", not "changed by the adoption". Repro: while an action's adoption of saved: true is held, setStore(s => void (s.stable = "edited")) left untrack(() => store.stable) at "same", and a memo reading ${n()}:${store.stable} made n pending. adoptPB now records the adopted object and the keys are diffed against that object on the first held read. getNode's born-holding path consults the record, and readSource checks the key before its context-free / children-forbidden arm.
  • Three pins for that case: a tracked memo, an untracked read, and a fresh node after the reader drops the key and reads it again. All three fail on the previous source, and each part of the fix is needed by at least one of them.
  • INTERNALS-STORE-STATE.md now says the adoption hold is scoped to keys. Also fixed the readSource docblock (heldKey, not adoptionKeyUnchanged) and the record comment (it's keyed on the target). The record no longer holds on to a backing once its keys are computed.
  • The third-shape it.fails pins now point to "the Confirming one overlapping optimistic-store action holds an independent signal update #3706 follow-up". We'll open a tracking issue for it.
  • Merged next rather than rebasing, and re-measured the ledger against next @ 7f9bd7a: +578..582 B minified, + createStore 16.85 KB, hydrating+stores 30.74 KB, and frozen page caps at 45.91 / 50.09 KB. The PR body has the matching Size-Exception: line and the corrected case count (19: 17 pass, 2 expected fail).

CI notes: the earlier size failure was the old frames cap, which is fixed on next. compare can fail only because the fork token gets a 403 when posting the size comment. The job timeout in published-declarations.spec.ts is fixed on next (123353a) and is merged in here.

— Claude via Cursor

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit d281b4f into solidjs:next Sep 29, 2026
6 of 7 checks passed
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