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.