diff --git a/.changeset/netted-verdict-reader.md b/.changeset/netted-verdict-reader.md new file mode 100644 index 0000000000..16238a5994 --- /dev/null +++ b/.changeset/netted-verdict-reader.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Writes that net to a signal's committed value in the tick an action opens a hold no longer pend the signal: a render effect reading `isPending(s)` shows `false` during the hold and is not re-run when the action lands (D-8). diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index 0f28b9cdbf..074532e3cd 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -190,7 +190,7 @@ An armed boundary catches a lane the same way it catches a transition (D-12, D-1 `isPending(x)` is `true` while any of three causes holds: (i) a write held by a live hold; (ii) the node's own async in flight for a new question; (iii) a fresh value landed but held uncommitted by a hold it is entangled with — and `false` the moment none does. Exception 1: a node with no committed value — a first load, or a memo born held over a held value (D-2) — is loading, not pending. A node answered and then re-asked in the same flush — an async first answer that lands in the tick a write re-asks it — is not a first load (amended 2026-10-30): its answer is a produced value, so `latest` of it shows the answer and `isPending` of it is `true`, the re-ask being a new question; so is a derivation of it with no value of its own, at any depth (F-9, D-22). It has no verdict: inside an owner `isPending` of it does what its read does — a first load's `NotReadyError` goes to a boundary or holds the change (F-2); a reader of a memo born held is held with it, or caught by a boundary that has not shown content — and the reader runs at the commit, where the answer is final; unowned it is `false` (D-24). A node with a committed value is judged wherever its readers are: on screen, behind a fallback, or in a branch not yet attached. A verdict on content that may have no committed value is read inside the boundary that owns it — `

{m()}

