Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-ispending-unrevealed-lane-landing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

Keep `isPending` true for a lane derivation whose changed answer has landed but not yet been revealed (#4685)
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:417` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… |
| A22 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:231` | store.ts×1 | createProjection.draft-lifetime-3585.test.ts×1 spec-async-semantics.test.ts×1 visibility-oracle-store.states.ts×1 | [ruled, amended in place 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — **Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) … |
| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:241` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… |
| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:159` | affects-marks.ts×1 affects.ts×1 constants.ts×1 lanes.ts×2 types.ts×1 verdict.ts×3 affects.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 create-deferred-3710.test.ts×2 optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×3 kanban-a17-fixture.test.ts×1 signal-store-twins-qd.test.ts×1 visibility-oracle-store.states.ts×4 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… |
| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:159` | affects-marks.ts×1 affects.ts×1 constants.ts×1 lanes.ts×2 types.ts×1 verdict.ts×4 affects.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 create-deferred-3710.test.ts×2 optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×3 kanban-a17-fixture.test.ts×1 signal-store-twins-qd.test.ts×1 visibility-oracle-store.states.ts×4 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… |
| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:311` | projection.ts×2 store.ts×1 | derived-presence-async-3726.test.ts×2 seedless-derived-store.test.ts×1 uninitialized-visibility.test.ts×3 visibility-oracle-store.states.ts×7 visibility-oracle-store.test.ts×1 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… |
| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:111` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 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:303` | — | 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… |
Expand Down
9 changes: 7 additions & 2 deletions packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1266,8 +1266,13 @@ export function reruns(u: Transaction): unknown {
// the screen). So does a memo the landing holds (CONFIG_HELD: its
// staged value is in this frame): the held runs read it, so shown
// first they showed the stale pass the re-derivation replaces — one
// frame reveals the change (F-1, A15; #4648).
frame ||= (r as any)._type || s === r || r._config & CONFIG_HELD;
// frame reveals the change (F-1, A15; #4648). A verdict-only reader
// (no REACTIVE_FRAME_READ: it never read the frame as committed) is
// not that frame — its re-derivation shows a verdict beside the
// screen, so the landing's runs need not wait for it (#4685).
frame ||=
!(r._config & CONFIG_VERDICT && !(r._flags & REACTIVE_FRAME_READ)) &&
((r as any)._type || s === r || r._config & CONFIG_HELD);
// A lane's reveal leaves the mark to the transaction: a reader that
// also read a held node as committed (`staleReader`) still observes
// that flight until it re-runs — cleared here, the transaction landed
Expand Down
22 changes: 21 additions & 1 deletion packages/signals/src/core/verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,27 @@ function verdictValue(el: Signal<any> | Computed<any>, c: Computed<any> | null):
(latestActive || !owner._x!._shownError)
)
throw owner._x!._error;
if (probing && flying) probeFound = true;
// A lane derivation's landed answer waits for the lane's reveal while
// the lane is blocked: what the reader sees (`v`, the screen — or the
// latest view inside `latest()`) is not the lane's latest then, so the
// answer is not final until the reveal shows it (#4685). An equal one
// is final now (A24), as is the latest view that already observes it.
// The reveal — the lane's seam, before and without its parent's
// landing — is what flips the verdict, so the reader re-derives there
// (`l._reruns`), not only at the parent's landing (`verdictRead`).
if (probing) {
const u = laneValueOf(el),
unrevealed =
u !== v &&
(!el._equals || !el._equals(u as any, v as any)) &&
// Only a blocked lane's landed answer waits: an unblocked
// staging reveals in this flush, so the display already moves
// with the probe's read (#4429's pulled pass — the probe
// answers the screen, not a round that is not stuck).
blocked(l);
if (flying || unrevealed) probeFound = true;
if (unrevealed && tracked && l._reruns[l._reruns.length - 1] !== c) l._reruns.push(c!);
}
if (__TEST__) shadowActual(latestActive && !flying ? SLOT_LV : SLOT_DV);
return v;
}
Expand Down
16 changes: 9 additions & 7 deletions packages/signals/tests/createOptimistic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3776,8 +3776,9 @@ describe("createOptimistic", () => {

// CRITICAL: tax text should update independently
expect(shown(taxTexts)).toEqual(["US_SALES_TAX", "UK_VAT"]);
// Tax's own async slot has resolved even though shipping is still pending.
expect(taxPendingComputes.at(-1)).toBe(false);
// Tax's answer has landed but is held unrevealed by shipping's
// flight in the same lane, so it stays pending until the reveal.
expect(taxPendingComputes.at(-1)).toBe(true);

// Shipping text should NOT have updated yet
expect(shown(shippingTexts)).toEqual(["FEDEX"]);
Expand Down Expand Up @@ -4179,8 +4180,9 @@ describe("createOptimistic", () => {

// CRITICAL: shipping text should update INDEPENDENTLY
expect(shown(shippingTexts)).toEqual(["FEDEX", "DHL"]);
// Shipping's own async slot has resolved independently.
expect(shippingPendingComputes.at(-1)).toBe(false);
// Shipping's answer has landed but is held unrevealed by tax's
// pending flight in the same lane, so it stays pending.
expect(shippingPendingComputes.at(-1)).toBe(true);

// Tax should NOT have updated yet (correction re-fetch still in flight)
expect(shown(taxTexts)).toEqual(["US_SALES_TAX"]);
Expand Down Expand Up @@ -4357,9 +4359,9 @@ describe("createOptimistic", () => {
await Promise.resolve();
flush();

// Shipping's own pending slot clears even though the merged total is
// still waiting on tax.
expect(shippingPendingComputes.at(-1)).toBe(false);
// Shipping's answer has landed but is held unrevealed by the merged
// lane still waiting on tax, so it stays pending.
expect(shippingPendingComputes.at(-1)).toBe(true);
// Tax also still pending
expect(taxPendingComputes.at(-1)).toBe(true);
// orderTotal hasn't updated (merged lane still pending)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import {
action,
createMemo,
createOptimistic,
createRenderEffect,
createRoot,
flush,
isPending
} from "../src/index.js";

function deferred<T = void>() {
let resolve!: (v: T) => void;
const promise = new Promise<T>(r => (resolve = r));
return { promise, resolve };
}

const tick = async () => {
for (let i = 0; i < 8; i++) await Promise.resolve();
flush();
};

describe("#4685 isPending over an unrevealed lane landing", () => {
it("stays true until the changed lane value is revealed", async () => {
const [opt, setOpt] = createOptimistic(0);
const left = deferred<number>();
const right = deferred<number>();
const screen = { left: "", pending: "", total: "" };
const frames: string[] = [];
let dispose!: () => void;
createRoot(d => {
dispose = d;
const leftMemo = createMemo(async () => {
const v = opt();
if (v === 0) return 0;
await left.promise;
return 10;
});
const rightMemo = createMemo(async () => {
const v = opt();
if (v === 0) return 0;
await right.promise;
return 20;
});
const total = createMemo(() => leftMemo() + rightMemo());
const bind = (k: keyof typeof screen, fn: () => unknown) =>
createRenderEffect(fn, v => {
screen[k] = String(v);
});
bind("left", leftMemo);
bind("pending", () => isPending(leftMemo));
bind("total", total);
});
flush();
const snap = () =>
frames.push(`left ${screen.left} | pending ${screen.pending} | total ${screen.total}`);
await tick();
snap();

const act = action(function* () {
setOpt(1);
yield new Promise(() => {});
});
act();
await tick();
snap();

left.resolve(10);
await tick();
snap();

right.resolve(20);
await tick();
snap();

expect(frames).toEqual([
"left 0 | pending false | total 0",
"left 0 | pending true | total 0",
"left 0 | pending true | total 0",
"left 10 | pending false | total 30"
]);
dispose();
});
});
4 changes: 2 additions & 2 deletions scripts/size/floor-caps.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"minified": 102212
},
"page: live server components (base + live/GET + action + isPending/latest)": {
"cap": "40.30 KB",
"minified": 121153
"cap": "40.35 KB",
"minified": 121282
},
"server: floor (getRequestEvent + isServer)": {
"cap": "1.29 KB",
Expand Down
Loading