Skip to content

Commit 4db98b4

Browse files
committed
fix(signals): unchanged store key read under an adoption hold does not hold the reader (#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 other two reproductions (a nested <Show> preview over `cards.find(...)`, and a single action writing rows before its yield with the preview mounting afterwards) reach the same arm through an inherited array method and through a row first read after the adoption. The arm now compares the read key across the held and adopted backings (own on both, never an accessor, the store's slot equality so a re-ingested row is the same logical slot; or own on neither with the same prototype). A child slot nobody read before the adoption has no target to compare, so it is materialized under the parent's hold with the old raw as its held view and its own leaves decide: sameLogicalSlot recognizes such a child through that held view, and the parent's slot node is seeded with the adopted raw so the reader is served that child. A key that reads the same from either backing derives nothing from the hold and takes the ordinary pending-backing selection with no transaction entry and no stale replay. This is the adoption twin of the fold gate's written-key scoping (#3688). Keyless reads, chained backings, a chained held view, optimistic families, shallow stores, raw-marked values, relocated rows and descriptor reads whose flags changed keep the whole-container hold. Size: +765 B minified on the store read path; brotli +149..+224 B, four caps ratcheted (+ createStore 16.71 -> 16.94 KB, hydrating + every store 30.63 -> 30.80 KB, base page 44.93 -> 45.02 KB, live page 49.10 -> 49.28 KB).
1 parent 6958367 commit 4db98b4

9 files changed

Lines changed: 853 additions & 16 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/signals": patch
3+
---
4+
5+
Reading a store key a derived store's adoption left unchanged no longer holds the reader with the transaction that adopted it (#3706). Confirming one optimistic move while another was pending landed the authoritative array on the derived base store under the still-open transaction, and the adoption-hold arm of `readSource` entered every deriving read of that container into the transaction: a memo reading `drag() === cards[0].id` beside an independent `drag` signal was held until the second move settled, and `isPending(drag)` went true. The arm now compares the key across the held and adopted backings with the store's slot equality (a re-ingested row is the same logical slot): a key that reads the same from either is served with no entry and no stale replay, the adoption twin of the fold gate's written-key scoping (#3688). Keyless reads (`ownKeys`, `$TRACK`, `deep()`), chained backings and optimistic families keep the whole-container hold.

‎packages/signals/docs/RULES-INDEX.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
7373
| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 scheduler.ts×1 store.ts×1 | action-await-contract.test.ts×2 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… |
7474
| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:245` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… |
7575
| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×2 core.ts×20 optimistic.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 store.ts×1 | createOptimistic.test.ts×5 held-derivation-not-a-proposal-3612.test.ts×1 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… |
76-
| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | boundaries.ts×1 action.ts×1 async.ts×1 constants.ts×1 core.ts×8 effect.ts×1 optimistic.ts×1 scheduler.ts×2 signals.ts×1 store.ts×5 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 latest-held-till-flush.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-store-parity.test.ts×6 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … |
76+
| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | boundaries.ts×1 action.ts×1 async.ts×1 constants.ts×1 core.ts×8 effect.ts×1 optimistic.ts×1 scheduler.ts×2 signals.ts×1 store.ts×5 | adoption-unchanged-key-read-3706.test.ts×7 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 latest-held-till-flush.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-store-parity.test.ts×6 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … |
7777
| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×3 effect.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… |
7878
| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:83` | core.ts×2 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … |
7979
| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:91` | core.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… |

‎packages/signals/src/store/next/store.ts‎

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,13 @@ export function getNode(
310310
// committed is the held view `hv[key]`, staged is `v[key]`.
311311
const fold = heldFoldTransition(target);
312312
let held = heldAdoptionTransition(target);
313-
if (held !== null) current = (target.hv as any)[key];
314-
else if ((held = fold) !== null) current = (target.v as any)[key];
313+
if (held !== null) {
314+
current = (target.hv as any)[key];
315+
// A slot-equal adopted raw is the one to wrap: a child materialized
316+
// under the hold is registered for it alone (#3706).
317+
const nv = (target.v as any)[key];
318+
if (sameLogicalSlot(target, current, nv)) current = nv;
319+
} else if ((held = fold) !== null) current = (target.v as any)[key];
315320
// Create-floor diet: slotSignal bakes the whole node into one literal —
316321
// no options object, no equals/unobserved closures, no NodeExtension,
317322
// no post-construction expandos (acc + the wrap cache px/pxv are
@@ -437,6 +442,47 @@ function holdVisible(txn: Transition | null, c: Computed<any>): boolean {
437442
return true;
438443
}
439444

445+
// #3706: the adoption twin of the fold gate's written-key scoping (#3688).
446+
function adoptionKeyUnchanged(target: StoreNextTarget, key: PropertyKey, desc?: boolean): boolean {
447+
const hv = target.hv!;
448+
const v = target.v;
449+
if (target.ch || target.fam?.opt === true || (hv as any)[$TARGET] !== undefined) return false;
450+
const own = hasOwn.call(hv, key);
451+
if (own !== hasOwn.call(v, key)) return false;
452+
// Absent on both (a prototype method, an out-of-range index) reads alike.
453+
if (!own) return Object.getPrototypeOf(hv) === Object.getPrototypeOf(v);
454+
// Accessors are never invoked here; a descriptor read compares the flags too.
455+
if (isOwnAccessor(hv, key) || isOwnAccessor(v, key)) return false;
456+
if (desc) {
457+
const od = Object.getOwnPropertyDescriptor(hv, key)!;
458+
const nd = Object.getOwnPropertyDescriptor(v, key)!;
459+
if (od.enumerable !== nd.enumerable || od.writable !== nd.writable) return false;
460+
}
461+
const a = hv[key as any];
462+
const b = v[key as any];
463+
if (isEqual(a, b) || sameLogicalSlot(target, a, b)) return true;
464+
// A child slot nobody read before the adoption has no target to compare:
465+
// materialize it under the parent's hold so its own leaves decide (#3706).
466+
// Never for a shallow store or a raw-marked value (the reference IS the
467+
// value there), and never across kinds; a relocated row keeps its hold.
468+
if (
469+
target.s ||
470+
!isWrappable(a) ||
471+
!isWrappable(b) ||
472+
Array.isArray(a) !== Array.isArray(b) ||
473+
(a as any)[$TARGET] !== undefined ||
474+
(b as any)[$TARGET] !== undefined ||
475+
(rawValuesUsed && (isRawValue(a) || isRawValue(b))) ||
476+
lookupTarget(a, target.fam) !== undefined ||
477+
lookupTarget(b, target.fam) !== undefined
478+
)
479+
return false;
480+
const child = createTarget(b, target, key, target.fam);
481+
child.hv = a;
482+
child.ht = target.ht;
483+
return true;
484+
}
485+
440486
function stageHeldKey(node: Signal<any>, nv: any, txn: Transition): void {
441487
if (slotNodeEquals.call(node, node._value, nv)) return;
442488
node._pendingValue = nv;
@@ -448,7 +494,10 @@ function stageHeldKey(node: Signal<any>, nv: any, txn: Transition): void {
448494
function sameLogicalSlot(target: StoreNextTarget, a: any, b: any): boolean {
449495
if (a === null || typeof a !== "object" || b === null || typeof b !== "object") return false;
450496
const at = lookupTarget(a, target.fam);
451-
return at !== undefined && at === lookupTarget(b, target.fam);
497+
const bt = lookupTarget(b, target.fam);
498+
// A child materialized under its parent's hold keeps the old raw as its
499+
// held view (#3706): the slot it was read at, not a relocation.
500+
return at === undefined ? bt !== undefined && bt.hv === a : at === bt;
452501
}
453502

454503
/**
@@ -1785,8 +1834,13 @@ function foldHeld(target: StoreNextTarget): boolean {
17851834
}
17861835

17871836
/** The backing a reader is served. `key` (the get/has/descriptor traps)
1788-
* scopes a fold hold to the keys the fold touched — see pendingBackingVisible. */
1789-
function readSource(target: StoreNextTarget, key?: PropertyKey): Record<PropertyKey, any> {
1837+
* scopes a fold hold to the keys the fold touched — see pendingBackingVisible —
1838+
* and an adoption hold to the keys the adoption changed (adoptionKeyUnchanged). */
1839+
function readSource(
1840+
target: StoreNextTarget,
1841+
key?: PropertyKey,
1842+
desc?: boolean
1843+
): Record<PropertyKey, any> {
17901844
// Adoption hold first (#3074): an adoption staged under a live transaction
17911845
// (or a latest()-pull, PLAIN_HOLD) serves the pre-hold committed view to
17921846
// committed-visibility readers — context-free and children-forbidden ones,
@@ -1797,11 +1851,11 @@ function readSource(target: StoreNextTarget, key?: PropertyKey): Record<Property
17971851
const hv = heldMaskView(target);
17981852
if (hv !== null) {
17991853
const c = readerContext();
1800-
if (
1801-
c === null ||
1802-
c._config & CONFIG_CHILDREN_FORBIDDEN ||
1803-
!holdVisible(ht === PLAIN_HOLD ? null : currentTransition(ht as Transition), c)
1804-
)
1854+
if (c === null || c._config & CONFIG_CHILDREN_FORBIDDEN) return hv;
1855+
// A key the adoption left unchanged derives nothing from the hold (#3706).
1856+
if (key !== undefined && adoptionKeyUnchanged(target, key, desc))
1857+
return pendingBackingVisible(target, false, key) ? target.pb! : target.v;
1858+
if (!holdVisible(ht === PLAIN_HOLD ? null : currentTransition(ht as Transition), c))
18051859
return hv;
18061860
}
18071861
}
@@ -2444,7 +2498,7 @@ const traps: ProxyHandler<StoreNextTarget> = {
24442498
if (pendingCheckActive) witnessAffectsMark(target as any, key);
24452499
const obs = getObserver();
24462500
if (target.fam !== null && obs === null && !inDraft(target)) firewallGate(target);
2447-
const src = readSource(target, key);
2501+
const src = readSource(target, key, true);
24482502
const desc = visibleDescriptor(target, src, key);
24492503
if (!inDraft(target) && obs !== null && !observerHoldsKeySet(target, obs)) {
24502504
// The node is born from the source's presence (as `has` births it),

0 commit comments

Comments
 (0)