diff --git a/.changeset/fix-store-adoption-unchanged-key-read-hold.md b/.changeset/fix-store-adoption-unchanged-key-read-hold.md new file mode 100644 index 000000000..1f5bdb7a6 --- /dev/null +++ b/.changeset/fix-store-adoption-unchanged-key-read-hold.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +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 adoption hold is now key-scoped like the fold hold (#3688): the keys an adoption changed against the held view are recorded once per adoption (own on both backings, never an accessor, same enumerability, the store's slot equality so a re-ingested row is the same logical slot) and a get/has/descriptor read of any other key is served with no entry and no stale replay. Keyless reads (`ownKeys`, `$TRACK`, `deep()`), chained backings, optimistic families and a swapped or non-plain prototype keep the whole-container hold. diff --git a/packages/signals/docs/INTERNALS-STORE-STATE.md b/packages/signals/docs/INTERNALS-STORE-STATE.md index 789b5e5b3..42ad9c2c6 100644 --- a/packages/signals/docs/INTERNALS-STORE-STATE.md +++ b/packages/signals/docs/INTERNALS-STORE-STATE.md @@ -173,7 +173,16 @@ there is nothing to diff.) staged under the transaction — the adoption's notification ran before the node existed and the drain has nothing left to say. (The #3336 PR adds the same for setter-staged `pb` holds and makes the first tracked read serve - the node's value; the two compose.) + the node's value; the two compose.) The unit of the hold is the **key** + the adoption changed, not the container (#3706; the fold hold's `wk` rule, + #3688): `adoptPB` records the adopted object, and the keys it changed + against `hv` are computed once, on the first held read (own on both, never + an accessor, same enumerability, slot equality) — so a mainline setter + write during the hold, which replaces the backing, is never counted as the + adoption's. A get/has/descriptor read of any other key is served from the + backing to every reader and is not born holding. Keyless reads (`ownKeys`, + `$TRACK`, `deep()`), chained backings, optimistic families and a swapped + or non-plain prototype hold the whole container. ## 4. Identity rules diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 62f0a0255..92a45ec4b 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -73,7 +73,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | 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… | | 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… | | 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… | -| 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 … | +| 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×9 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 … | | 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… | | 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 … | | 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… | @@ -124,19 +124,19 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | |---|---|---|---|---|---| -| RUL-1 | resolved | `docs/INTERNALS-STORE-STATE.md:512` | store.ts×2 target.ts×1 | next-smoke.test.ts×1 | - **RUL-1 — RESOLVED (2026-08-16, verified per "mirror signals if verified" — | -| RUL-2 | ruled | `docs/INTERNALS-STORE-STATE.md:521` | optimistic.ts×1 target.ts×1 | createOptimisticStore.test.ts×1 | - **RUL-2 — RULED (Ryan, 2026-08-17): no landing matrix. Two orthogonal | -| RUL-3 | resolved | `docs/INTERNALS-STORE-STATE.md:620` | optimistic.ts×1 target.ts×1 | — | - **RUL-3 — RESOLVED (verified 2026-08-16).** Ownership already lives | -| RUL-4 | resolved | `docs/INTERNALS-STORE-STATE.md:627` | — | optimistic-signal-refetch-hold.test.ts×1 | - **RUL-4 — RESOLVED (2026-08-17, signal parity — verified empirically).** | -| RUL-5 | live | `docs/INTERNALS-STORE-STATE.md:640` | reconcile.ts×1 | adoption-lane-rollback.test.ts×1 | - **RUL-5 — SPEC'D (2026-08-17): §6b.** Lane backing + lane-view/committed- | -| RUL-6 | live | `docs/INTERNALS-STORE-STATE.md:644` | — | — | - **RUL-6 — reclassified: SPEC WORK, not a ruling.** Live chaining (#2941 — | -| RUL-7 | live | `docs/INTERNALS-STORE-STATE.md:653` | — | — | - **RUL-7 — SPEC'D (2026-08-17): §6c.** One status field on the root target, | -| RUL-8 | live | `docs/INTERNALS-STORE-STATE.md:656` | — | adoption-lane-rollback.test.ts×1 | - **RUL-8 — SPEC'D (2026-08-17): §6 rewrite, resolves O2.** Per-transaction | -| RUL-9 | resolved | `docs/INTERNALS-STORE-STATE.md:659` | — | — | - **RUL-9 — RESOLVED (2026-08-17, parity by construction).** Every piece of | -| RUL-10 | live | `docs/INTERNALS-STORE-STATE.md:664` | optimistic.ts×1 | — | - **RUL-10 — The equality trio.** One precise rule needed spanning: no-op | -| RUL-11 | live | `docs/INTERNALS-STORE-STATE.md:671` | — | — | - **RUL-11 — SPEC'D (2026-08-17): §6d.** Sticky descendants flag ported from | -| RUL-12 | live | `docs/INTERNALS-STORE-STATE.md:673` | optimistic.ts×1 reconcile.ts×1 store.ts×2 | createProjection.async.test.ts×1 shared-child-multiparent.test.ts×1 | - **RUL-12 — Smaller rulings, each with a proposed default** (proceeding on | -| RUL-13 | resolved | `docs/INTERNALS-STORE-STATE.md:719` | — | — | - **RUL-13 — RESOLVED (verified 2026-08-16)**: `optimistic-lane-transaction- | +| RUL-1 | resolved | `docs/INTERNALS-STORE-STATE.md:521` | store.ts×2 target.ts×1 | next-smoke.test.ts×1 | - **RUL-1 — RESOLVED (2026-08-16, verified per "mirror signals if verified" — | +| RUL-2 | ruled | `docs/INTERNALS-STORE-STATE.md:530` | optimistic.ts×1 target.ts×1 | createOptimisticStore.test.ts×1 | - **RUL-2 — RULED (Ryan, 2026-08-17): no landing matrix. Two orthogonal | +| RUL-3 | resolved | `docs/INTERNALS-STORE-STATE.md:629` | optimistic.ts×1 target.ts×1 | — | - **RUL-3 — RESOLVED (verified 2026-08-16).** Ownership already lives | +| RUL-4 | resolved | `docs/INTERNALS-STORE-STATE.md:636` | — | optimistic-signal-refetch-hold.test.ts×1 | - **RUL-4 — RESOLVED (2026-08-17, signal parity — verified empirically).** | +| RUL-5 | live | `docs/INTERNALS-STORE-STATE.md:649` | reconcile.ts×1 | adoption-lane-rollback.test.ts×1 | - **RUL-5 — SPEC'D (2026-08-17): §6b.** Lane backing + lane-view/committed- | +| RUL-6 | live | `docs/INTERNALS-STORE-STATE.md:653` | — | — | - **RUL-6 — reclassified: SPEC WORK, not a ruling.** Live chaining (#2941 — | +| RUL-7 | live | `docs/INTERNALS-STORE-STATE.md:662` | — | — | - **RUL-7 — SPEC'D (2026-08-17): §6c.** One status field on the root target, | +| RUL-8 | live | `docs/INTERNALS-STORE-STATE.md:665` | — | adoption-lane-rollback.test.ts×1 | - **RUL-8 — SPEC'D (2026-08-17): §6 rewrite, resolves O2.** Per-transaction | +| RUL-9 | resolved | `docs/INTERNALS-STORE-STATE.md:668` | — | — | - **RUL-9 — RESOLVED (2026-08-17, parity by construction).** Every piece of | +| RUL-10 | live | `docs/INTERNALS-STORE-STATE.md:673` | optimistic.ts×1 | — | - **RUL-10 — The equality trio.** One precise rule needed spanning: no-op | +| RUL-11 | live | `docs/INTERNALS-STORE-STATE.md:680` | — | — | - **RUL-11 — SPEC'D (2026-08-17): §6d.** Sticky descendants flag ported from | +| RUL-12 | live | `docs/INTERNALS-STORE-STATE.md:682` | optimistic.ts×1 reconcile.ts×1 store.ts×2 | createProjection.async.test.ts×1 shared-child-multiparent.test.ts×1 | - **RUL-12 — Smaller rulings, each with a proposed default** (proceeding on | +| RUL-13 | resolved | `docs/INTERNALS-STORE-STATE.md:728` | — | — | - **RUL-13 — RESOLVED (verified 2026-08-16)**: `optimistic-lane-transaction- | ## R — core-store (`CS-R`) | id | status | defined | cited in src | cited in tests | statement (at definition) | @@ -381,19 +381,19 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | §1 | live | `docs/INTERNALS-STORE-STATE.md:27` | target.ts×1 | reveal-gating-contract.test.ts×1 | Storage model (the single-home rule) | | §2 | live | `docs/INTERNALS-STORE-STATE.md:81` | — | — | Read paths | | §3 | live | `docs/INTERNALS-STORE-STATE.md:116` | scheduler.ts×1 optimistic.ts×1 reconcile.ts×1 store.ts×1 target.ts×1 | — | Write paths (all must stay equivalent) | -| §4 | live | `docs/INTERNALS-STORE-STATE.md:178` | — | — | Identity rules | -| §5 | live | `docs/INTERNALS-STORE-STATE.md:190` | — | — | Laziness invariants (candidates for `__TEST__` assertions) | -| §5b | live | `docs/INTERNALS-STORE-STATE.md:208` | target.ts×2 | — | Creation budget (phase-1 fitness) | -| §5c | live | `docs/INTERNALS-STORE-STATE.md:227` | — | — | Comparison method (shipped vs rewrite) | -| §6 | live | `docs/INTERNALS-STORE-STATE.md:278` | core.ts×1 invariants.ts×1 lanes.ts×1 optimistic.ts×1 store.ts×2 target.ts×1 | — | Structural edits — the key-set node (resolves O2, RUL-8) | -| §6b | live | `docs/INTERNALS-STORE-STATE.md:306` | reconcile.ts×2 | adoption-lane-rollback.test.ts×1 | Lane-aware adoption (RUL-5) | -| §6c | live | `docs/INTERNALS-STORE-STATE.md:325` | projection.ts×2 store.ts×1 | createProjection.async.test.ts×1 flight-owned-transaction.test.ts×1 | Store-wide status gating (RUL-7) | -| §6d | live | `docs/INTERNALS-STORE-STATE.md:338` | reconcile.ts×1 target.ts×2 | — | Diff reachability (RUL-11) | -| §7 | live | `docs/INTERNALS-STORE-STATE.md:350` | optimistic.ts×1 projection.ts×1 | — | Projections & optimism layering | -| §7b | live | `docs/INTERNALS-STORE-STATE.md:360` | optimistic.ts×2 projection.ts×1 reconcile.ts×1 store.ts×11 target.ts×3 store.ts×1 | optimistic-chained-revert-3672-memo.test.ts×1 | Chained backing (cross-store) — spec | -| §8 | live | `docs/INTERNALS-STORE-STATE.md:431` | — | reconcile-resend-identity.test.ts×1 | Assumptions / open questions | -| §8b | live | `docs/INTERNALS-STORE-STATE.md:487` | — | — | Suite-mined rules (2026-08-16) — index & rulings needed | -| §9 | live | `docs/INTERNALS-STORE-STATE.md:724` | — | — | Decision log | +| §4 | live | `docs/INTERNALS-STORE-STATE.md:187` | — | — | Identity rules | +| §5 | live | `docs/INTERNALS-STORE-STATE.md:199` | — | — | Laziness invariants (candidates for `__TEST__` assertions) | +| §5b | live | `docs/INTERNALS-STORE-STATE.md:217` | target.ts×2 | — | Creation budget (phase-1 fitness) | +| §5c | live | `docs/INTERNALS-STORE-STATE.md:236` | — | — | Comparison method (shipped vs rewrite) | +| §6 | live | `docs/INTERNALS-STORE-STATE.md:287` | core.ts×1 invariants.ts×1 lanes.ts×1 optimistic.ts×1 store.ts×2 target.ts×1 | — | Structural edits — the key-set node (resolves O2, RUL-8) | +| §6b | live | `docs/INTERNALS-STORE-STATE.md:315` | reconcile.ts×2 | adoption-lane-rollback.test.ts×1 | Lane-aware adoption (RUL-5) | +| §6c | live | `docs/INTERNALS-STORE-STATE.md:334` | projection.ts×2 store.ts×1 | createProjection.async.test.ts×1 flight-owned-transaction.test.ts×1 | Store-wide status gating (RUL-7) | +| §6d | live | `docs/INTERNALS-STORE-STATE.md:347` | reconcile.ts×1 target.ts×2 | — | Diff reachability (RUL-11) | +| §7 | live | `docs/INTERNALS-STORE-STATE.md:359` | optimistic.ts×1 projection.ts×1 | — | Projections & optimism layering | +| §7b | live | `docs/INTERNALS-STORE-STATE.md:369` | optimistic.ts×2 projection.ts×1 reconcile.ts×1 store.ts×11 target.ts×3 store.ts×1 | optimistic-chained-revert-3672-memo.test.ts×1 | Chained backing (cross-store) — spec | +| §8 | live | `docs/INTERNALS-STORE-STATE.md:440` | — | reconcile-resend-identity.test.ts×1 | Assumptions / open questions | +| §8b | live | `docs/INTERNALS-STORE-STATE.md:496` | — | — | Suite-mined rules (2026-08-16) — index & rulings needed | +| §9 | live | `docs/INTERNALS-STORE-STATE.md:733` | — | — | Decision log | | §11 | live | `docs/NODE-SHAPE.md:29` | — | — | Stage 3 opener: the core tax map (2026-08-21) | | §11b | live | `docs/NODE-SHAPE.md:53` | constants.ts×1 | rules-index.test.ts×1 treeshake.test.ts×1 | Presence bits — hot-path monomorphism | | §11c | live | `docs/NODE-SHAPE.md:72` | — | — | Stage-3 increment log | diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 8fde4ae9f..cb59e8681 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -310,6 +310,8 @@ export function getNode( // committed is the held view `hv[key]`, staged is `v[key]`. const fold = heldFoldTransition(target); let held = heldAdoptionTransition(target); + // A key the adoption left unchanged is not born holding (#3706). + if (held !== null && !heldKey(target, key)) held = null; if (held !== null) current = (target.hv as any)[key]; else if ((held = fold) !== null) current = (target.v as any)[key]; // Create-floor diet: slotSignal bakes the whole node into one literal — @@ -437,6 +439,51 @@ function holdVisible(txn: Transition | null, c: Computed): boolean { return true; } +// #3706: the keys an adoption under a live hold changed against the held +// view — the adoption twin of `wk` (#3688), keyed on the target. adoptPB +// records `[adopted object]`; the keys are diffed against IT, once, on the +// first held read (its children are re-pointed by then), and replace the +// entry. Never against the live backing — a mainline write during the hold +// replaces that, and its key is not the adoption's. WK_ALL holds the whole +// container. +const heldKeys = new WeakMap>(); + +function heldKey(target: StoreNextTarget, key: PropertyKey): boolean { + let keys = heldKeys.get(target)!; + if (Array.isArray(keys)) heldKeys.set(target, (keys = adoptionChangedKeys(target, keys[0]))); + return keys === WK_ALL || keys.has(key); +} + +function adoptionChangedKeys(target: StoreNextTarget, v: any): Set { + const hv = target.hv!; + // A chained backing, an optimistic family, a chained held view, a swapped + // or non-plain prototype (inherited accessors read through `this`): whole. + if ( + v[$TARGET] !== undefined || + target.fam?.opt === true || + (hv as any)[$TARGET] !== undefined || + Object.getPrototypeOf(hv) !== Object.getPrototypeOf(v) || + !plainProto(v) + ) + return WK_ALL; + const keys = new Set(); + // Accessors are never invoked: they and a flipped enumerability are changes. + for (const key of Reflect.ownKeys(hv)) + if ( + !hasOwn.call(v, key) || + isOwnAccessor(hv, key) || + isOwnAccessor(v, key) || + propertyIsEnumerable.call(hv, key) !== propertyIsEnumerable.call(v, key) || + !( + isEqual(hv[key as any], v[key as any]) || + sameLogicalSlot(target, hv[key as any], v[key as any]) + ) + ) + keys.add(key); + for (const key of Reflect.ownKeys(v)) if (!hasOwn.call(hv, key)) keys.add(key); + return keys; +} + function stageHeldKey(node: Signal, nv: any, txn: Transition): void { if (slotNodeEquals.call(node, node._value, nv)) return; node._pendingValue = nv; @@ -942,6 +989,7 @@ export function adoptPB( if (heldMaskView(target) === null) target.hv = target.v; target.ht = activeTransition ?? PLAIN_HOLD; if (!eager && activeTransition !== null) heldAdoptions.add(target); + heldKeys.set(target, [incoming]); } target.pb = null; // Overlay and accessor-scan state describe the OUTGOING backing — a @@ -1785,7 +1833,8 @@ function foldHeld(target: StoreNextTarget): boolean { } /** The backing a reader is served. `key` (the get/has/descriptor traps) - * scopes a fold hold to the keys the fold touched — see pendingBackingVisible. */ + * scopes a fold hold to the keys the fold touched — see pendingBackingVisible — + * and an adoption hold to the keys the adoption changed (heldKey). */ function readSource(target: StoreNextTarget, key?: PropertyKey): Record { // Adoption hold first (#3074): an adoption staged under a live transaction // (or a latest()-pull, PLAIN_HOLD) serves the pre-hold committed view to @@ -1795,7 +1844,9 @@ function readSource(target: StoreNextTarget, key?: PropertyKey): Record new Promise(r => setTimeout(r, 0)); + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise(r => (resolve = r)); + return { promise, resolve }; +} + +function board(read: (drag: () => string | undefined, cards: Card[]) => unknown) { + const gates = new Map>(); + let move!: (id: string, column: number) => Promise; + let setDrag!: (v: string) => void; + let drag!: () => string | undefined; + let derived!: () => unknown; + const views: string[] = []; + const dispose = createRoot(d => { + const [server, setServer] = createSignal([ + { id: "0", column: 0 }, + { id: "1", column: 1 } + ]); + const [base] = createStore(() => server(), []); + const [cards, setCards] = createOptimisticStore(base); + [drag, setDrag] = createSignal(); + derived = createMemo(() => read(drag, cards)); + move = action(function* (id: string, column: number) { + setCards(draft => { + draft.find(item => item.id === id)!.column = column; + }); + const gate = deferred(); + gates.set(id, gate); + yield gate.promise; + setServer(rows => rows.map(row => (row.id === id ? { ...row, column } : row))); + }); + createRenderEffect( + () => + `drag: ${drag() ?? "unset"} latest: ${latest(drag) ?? "unset"} pending: ${isPending(drag)}`, + v => void views.push(v) + ); + createRenderEffect(derived, () => {}); + return d; + }); + flush(); + return { + move, + confirm: (id: string) => gates.get(id)!.resolve(), + setDrag, + drag, + derived, + views, + dispose + }; +} + +describe("#3706 unchanged key read under an adoption hold", () => { + it("an independent sync write publishes while a second overlapping move is pending", async () => { + const b = board((drag, cards) => drag() === cards[0].id); + expect(b.views).toEqual(["drag: unset latest: unset pending: false"]); + + const first = b.move("0", 1); + const second = b.move("1", 2); + b.confirm("0"); + await first; + await tick(); + + // A separate interaction, unrelated to the pending move. + b.setDrag("0"); + flush(); + expect(b.drag()).toBe("0"); + expect(latest(b.drag)).toBe("0"); + expect(isPending(b.drag)).toBe(false); + expect(b.derived()).toBe(true); + expect(b.views.at(-1)).toBe("drag: 0 latest: 0 pending: false"); + + b.confirm("1"); + await second; + flush(); + expect(b.derived()).toBe(true); + expect(b.views.at(-1)).toBe("drag: 0 latest: 0 pending: false"); + b.dispose(); + }); + + it("an inherited array method read (`cards.find`) derives nothing from the hold either", async () => { + const b = board((drag, cards) => cards.find(c => c.id === drag()) !== undefined); + const first = b.move("0", 1); + const second = b.move("1", 2); + b.confirm("0"); + await first; + await tick(); + b.setDrag("0"); + flush(); + expect(isPending(b.drag)).toBe(false); + expect(b.derived()).toBe(true); + b.confirm("1"); + await second; + b.dispose(); + }); + + it("a stale pass (render effect) reading the unchanged key beside a changed one keeps its replay", async () => { + const gate = deferred(); + const [server, setServer] = createSignal({ saved: false, stable: "same" }); + const [store] = createStore(() => server(), { saved: false, stable: "same" }); + const [enabled, setEnabled] = createSignal(false); + const seen: string[] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + createRenderEffect( + () => `${enabled()}:${store.stable}:${store.saved}`, + v => void seen.push(v) + ); + }); + flush(); + const save = action(function* save() { + setServer(s => ({ ...s, saved: true })); + yield gate.promise; + }); + const p = save(); + flush(); + setEnabled(true); + flush(); + // The independent write publishes with the committed frame; the changed + // key stays committed and the effect is replayed at the action's settle. + expect(seen.at(-1)).toBe("true:same:false"); + expect(isPending(enabled)).toBe(false); + gate.resolve(); + await p; + flush(); + expect(seen.at(-1)).toBe("true:same:true"); + dispose(); + }); + + it("presence reads (`in`, descriptor) and an out-of-range index take the same gate", async () => { + const gate = deferred(); + const [server, setServer] = createSignal([{ id: "0", column: 0 }]); + const [store] = createStore(() => server(), []); + const [enabled, setEnabled] = createSignal(false); + let derived!: () => string; + let dispose!: () => void; + createRoot(d => { + dispose = d; + derived = createMemo( + () => + `${enabled()}:${"id" in store[0]}:${Object.getOwnPropertyDescriptor(store[0], "id")?.value}:${store[5] === undefined}` + ); + createRenderEffect(derived, () => {}); + }); + flush(); + const save = action(function* save() { + setServer(rows => rows.map(row => ({ ...row, column: 1 }))); + yield gate.promise; + }); + const p = save(); + flush(); + setEnabled(true); + flush(); + expect(derived()).toBe("true:true:0:true"); + expect(isPending(enabled)).toBe(false); + gate.resolve(); + await p; + }); + + it("contrast: a key the adoption ADDED or DELETED still holds the reader (A29)", async () => { + for (const shape of ["added", "deleted"] as const) { + const gate = deferred(); + const [server, setServer] = createSignal<{ extra?: number }>( + shape === "added" ? {} : { extra: 1 } + ); + const [store] = createStore<{ extra?: number }>(() => server(), {}); + const [enabled, setEnabled] = createSignal(false); + let derived!: () => string; + let dispose!: () => void; + createRoot(d => { + dispose = d; + derived = createMemo(() => `${enabled()}:${"extra" in store}`); + createRenderEffect(derived, () => {}); + }); + flush(); + const save = action(function* save() { + setServer(shape === "added" ? { extra: 1 } : {}); + yield gate.promise; + }); + const p = save(); + flush(); + setEnabled(true); + flush(); + expect(isPending(enabled), shape).toBe(true); + gate.resolve(); + await p; + flush(); + expect(isPending(enabled), shape).toBe(false); + expect(derived(), shape).toBe(shape === "added" ? "true:true" : "true:false"); + dispose(); + } + }); + + it("contrast: a prototype swap still holds the reader (A29)", async () => { + class A { + label() { + return "old"; + } + } + class B { + label() { + return "new"; + } + } + const gate = deferred(); + const [server, setServer] = createSignal(new A()); + const [store] = createStore(() => server(), new A()); + const [enabled, setEnabled] = createSignal(false); + let dispose!: () => void; + const seen: string[] = []; + createRoot(d => { + dispose = d; + createRenderEffect( + () => `${enabled()}:${store.label()}`, + v => void seen.push(v) + ); + }); + flush(); + const save = action(function* save() { + setServer(new B()); + yield gate.promise; + }); + const p = save(); + flush(); + setEnabled(true); + flush(); + // The stale pass keeps the committed frame until the action settles. + expect(seen.at(-1)).toBe("true:old"); + gate.resolve(); + await p; + flush(); + expect(seen.at(-1)).toBe("true:new"); + dispose(); + }); + + // Third playground: the row is first read AFTER the adoption, so it has no + // target for slot equality and the container hold stands. Freeing it needs + // the adoption to carry per-child held views: a positional lazy + // materialization aliased reordered rows and split proxy identity by + // reader context under review. Left as a design call, tracked in #3712. + for (const read of ["find", "index-id"] as const) { + it.fails( + `a row first read AFTER the adoption (${read}) derives nothing from the hold (third playground)`, + async () => { + let release!: () => void; + let drag!: () => string | undefined; + let setDrag!: (v: string) => void; + let move!: () => Promise; + let derived!: () => unknown; + let serverCards!: () => { id: string; version: number }[]; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const [rows, setRows] = createSignal([{ id: "card", version: 0 }]); + serverCards = createMemo(() => rows()); + const [localCards] = createStore(() => serverCards(), []); + const [cards] = createOptimisticStore(localCards); + [drag, setDrag] = createSignal(); + move = action(function* () { + setRows([{ id: "card", version: 1 }]); + yield new Promise(r => (release = r)); + }); + // The row is only read once `drag` is set — after the adoption. + derived = createMemo(() => + read === "find" ? drag() && cards.find(c => c.id === drag())?.id : drag() && cards[0].id + ); + createRenderEffect(derived, () => {}); + }); + flush(); + const p = move(); + flush(); + await tick(); + expect(isPending(serverCards)).toBe(true); + setDrag("card"); + flush(); + const pending = isPending(drag); + release(); + await p; + flush(); + dispose(); + expect(pending).toBe(false); + } + ); + } + + it("a changed leaf of a row first read after the adoption holds (A29)", async () => { + let release!: () => void; + let setDrag!: (v: string) => void; + let drag!: () => string | undefined; + let move!: () => Promise; + let version!: () => unknown; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const [rows, setRows] = createSignal([{ id: "card", version: 0 }]); + const [cards] = createStore(() => rows(), []); + [drag, setDrag] = createSignal(); + move = action(function* () { + setRows([{ id: "card", version: 1 }]); + yield new Promise(r => (release = r)); + }); + version = createMemo(() => drag() && cards[0].version); + createRenderEffect(version, () => {}); + }); + flush(); + const p = move(); + flush(); + await tick(); + setDrag("card"); + flush(); + expect(isPending(drag)).toBe(true); + release(); + await p; + flush(); + expect(isPending(drag)).toBe(false); + expect(version()).toBe(1); + dispose(); + }); + + it("reordered unread rows hold and are never aliased to one child (A29)", async () => { + const gate = deferred(); + const a = { id: "a" }; + const b = { id: "b" }; + const [server, setServer] = createSignal([a, b]); + const [store] = createStore<{ id: string }[]>(() => server(), []); + const [enabled, setEnabled] = createSignal(false); + let first!: () => unknown; + let dispose!: () => void; + createRoot(d => { + dispose = d; + first = createMemo(() => enabled() && store[0].id); + createRenderEffect(first, () => {}); + }); + flush(); + const swap = action(function* swap() { + setServer([b, a]); + yield gate.promise; + }); + const p = swap(); + flush(); + setEnabled(true); + flush(); + expect(isPending(enabled)).toBe(true); + gate.resolve(); + await p; + flush(); + expect(first()).toBe("b"); + expect(store[0].id).toBe("b"); + expect(store[1].id).toBe("a"); + expect(store[0]).not.toBe(store[1]); + dispose(); + }); + + it("a shallow derived store keeps the reference hold for a replaced unread row (A29)", async () => { + const gate = deferred(); + const [server, setServer] = createSignal([{ id: "card", version: 0 }]); + const [store] = createStore<{ id: string; version: number }[]>(() => server(), [], { + shallow: true + }); + const [enabled, setEnabled] = createSignal(false); + let version!: () => unknown; + let dispose!: () => void; + createRoot(d => { + dispose = d; + version = createMemo(() => enabled() && store[0].version); + createRenderEffect(version, () => {}); + }); + flush(); + const save = action(function* save() { + setServer([{ id: "card", version: 1 }]); + yield gate.promise; + }); + const p = save(); + flush(); + setEnabled(true); + flush(); + expect(isPending(enabled)).toBe(true); + gate.resolve(); + await p; + flush(); + expect(version()).toBe(1); + expect(store[0].version).toBe(1); + dispose(); + }); + + it("contrast: a descriptor read of a key whose enumerability changed still holds (A29)", async () => { + const gate = deferred(); + const [server, setServer] = createSignal<{ x: number }>({ x: 1 }); + const [store] = createStore<{ x: number }>(() => server(), { x: 1 }); + const [enabled, setEnabled] = createSignal(false); + const seen: string[] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + createRenderEffect( + () => `${enabled()}:${Object.getOwnPropertyDescriptor(store, "x")?.enumerable}`, + v => void seen.push(v) + ); + }); + flush(); + const hide = action(function* hide() { + setServer(Object.defineProperty({}, "x", { value: 1, enumerable: false }) as { x: number }); + yield gate.promise; + }); + const p = hide(); + flush(); + setEnabled(true); + flush(); + expect(seen.at(-1)).toBe("true:true"); + gate.resolve(); + await p; + flush(); + expect(seen.at(-1)).toBe("true:false"); + dispose(); + }); + + it("contrast: an inherited accessor reading `this` still holds the reader (A29)", async () => { + class Row { + column = 0; + get double() { + return this.column * 2; + } + } + const gate = deferred(); + const [server, setServer] = createSignal(new Row()); + const [store] = createStore(() => server(), new Row()); + const [enabled, setEnabled] = createSignal(false); + const seen: string[] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + createRenderEffect( + () => `${enabled()}:${store.double}`, + v => void seen.push(v) + ); + }); + flush(); + const bump = action(function* bump() { + setServer(Object.assign(new Row(), { column: 1 })); + yield gate.promise; + }); + const p = bump(); + flush(); + setEnabled(true); + flush(); + expect(seen.at(-1)).toBe("true:0"); + gate.resolve(); + await p; + flush(); + expect(seen.at(-1)).toBe("true:2"); + dispose(); + }); + + it("contrast: reading the key the adoption CHANGED still holds the reader (A29)", async () => { + const b = board((drag, cards) => `${drag() ?? "unset"}:${cards[0].column}`); + const first = b.move("0", 1); + const second = b.move("1", 2); + b.confirm("0"); + await first; + await tick(); + + b.setDrag("0"); + flush(); + // The memo derives from the adopted column: it joins the hold, and the + // independent signal is held with it until the transaction settles. + expect(isPending(b.drag)).toBe(true); + expect(latest(b.drag)).toBe("0"); + expect(b.drag()).toBe(undefined); + + b.confirm("1"); + await second; + flush(); + expect(isPending(b.drag)).toBe(false); + expect(b.derived()).toBe("0:1"); + b.dispose(); + }); + + it("control: the same memo without the store read publishes immediately", async () => { + const b = board(drag => drag() === "0"); + const first = b.move("0", 1); + const second = b.move("1", 2); + b.confirm("0"); + await first; + await tick(); + b.setDrag("0"); + flush(); + expect(isPending(b.drag)).toBe(false); + expect(b.derived()).toBe(true); + b.confirm("1"); + await second; + b.dispose(); + }); + + it("a plain derived store adopting under an open action holds only the changed keys", async () => { + const gate = deferred(); + const [server, setServer] = createSignal({ saved: false, stable: "same" }); + const [store] = createStore(() => server(), { saved: false, stable: "same" }); + const [enabled, setEnabled] = createSignal(false); + let unchanged!: () => boolean; + let dispose!: () => void; + createRoot(d => { + dispose = d; + unchanged = createMemo(() => enabled() && store.stable === "same"); + createRenderEffect(unchanged, () => {}); + }); + flush(); + const save = action(function* save() { + setServer(s => ({ ...s, saved: true })); + yield gate.promise; + }); + const p = save(); + flush(); + + setEnabled(true); + flush(); + // The unchanged-key reader publishes; the adopted key itself stays held + // (mainline sees the committed frame) until the action settles. + expect(unchanged()).toBe(true); + expect(isPending(enabled)).toBe(false); + expect(store.saved).toBe(false); + + gate.resolve(); + await p; + flush(); + expect(store.saved).toBe(true); + dispose(); + }); + + // The record is what the ADOPTION changed, not what changed since the hold + // began: a mainline setter write during the hold replaces the backing, and + // the key it wrote is not held with the adopting transaction. + describe("a mainline setter write during the hold to a key the adoption left unchanged", () => { + function heldSave(read: (n: number, stable: () => string) => string) { + const gate = deferred(); + const [server, setServer] = createSignal({ saved: false, stable: "same" }); + const [store, setStore] = createStore(() => server(), { saved: false, stable: "same" }); + const [n, setN] = createSignal(0); + let reader!: () => string; + const dispose = createRoot(d => { + reader = createMemo(() => read(n(), () => store.stable)); + createRenderEffect(reader, () => {}); + return d; + }); + flush(); + const save = action(function* save() { + setServer(s => ({ ...s, saved: true })); + yield gate.promise; + }); + const p = save(); + flush(); + setStore(s => void (s.stable = "edited")); + flush(); + return { + store, + n, + setN, + reader, + settle: async () => { + gate.resolve(); + await p; + flush(); + dispose(); + } + }; + } + + it("a tracked memo reading it does not make an independent signal pending", async () => { + const t = heldSave((n, stable) => `${n}:${stable()}`); + t.setN(1); + flush(); + expect(isPending(t.n)).toBe(false); + expect(t.reader()).toBe("1:edited"); + expect(t.store.saved).toBe(false); + await t.settle(); + }); + + it("an untracked read serves the write", async () => { + const t = heldSave((n, stable) => `${n}:${stable()}`); + expect(untrack(() => t.store.stable)).toBe("edited"); + expect(t.store.stable).toBe("edited"); + expect(t.store.saved).toBe(false); + await t.settle(); + }); + + it("a fresh node, after the reader drops the key and reads it again, is not born holding", async () => { + const t = heldSave((n, stable) => (n === 1 ? "off" : `${n}:${stable()}`)); + t.setN(1); + flush(); + expect(t.reader()).toBe("off"); + t.setN(2); + flush(); + expect(isPending(t.n)).toBe(false); + expect(t.reader()).toBe("2:edited"); + await t.settle(); + }); + }); +}); diff --git a/packages/web/test/optimistic-confirm-holds-signal-3706.spec.tsx b/packages/web/test/optimistic-confirm-holds-signal-3706.spec.tsx new file mode 100644 index 000000000..84ab1ff83 --- /dev/null +++ b/packages/web/test/optimistic-confirm-holds-signal-3706.spec.tsx @@ -0,0 +1,119 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * #3706 playground port: two overlapping optimistic moves share a + * transaction; confirming the first adopts the authoritative array on the + * derived base store under the open hold. A later, independent `drag` write + * must publish while the second move is still pending, even though a memo on + * the page reads `drag() === cards[0].id` — a key the adoption left unchanged. + * Signals-level pin: tests/adoption-unchanged-key-read-3706.test.ts. + */ +import { describe, expect, test } from "vitest"; +import { + action, + createMemo, + createOptimisticStore, + createSignal, + createStore, + flush, + isPending, + latest +} from "solid-js"; +import { render } from "../src/index.js"; + +type Card = { id: string; column: number }; +const tick = () => new Promise(r => setTimeout(r, 0)); + +function setup(opts: { memo: boolean; confirmFirst: boolean }) { + const container = document.createElement("div"); + document.body.appendChild(container); + let run!: () => Promise; + let confirmSecond!: () => void; + let confirmFirst!: () => void; + + function App() { + const [server, setServer] = createSignal( + Array.from({ length: 2 }, (_, index) => ({ id: String(index), column: index })) + ); + const [base] = createStore(() => server(), []); + const [cards, setCards] = createOptimisticStore(base); + const [drag, setDrag] = createSignal(); + const condition = opts.memo ? createMemo(() => drag() === cards[0].id) : () => "off"; + + const move = action(function* (id: string, column: number) { + setCards(draft => { + const card = draft.find(item => item.id === id)!; + card.column = column; + }); + yield new Promise(resolve => { + if (id === "0") confirmFirst = resolve; + else confirmSecond = resolve; + }); + setServer(rows => rows.map(row => (row.id === id ? { ...row, column } : row))); + }); + + run = async () => { + const first = move("0", 1); + void move("1", 2); + if (opts.confirmFirst) { + confirmFirst(); + await first; + } + await tick(); + setDrag("0"); + }; + + return ( + <> +
{String(condition())}
+
+          drag: {drag() ?? "unset"}
+          {"\n"}
+          latest: {latest(drag) ?? "unset"}
+          {"\n"}
+          pending: {String(isPending(drag))}
+        
+ + ); + } + + const dispose = render(() => , container); + flush(); + const out = () => container.querySelector("#out")!.textContent!.replace(/\s+/g, " ").trim(); + const cond = () => container.querySelector("#cond")!.textContent; + const settle = () => { + confirmFirst(); + confirmSecond(); + }; + return { run, out, cond, settle, dispose, container }; +} + +describe("#3706 confirming one overlapping optimistic action holds an independent signal write", () => { + for (const memo of [true, false]) { + for (const confirmFirst of [true, false]) { + test(`memo=${memo} confirmFirst=${confirmFirst}`, async () => { + const s = setup({ memo, confirmFirst }); + expect(s.out()).toBe("drag: unset latest: unset pending: false"); + await s.run(); + flush(); + await tick(); + flush(); + const afterWrite = s.out(); + const condAfterWrite = s.cond(); + s.settle(); + await tick(); + await tick(); + flush(); + const afterSettle = s.out(); + s.dispose(); + s.container.remove(); + expect({ afterWrite, condAfterWrite, afterSettle }).toEqual({ + afterWrite: "drag: 0 latest: 0 pending: false", + condAfterWrite: memo ? "true" : "off", + afterSettle: "drag: 0 latest: 0 pending: false" + }); + }); + } + } +}); diff --git a/packages/web/test/optimistic-lazy-show-preview-3706.spec.tsx b/packages/web/test/optimistic-lazy-show-preview-3706.spec.tsx new file mode 100644 index 000000000..eafcd5f9b --- /dev/null +++ b/packages/web/test/optimistic-lazy-show-preview-3706.spec.tsx @@ -0,0 +1,111 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * #3706, third playground: one action writes the authoritative rows before + * yielding; a separate click sets `drag`. The preview mounts a nested + * over `cards.find(...)` only then, so the row is first read AFTER the + * derived store adopted the new rows under the open action. The drag write + * must publish while the row update stays pending. + */ +import { describe, expect, test } from "vitest"; +import { + action, + createMemo, + createOptimisticStore, + createSignal, + createStore, + flush, + isPending, + latest, + Show +} from "solid-js"; +import { render } from "../src/index.js"; + +const tick = () => new Promise(r => setTimeout(r, 0)); + +describe("#3706 lazily mounted Show preview over an adopted row", () => { + // `find` reads the row for the first time after the adoption inside the + // inner Show's `when` memo; the container hold stands there (design call, + // #3712; see the signals pin). `index` reads it from the + // insert's render effect, a stale reader that keeps the committed frame + // and publishes. + for (const preview of ["find", "index", "none"] as const) { + (preview === "find" ? test.fails : test)(`preview=${preview}`, async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + let release!: () => void; + let move!: () => Promise; + let setDrag!: (v: string) => void; + function App() { + const [rows, setRows] = createSignal([{ id: "card", version: 0 }]); + const serverCards = createMemo(() => rows()); + const [localCards] = createStore(() => serverCards(), []); + const [cards] = createOptimisticStore(localCards); + const [drag, setDragFn] = createSignal(); + setDrag = setDragFn; + move = action(function* () { + setRows([{ id: "card", version: 1 }]); + yield new Promise(r => (release = r)); + }); + return ( + <> + + {id => + preview === "find" ? ( + card.id === id())}> + {card =>