{isPending(m) ? "updating" : ""}
` — where its reader is caught with the content; read outside, it holds the change that mounted it. Either way no answer is shown before the commit: `` is never seen enabled over an unjudged value (`packages/web/test/ispending-guard-born-held.spec.tsx`). No verdict is the honest answer: `true` would say "updating" for something never shown, and `false` would read as settled to a guard like `if (!isPending(x)) save(x())`. Exception 2: a re-ask of the same question is quiet; an `affects()` mark is a declared new question and pends exactly the marked data and what derives from it, released when the declaring hold settles (or at flush end, ambient). `[isPending(x), x()]` read in one scope never pairs `true` with the fresh value: a verdict reader sees the screen — in either read order — unless it is a node a hold's fetch is waiting on (D-25). `isPending(() => latest(x))` follows `x`'s own non-quiet async only — on a signal or sync memo the latest form is never pending — so `[isPending(() => latest(x)), latest(x)]` never pairs `true` with the fresh value either. A spinner driven by `isPending` fires while the owner's async is still in flight (it does not wait for the hold). -**Collapses:** [A19](./SPEC-ASYNC-SEMANTICS.md#a19-ispendingx--the-observable-value-is-not-final-three-causes), [A24](./SPEC-ASYNC-SEMANTICS.md#a24-question-scoped-pending-pending-iff-a-value-change-is-in-flight-or-an-affects-mark-is-live), [A10](./SPEC-ASYNC-SEMANTICS.md#a10-ispendingx-x-is-atomic-within-one-scope), [A8](./SPEC-ASYNC-SEMANTICS.md#a8-ispending--latestx-follows-xs-own-async-only--verdicts-are-per-channel), [A14](./SPEC-ASYNC-SEMANTICS.md#a14-companion-nodes-get-child-lanes-that-do-not-merge-with-the-owner), [A20](./SPEC-ASYNC-SEMANTICS.md#a20-superseded-optimistic-writes-announce-a-store-wide-pending)/[A21](./SPEC-ASYNC-SEMANTICS.md#a21-superseded-the-store-wide-mask) (superseded — the mask is gone), recovery 6.3. **Status:** standing. A probe alone holds nothing; a plain read beside it holds (`optimistic-settle-verdicts.test.ts` #3409, `question-scoped-pending.test.ts` 3.4; #3977). `isPending` of a memo born held, read inside and outside the boundary: `tests/fresh-boundary-born-held-reader.test.ts` (#3977; the engine joins the reader to the hold as the read does, which is the throw's observable effect). **Known deviation (#4469, 2026-10-30):** a verdict reader is re-run at every landing of a hold it observed, even when the landing changes nothing it read; the gate that would skip it needs a deps walk. Measured 2026-10-30: two cheaper gates broke F-9/D-23 (verdict-memo-flight-no-reask) and #4189 (errored-store-probe-settled-3962) pins and failed the frozen floors by +31/+36 B. A plain reader of the same recovered hold is not in this: it shows the committed value for the whole hold and runs once, at the commit, since a recovery to a value equal to the last good one is still a change of answer (D-41, D-9). Pinned in `tests/hold-recovers-landing-reruns-4469.test.ts` (the verdict rows `it.fails`). The same seam reaches a verdict reader of a signal whose writes net to its committed value in the tick an action opens a hold (#4485): row V2 (`stagedScreen`) runs before any netted check, so the reader is listed as the hold's stale reader and re-runs at the landing, and `isPending(s)` reads `true` for the hold although the writes made no proposal (D-8). That is a bug, not this deviation; pinned `it.fails` in `tests/netted-verdict-reader-4485.test.ts`. +**Collapses:** [A19](./SPEC-ASYNC-SEMANTICS.md#a19-ispendingx--the-observable-value-is-not-final-three-causes), [A24](./SPEC-ASYNC-SEMANTICS.md#a24-question-scoped-pending-pending-iff-a-value-change-is-in-flight-or-an-affects-mark-is-live), [A10](./SPEC-ASYNC-SEMANTICS.md#a10-ispendingx-x-is-atomic-within-one-scope), [A8](./SPEC-ASYNC-SEMANTICS.md#a8-ispending--latestx-follows-xs-own-async-only--verdicts-are-per-channel), [A14](./SPEC-ASYNC-SEMANTICS.md#a14-companion-nodes-get-child-lanes-that-do-not-merge-with-the-owner), [A20](./SPEC-ASYNC-SEMANTICS.md#a20-superseded-optimistic-writes-announce-a-store-wide-pending)/[A21](./SPEC-ASYNC-SEMANTICS.md#a21-superseded-the-store-wide-mask) (superseded — the mask is gone), recovery 6.3. **Status:** standing. A probe alone holds nothing; a plain read beside it holds (`optimistic-settle-verdicts.test.ts` #3409, `question-scoped-pending.test.ts` 3.4; #3977). `isPending` of a memo born held, read inside and outside the boundary: `tests/fresh-boundary-born-held-reader.test.ts` (#3977; the engine joins the reader to the hold as the read does, which is the throw's observable effect). **Known deviation (#4469, 2026-10-30):** a verdict reader is re-run at every landing of a hold it observed, even when the landing changes nothing it read; the gate that would skip it needs a deps walk. Measured 2026-10-30: two cheaper gates broke F-9/D-23 (verdict-memo-flight-no-reask) and #4189 (errored-store-probe-settled-3962) pins and failed the frozen floors by +31/+36 B. A plain reader of the same recovered hold is not in this: it shows the committed value for the whole hold and runs once, at the commit, since a recovery to a value equal to the last good one is still a change of answer (D-41, D-9). Pinned in `tests/hold-recovers-landing-reruns-4469.test.ts` (the verdict rows `it.fails`). A verdict reader of a signal whose writes net to its committed value in the tick an action opens a hold is not in this either (#4485, fixed 2026-10-30): the writes made no proposal (D-8), so the reader is not the hold's stale reader and `isPending(s)` stays `false` (`tests/netted-verdict-reader-4485.test.ts`). ### D-24. `isPending` never throws unowned; the probe is reads-only diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 816b0aff2d..8a094bbefb 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -85,7 +85,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul |---|---|---|---|---|---| | V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:480` | seat.ts×5 | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | | V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:490` | seat.ts×2 | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | -| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:496` | seat.ts×5 | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | +| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:496` | seat.ts×4 | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | | V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:503` | seat.ts×3 | spec-async-semantics.test.ts×5 | - **V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | | V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:515` | scheduler.ts×1 | spec-async-semantics.test.ts×3 | - **V5 (A17 corollary — found and fixed with the revert-target elimination, | ## B — tier B diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index bd34b91e96..f5f4707582 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -2063,19 +2063,21 @@ export function read(el: Signal | Computed): T { if (__TEST__) shadowActual(SLOT_C); return el._value as T; } - // (A plain signal is never uninitialized — the carve-out is `serve`'s.) - // Lane work over a folded lane (D-34), once the frame has a - // transaction, reads the screen too. + // A staging equal to the committed value is writes that netted (D-8): no + // proposal, so not a read of the frame's future — nor, for a verdict + // reader, a stale read of the tick's hold re-run at its landing (#4485): + // the committed value is the staging. (A plain signal is never + // uninitialized — the carve-out is `serve`'s.) Lane work over a folded + // lane (D-34), once the frame has a transaction, reads the screen too. if ( - ((observeFlightHook && c._config & CONFIG_VERDICT) || (laneStageHook && passLane?.folded)) && - stagedScreen(c as Computed, el) + el._pendingValue === el._value || + (((observeFlightHook && c._config & CONFIG_VERDICT) || (laneStageHook && passLane?.folded)) && + stagedScreen(c as Computed, el)) ) { - if (__TEST__) shadowActual(SLOT_C); + if (__TEST__) shadowActual(el._pendingValue === el._value ? SLOT_P : SLOT_C); return el._value as T; } - // A staging equal to the committed value is writes that netted (D-8): no - // proposal, so not a read of the frame's future. - if (el._pendingValue !== el._value) stagedRead(c as Computed); + stagedRead(c as Computed); if (__TEST__) shadowActual(SLOT_P); return el._pendingValue as T; } diff --git a/packages/signals/src/core/seat.ts b/packages/signals/src/core/seat.ts index 00a4dec12f..7ab83f0e7a 100644 --- a/packages/signals/src/core/seat.ts +++ b/packages/signals/src/core/seat.ts @@ -504,6 +504,11 @@ export function slot( const laneNode = (cfg & CONFIG_OVERRIDE) !== 0; const guess = (cfg & CONFIG_GUESS) !== 0; const stagedError = (cfg & CONFIG_STAGED_ERROR) !== 0; + /** D-8: a plain signal's staging equal to its committed value — writes + * that netted, no proposal (`read`'s plain arm; #4452, #4485). A window's + * read of it is the seam's verdict's rows below (`provisionalVerdict` + * answers it final and routes it as they say). */ + const netted = staged && !isFn && !laneNode && el._pendingValue === el._value; /** The staging as a slot: its value, or the error it stages (D-41). */ const stagedSlot = stagedError ? SLOT_E : SLOT_P; /** The screen as a slot: the committed value, or the error still shown @@ -903,27 +908,24 @@ export function slot( // (F-9, as D1/L1: a staging beneath a flight is not what the reader // sees — the flight decides below. The 50k differential's V3 finding.) if (!pending) { - // D1'' / L1'' / V3'' (D-8, #4452): a signal's staging equal to its - // committed value is writes that netted — no proposal, so the read - // is the screen's: the slot is still P (the same value), no mark. - const netted = !isFn && el._pendingValue === el._value; + const row = renderLeaf ? "L1" : inLane ? rowBase + "D7" : rowBase + "D1"; + // D1'' / L1'' / V3'' (D-8, #4452, #4485): writes that netted made no + // proposal — the staging (the screen's value) for every reader, raising + // nothing: not a read of the frame's future, nor a verdict reader's + // stale read of the tick's hold. + if (netted) return carry(pred(row + "''", SLOT_P)); if (verdictReader && !uninit && c!._config & CONFIG_VERDICT) { // V2/V3 (D-23 D-32 [R5]): the screen once the tick has a hold, the // staging before (the seam's verdict). if (stagedScreen) return carry(pred("V2", screen, MARK_ST, flushTransaction)); - if (netted) return carry(pred("V3''", stagedSlot)); return carry(pred("V3", stagedSlot, RAISE_FL | MARK_W, tickHold())); } if (inLane && seat!.lane!.folded && stagedScreen && !uninit) return carry(pred(rowBase + "D7'", SLOT_C, MARK_ST, flushTransaction)); // folded lane work reads the screen (D-34) - const row = renderLeaf ? "L1" : inLane ? rowBase + "D7" : rowBase + "D1"; // A pass reading a node born staged (uninitialized) has no screen to // re-derive on: no Fl mark (#3969), the value is still the staging's. - // Nor a netted signal's (D1'' / L1'', above). - const raise = - uninit || netted ? 0 : RAISE_FL | (inSeat || (isEffect && verdictReader) ? MARK_W : 0); - const lane = inLane ? seat!.lane : null; - return carry(pred(netted ? row + "''" : row, stagedSlot, raise, null, lane)); + const raise = uninit ? 0 : RAISE_FL | (inSeat || (isEffect && verdictReader) ? MARK_W : 0); + return carry(pred(row, stagedSlot, raise, null, inLane ? seat!.lane : null)); } } diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 79dc3cea50..c3ca6fb1c5 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -563,8 +563,16 @@ function verdictValue(el: Signal | Computed, c: Computed | null): (!uninitialized || (probing && !(el._config & CONFIG_STAGED_ERROR))) ) pendingVerdict(tracked ? c : null, owner); + // D-8: a signal's staging equal to its committed value is writes that + // netted — no proposal: the seam holds nothing of it and no landing + // flips its verdict, so it is final (F-9, D-23) and the reader is not + // re-run at the landing (#4485; `read`'s plain arm likewise, #4452). The + // reader is still the tick's verdict reader, shown now (D-25: the + // posture is the pass's). A signal's staging only, as `read`'s arm. else if (tracked && globalQueue._running) - provisionalVerdict(c!, !latestActive && !uninitialized); + flushTransaction && !(el as any)._fn && el._pendingValue === el._value + ? route(c!, flushTransaction) + : provisionalVerdict(c!, !latestActive && !uninitialized); // Outside a flush, the staging is an unflushed write (A28): the probe // answers for the flushed world, and watches the seam of the flush that // carries the write — a memo of `isPending(x)` created after the write diff --git a/packages/signals/tests/action-outside-write-ispending-4367.test.ts b/packages/signals/tests/action-outside-write-ispending-4367.test.ts index f4d32cf328..e20988c245 100644 --- a/packages/signals/tests/action-outside-write-ispending-4367.test.ts +++ b/packages/signals/tests/action-outside-write-ispending-4367.test.ts @@ -9,7 +9,9 @@ // `false` at that flush (D-17: a memo between `isPending` and its reader // behaves as the direct read does). A pass outside any transaction's flush // publishes to the screen: its readers re-derive at the seam whatever holds -// the lane's frame. +// the lane's frame. (Re-pinned for #4485: the opening tick's writes net to +// the committed value, so that tick made no proposal and never shows +// `pending` (D-8); the settle after the later writes is unchanged.) import { describe, expect, it } from "vitest"; import { action, @@ -52,7 +54,12 @@ describe("#4367 isPending(s) after an action, with s also written outside it", ( act(3); set(2); await tick(); - expect(frames).toEqual([`F${v(2)}`, `pending${v(2)}`]); + // `set(3)` in the action and `set(2)` outside it are one tick's writes + // to `s`, and they net to its committed value: no proposal, so `s` is + // not held and pends nothing (D-8, #4485) — the screen stays `F`. + // (`s` lands at 2 without the later write.) The memo form's reader + // re-runs only when it reads `s()` beside the verdict. + expect(frames).toEqual(memo && !value ? ["F"] : [`F${v(2)}`, `F${v(2)}`]); set(3); flush(); // Nothing differs from the action's write: not pending, in either form. @@ -92,7 +99,8 @@ describe("#4367 isPending(s) after an action, with s also written outside it", ( release(); await tick(); expect(isPending(s)).toBe(false); - expect(frames).toEqual(["F", "pending", "F"]); + // The first tick's writes net (D-8, #4485): never pending. + expect(frames).toEqual(["F", "F"]); dispose(); }); }); diff --git a/packages/signals/tests/netted-verdict-reader-4485.test.ts b/packages/signals/tests/netted-verdict-reader-4485.test.ts index b69a86173b..cd40c70741 100644 --- a/packages/signals/tests/netted-verdict-reader-4485.test.ts +++ b/packages/signals/tests/netted-verdict-reader-4485.test.ts @@ -1,10 +1,10 @@ -// #4485 (it.fails pin): writes that net to a signal's committed value in the -// tick an action opens a hold made no proposal (D-8), so a verdict reader of -// the signal shows `isPending(s)` false and is not re-run at the landing. -// Today it shows `true` for the whole hold and re-runs at the landing: the -// reader takes the seat table's `stagedScreen` row before any netted check, -// which lists it as a stale reader of the flush's hold (D-23 Status, #4469's -// family). +// #4485: writes that net to a signal's committed value in the tick an action +// opens a hold made no proposal (D-8), so a verdict reader of the signal +// shows `isPending(s)` false and is not re-run at the landing. It showed +// `true` for the whole hold and re-ran at the landing: its plain read took +// the `stagedScreen` arm before any netted check, which listed it as a stale +// reader of the flush's hold, and its probe was the seam's verdict +// (`provisionalVerdict`), not final (D-23 Status, #4469's family). import { describe, expect, it } from "vitest"; import { action, @@ -56,7 +56,7 @@ describe("#4485: a verdict reader of a signal whose writes net", () => { return { held, runs }; }; - it.fails.each(["in-action", "before-action"] as const)( + it.each(["in-action", "before-action"] as const)( "pends nothing and is not re-run at the landing (%s)", async where => { const { held, runs } = await net(where); diff --git a/packages/web/test/action-outside-write-ispending-4367.spec.tsx b/packages/web/test/action-outside-write-ispending-4367.spec.tsx index 272420b57b..ade69ba15c 100644 --- a/packages/web/test/action-outside-write-ispending-4367.spec.tsx +++ b/packages/web/test/action-outside-write-ispending-4367.spec.tsx @@ -7,7 +7,9 @@ // action's value ends the pending verdict; ``'s condition memo leaves // the verdict lane equal to its committed value. The screen must follow, as // it does for the inline `{isPending(s) ? … : …}` — and once the action -// completes, the fallback shows for good. +// completes, the fallback shows for good. (Re-pinned for #4485: the opening +// tick's writes net to the committed value, so it never shows `pending`, +// D-8.) import { expect, test } from "vitest"; import { Show, action, createSignal, flush, isPending } from "solid-js"; import { render } from "../src/index.js"; @@ -42,7 +44,10 @@ for (const shape of ["Show", "inline"] as const) set(2); await tick(); flush(); - expect(el.innerHTML).toBe("pending"); + // `set(3)` in the action and `set(2)` outside it are one tick's writes to + // `s`, and they net to its committed value: no proposal, so `s` is not + // held and pends nothing (D-8, #4485). + expect(el.innerHTML).toBe("[F]"); set(3); flush(); // Nothing differs from the action's write any more: not pending.