Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/ispending-memo-unstamped-hold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@solidjs/signals": patch
---

A memo wrapping `isPending(x)` agrees with a direct `isPending(x)` read while a sibling async memo holds the write (#3457).

- The fresh-read pairing rule (A10) only mutes a verdict for a LANDED answer awaiting reveal; while the transaction still has an async source computing, pending is the verdict for every reader. That carve-out was gated on the node's `_transition` stamp, but a sync memo staged AFTER the transaction opened is pushed straight into the transaction's batch and is not stamped until the flush stashes the hold. A wrapper memo recomputing on the companion flip read the memo's fresh staged value mid-flush, was told "not pending", and cached `false` for the whole hold, while the direct render-effect probe (which reads the committed value under the companion lane) reported `true`. The scan now runs for an unstamped node too: the transaction it resolves to is the one that owns its staged write.
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:109` | verdict.ts×2 | spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.test.ts×2 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. \*\*Amende… |
| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | — | createMemo.test.ts×1 visibility-oracle-store.test.ts×1 visibility-oracle.test.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… |
| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:125` | — | spec-async-semantics.test.ts×3 visibility-oracle-store.test.ts×5 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… |
| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:133` | invariants.ts×1 | createMemo.test.ts×1 latest-isPending-consistency.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. |
| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:133` | invariants.ts×1 verdict.ts×1 | createMemo.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. |
| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:51` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.test.ts×1 visibility-oracle.test.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… |
| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:141` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… |
| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:149` | async.ts×1 | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … |
Expand Down
2 changes: 1 addition & 1 deletion packages/signals/docs/SPEC-ASYNC-SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedVa

**Status:** **ruled** — #2831 finding 2
**Pinned by:** `tests/latest-isPending-consistency.test.ts`
**Mechanism (index, 2026-09-14):** `_recordFresh` (#2831): a probe that observed the fresh value cannot pair it with `pending`.
**Mechanism (index, 2026-09-14):** `_recordFresh` (#2831): a probe that observed the fresh value cannot pair it with `pending`. The pairing only covers a LANDED answer awaiting reveal: while the transaction still has an async source computing, the fresh value is an input and pending stays the verdict for every reader (`heldAwaitingAsync`, #3028) — including a node staged after the transaction opened, which carries no `_transition` stamp until the flush stashes the hold (#3457).

`[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it.

Expand Down
13 changes: 9 additions & 4 deletions packages/signals/src/core/verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,10 +569,15 @@ function heldAwaitingAsync(el: Signal<any> | Computed<any>): boolean {
// action (#2831: a reader that saw the new value must not also see
// pending); still-computing answers are covered by the reporter scan.
if (t._actions.length && !(el as Partial<Computed<any>>)._fn) return true;
// A node not yet stamped with a transition only qualifies through the
// action check above; the reporter scan below is for transition-held
// writes whose source async is still computing.
if (!et) return false;
// The reporter scan runs for an unstamped node too (#3457): a node staged
// AFTER the transaction opened is pushed straight into the transaction's
// batch (queuePendingNode, once initTransition adopted it) and only gets
// its `_transition` stamp when the flush stashes the hold, but its staged
// value is already the transaction's, and `t` resolved to that very
// transaction above. Gating on the stamp let a memo whose recompute read
// a sync memo's fresh staged value mid-flush pair "not pending" with it
// (A10) while the transaction's async source was still computing, so a
// memo-wrapped isPending() read false where a direct probe read true.
for (const [source, reporters] of t._asyncReporters) {
if (
reporters.size &&
Expand Down
85 changes: 85 additions & 0 deletions packages/signals/tests/ispending-memo-unstamped-hold-3457.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* #3457: a memo wrapping isPending(copy) must agree with a direct
* isPending(copy) read while a sibling async memo holds the write.
*
* `slow` pends first and opens the transaction; `copy` then stages its value
* straight into the transaction's batch, unstamped until the flush stashes
* the hold. The wrapper memo recomputes on the companion flip, reads copy's
* fresh staged value, and the A10 pairing rule must not mute the verdict:
* the transaction's async source is still computing (#3028), stamp or not.
*/
import { afterEach, describe, expect, it } from "vitest";
import {
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
isPending
} from "../src/index.js";

afterEach(() => flush());

const delay = <T>(ms: number, value: T) => new Promise<T>(r => setTimeout(r, ms, value));

describe("memo-wrapped isPending agrees with a direct read through a hold (#3457)", () => {
it("A10 / #3457 isPending(copy) inside a memo reports the hold like the direct probe", async () => {
let setCount!: (v: number) => void;
let direct: boolean | undefined;
let viaMemo: boolean | undefined;
let slowShown: number | undefined;
createRoot(() => {
const [count, set] = createSignal(0);
setCount = set;
const slow = createMemo(() => delay(20, count()));
const copy = createMemo(() => count());
const pending = createMemo(() => isPending(copy));
createRenderEffect(
() => count(),
() => {}
);
createRenderEffect(
() => slow(),
v => {
slowShown = v;
}
);
createRenderEffect(
() => isPending(copy),
v => {
direct = v;
}
);
createRenderEffect(
() => pending(),
v => {
viaMemo = v;
}
);
});
flush();
await delay(40, 0);
flush();
expect(slowShown).toBe(0);
expect(direct).toBe(false);
expect(viaMemo).toBe(false);

setCount(1);
flush();
// Held: the old value displays, both probes report pending.
expect(slowShown).toBe(0);
expect(direct).toBe(true);
expect(viaMemo).toBe(true);
await delay(5, 0);
flush();
expect(slowShown).toBe(0);
expect(direct).toBe(true);
expect(viaMemo).toBe(true);

await delay(60, 0);
flush();
expect(slowShown).toBe(1);
expect(direct).toBe(false);
expect(viaMemo).toBe(false);
});
});
65 changes: 65 additions & 0 deletions packages/web/test/ispending-memo-unstamped-hold-3457.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* @jsxImportSource @solidjs/web
* @vitest-environment jsdom
*/

// #3457: a memo wrapping isPending(copy) must agree with a direct
// isPending(copy) read in JSX while a sibling async memo holds the write.
import { describe, expect, test } from "vitest";
import { createMemo, createSignal, flush, isPending } from "solid-js";
import { render } from "@solidjs/web";

const delay = <T = void,>(ms: number, value?: T) =>
new Promise<T>(r => setTimeout(r, ms, value as T));

function snap(div: HTMLElement) {
return Array.from(div.querySelectorAll("p"))
.map(p => p.textContent)
.join(" | ");
}

function App() {
const [count, setCount] = createSignal(0);
const slow = createMemo(() => delay(100, count()));
const copy = createMemo(() => count());
const pending = createMemo(() => isPending(copy));
return (
<>
<button onClick={() => setCount(1)}>Run</button>
<p>Count: {count()}</p>
<p>Slow: {slow()}</p>
<p>Direct pending: {String(isPending(copy))}</p>
<p>Memo pending: {String(pending())}</p>
</>
);
}

describe("memo-wrapped isPending agrees with a direct read through a hold (#3457)", () => {
test("A10 / #3457 both probes read true for the whole hold, false after the reveal", async () => {
const div = document.createElement("div");
document.body.appendChild(div);
const dispose = render(() => <App />, div);
await delay(120);
flush();
expect(snap(div)).toBe("Count: 0 | Slow: 0 | Direct pending: false | Memo pending: false");

div.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const log: string[] = [];
for (let t = 0; t <= 140; t += 20) {
flush();
log.push(snap(div));
await delay(20);
}
const held = log.filter(l => l.includes("Slow: 0"));
expect(held.length).toBeGreaterThan(0);
for (const l of held) {
expect(l, l).toBe("Count: 0 | Slow: 0 | Direct pending: true | Memo pending: true");
}
expect(log[log.length - 1]).toBe(
"Count: 1 | Slow: 1 | Direct pending: false | Memo pending: false"
);

dispose();
div.remove();
});
});
Loading