Dragging {card().id}

} +
+ ) : preview === "index" ? ( +

Dragging {cards[0].id}

+ ) : ( +

Dragging {id()}

+ ) + } +
+
+              {JSON.stringify({
+                drag: drag(),
+                latest: latest(drag),
+                pending: isPending(drag),
+                serverPending: isPending(serverCards)
+              })}
+            
+ + ); + } + const dispose = render(() => , container); + flush(); + const p = move(); + flush(); + await tick(); + setDrag("card"); + flush(); + await tick(); + flush(); + const during = container.querySelector("pre")!.textContent; + const preview_ = container.querySelector("p")?.textContent; + release(); + await p; + flush(); + const after = container.querySelector("pre")!.textContent; + dispose(); + container.remove(); + expect({ during, preview_, after }).toEqual({ + during: JSON.stringify({ + drag: "card", + latest: "card", + pending: false, + serverPending: true + }), + preview_: "Dragging card", + after: JSON.stringify({ + drag: "card", + latest: "card", + pending: false, + serverPending: false + }) + }); + }); + } +}); diff --git a/packages/web/test/optimistic-nested-show-drag-3706.spec.tsx b/packages/web/test/optimistic-nested-show-drag-3706.spec.tsx new file mode 100644 index 000000000..bd3b43cbd --- /dev/null +++ b/packages/web/test/optimistic-nested-show-drag-3706.spec.tsx @@ -0,0 +1,85 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * #3706, second playground (nested preview): six overlapping + * optimistic moves, a separate `drag` signal set and cleared between them. + * The preview reads `cards.find(...)` under ``. Every + * drag write must commit (`pending=false`) while moves are in flight. + */ +import { describe, expect, test } from "vitest"; +import { + action, + createOptimisticStore, + createSignal, + createStore, + isPending, + latest, + Show +} from "solid-js"; +import { render } from "../src/index.js"; + +type Card = { id: string; column: number }; +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); + +describe("#3706 nested Show preview over an optimistic store", () => { + for (const inner of ["find", "plain"] as const) { + test(`six overlapping moves, inner=${inner}`, async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + let run!: () => Promise; + function App() { + const [server, setServer] = createSignal( + Array.from({ length: 2 }, (_, i) => ({ id: String(i), column: 0 })) + ); + const [base] = createStore(() => server(), []); + const [cards, setCards] = createOptimisticStore(base); + const [drag, setDrag] = createSignal(); + const move = action(function* (id: string, column: number) { + setCards(draft => { + draft.find(c => c.id === id)!.column = column; + }); + yield delay(400); + setServer(rows => rows.map(row => (row.id === id ? { ...row, column } : row))); + }); + run = async () => { + const rows: string[] = []; + for (let i = 0; i < 6; i++) { + const id = String(i % 2); + setDrag(id); + await delay(0); + rows.push( + `${i}: drag=${drag() ?? "unset"} latest=${latest(drag) ?? "unset"} pending=${isPending(drag)}` + ); + setDrag(undefined); + await delay(0); + void move(id, i + 1); + await delay(80); + } + await delay(500); + return rows; + }; + return ( + + {active => + inner === "find" ? ( + card.id === active())}> + {card => Preview {card().id}} + + ) : ( + Preview {active()} + ) + } + + ); + } + const dispose = render(() => , container); + const rows = await run(); + dispose(); + container.remove(); + expect(rows).toEqual( + Array.from({ length: 6 }, (_, i) => `${i}: drag=${i % 2} latest=${i % 2} pending=false`) + ); + }, 20000); + } +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index bd2c40a4f..d1ecd7760 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -2,6 +2,6 @@ "signals: core floor (createSignal/Memo/Effect/Root/flush)": "9.51 KB", "app: render + one signal (the simple-app floor)": "12.05 KB", "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": "19.69 KB", - "page: base server components (hydrating + dynamic + frames + sf reference)": "45.77 KB", - "page: live server components (base + live/GET + action + isPending/latest)": "49.96 KB" + "page: base server components (hydrating + dynamic + frames + sf reference)": "45.91 KB", + "page: live server components (base + live/GET + action + isPending/latest)": "50.09 KB" } diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index df90a7c33..01c777dc7 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -816,7 +816,24 @@ module.exports = [ // signal's write with someone else's action. Container-wide reads // (ownKeys, $TRACK, deep) still hold. On the store read path, paid by // every store user; core floor 0 B. - limit: "16.71 KB", + // Size-Exception (#3706, 2026-09-29): 16.71 -> 16.85 KB, measured at + // 16,848 B against `next` @ 7f9bd7a6d's 16,708 (+140 B; +578 B minified). + // The adoption hold is key-scoped like the fold hold (#3688): adoptPB + // records the adopted object, the keys it changed against the held view + // are computed once, on the first held read (`heldKeys`, diffed against + // that object so re-pointed children compare as one slot and a mainline + // write during the hold is not counted: own on both, never an accessor, + // same enumerability, slot equality; a swapped or non-plain prototype, a + // chained backing or an optimistic family hold the whole container), and + // `readSource` and getNode's born-holding path answer with one set + // lookup — a memo reading `cards[0].id` beside an independent signal no + // longer holds that signal's write with the transaction that adopted the + // array. The per-read compare variant measured +327 B minified (+59 B + // here); recorded keys were chosen for the single `has(key)` rule shared + // with `wk` and for covering enumerability and inherited accessors. + // Accepted by the maintainer. On the store read path, paid by every + // store user; core floor 0 B. + limit: "16.85 KB", alias }, { @@ -1875,7 +1892,11 @@ module.exports = [ // 30.63 KB, measured at 30,599 B against `next` @ f1b07761f's 30,622 (-23 B; // -3 B minified). Same parking-gate change as the no-stores hydrating // note; brotli layout over the larger bundle turns it into a saving here. - limit: "30.63 KB", + // Size-Exception (#3706, 2026-09-29): 30.63 -> 30.74 KB, measured at + // 30,738 B against `next` @ 7f9bd7a6d's 30,608 (+130 B; +580 B minified). + // Same adoption-hold change as the `+ createStore` note (+140 B there). + // Accepted by the maintainer. + limit: "30.74 KB", alias }, { @@ -2954,6 +2975,12 @@ module.exports = [ // minified — the occurrence's listener detach, frames note), over the // rounded cap by 1 B; 45.76 -> 45.77 KB under the same exception. The // cap is frozen again at 45.77 KB. + // Size-Exception (#3706, 2026-09-29): 45.77 -> 45.91 KB, measured at + // 45,904 B against `next` @ 7f9bd7a6d's 45,761 (+143 B; 134 B over the + // cap; +582 B minified). Same adoption-hold change as the `+ createStore` + // note, reaching this page through the frames client's container-trace + // materializer. Accepted by the maintainer. The cap is frozen again at + // 45.91 KB. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], alias: pageAlias }, @@ -3011,6 +3038,11 @@ module.exports = [ // the live path adds nothing of its own. Accepted by the maintainer. The // cap is frozen again at 49.96 KB. Review fixes, same PR: 49,951 -> // 49,901 B (-50 B; +35 B minified — brotli layout), under the cap. + // Size-Exception (#3706, 2026-09-29): 49.96 -> 50.09 KB, measured at + // 50,085 B against `next` @ 7f9bd7a6d's 49,955 (+130 B; 125 B over the + // cap; +581 B minified). Same adoption-hold change as the base page's + // note (+143 B there). Accepted by the maintainer. The cap is frozen + // again at 50.09 KB. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], alias: pageAlias }