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
5 changes: 5 additions & 0 deletions .changeset/body-end-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

The window after an action body ends and its override is superseded by the committed truth (#3427) now reads like a landing supersession for every reader: a stale reader re-run by an unrelated write keeps displaying the override, a memo created mainline during the window is held with the transaction, and `isPending()` reads true while the truth differs from the override (`latest()` already answered the truth). The node carries no transaction stamp in that window — an override written inside an action never passes the adoption loop that stamps one — so `supersededRead` and the verdict now resolve the owning transaction through `_overrideOwner`. Also: a `latest()` / `isPending()` pull from mainline never enters a transaction (it is an observation); one did through the supersession path and captured the caller's synchronous block.
5 changes: 5 additions & 0 deletions .changeset/latest-seed-invisibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

`latest(() => store.key)` on a derived store (projection) that has not yet resolved threw for tracked and untracked reads but returned the **seed** through `latest()`: `read()` routes a `latest()` read to the companion before its firewall/status logic, and the leaf's own `_value` is the seed. `latest()` now judges "uninitialized" on the leaf's owner — the projection's firewall — and throws `NotReadyError` like every other read (A25: the seed is a draft, never a value; A7).
192 changes: 96 additions & 96 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions packages/signals/docs/SPEC-ASYNC-SEMANTICS.md

Large diffs are not rendered by default.

33 changes: 20 additions & 13 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1506,26 +1506,33 @@ function heldFromStale(el: Signal<any> | Computed<any>, c: Computed<any>): boole
* transaction (born held) and mainline is never touched. */
let stagedEntry: Transition | null = null;

export function enterStagedRead(el: Signal<any> | Computed<any>): void {
const t = el._transition;
if (t === null || t === activeTransition || pendingCheckActive) return;
export function enterStagedRead(
el: Signal<any> | Computed<any>,
t: Transition | null | undefined = el._transition
): void {
if (!t || t === activeTransition || pendingCheckActive) return;
// Verdict machinery (GlobalQueue._verdictPull: companion creation and the
// latest()/isPending() pulls — the latest() shadow is created before it is
// marked optimistic, so the bit alone cannot tell) and optimistic nodes
// (own lane posture, A31) read staged truth by design; they keep the
// entering path.
// (`context` is non-null here: every caller selected a value for a reader.)
const ctx = context as Computed<any>;
if (
activeTransition === null &&
!globalQueue._running &&
!GlobalQueue._verdictPull &&
ctx._flags & REACTIVE_RECOMPUTING_DEPS &&
!(ctx._config & CONFIG_OPTIMISTIC) &&
(stagedEntry === null || stagedEntry === t)
)
stagedEntry = t;
else globalQueue.initTransition(t);
if (activeTransition === null && !globalQueue._running) {
// Verdict pulls are observations, not derivations: a latest() /
// isPending() call from mainline must never enter a transaction (it
// would capture the rest of the caller's synchronous block).
if (GlobalQueue._verdictPull) return;
if (
ctx._flags & REACTIVE_RECOMPUTING_DEPS &&
!(ctx._config & CONFIG_OPTIMISTIC) &&
(stagedEntry === null || stagedEntry === t)
) {
stagedEntry = t;
return;
}
}
globalQueue.initTransition(t);
}

export function readNodeFast<T>(el: Signal<T>): T | typeof READ_SLOW {
Expand Down
20 changes: 13 additions & 7 deletions packages/signals/src/core/optimistic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,13 +338,19 @@ function endOptimism(transition: Transition): boolean {
* OTHER transaction, the same visibility a foreign transaction's staged
* write has. */
function supersededRead(el: OptimisticNode): unknown {
if (stale && el._transition && activeTransition !== el._transition)
return unwrapOverride(el._x?._overrideValue);
if (el._pendingValue === NOT_PENDING) return el._value;
// The staged truth is a staged read like any other (A29): the pass that
// derives from it is the transaction's.
enterStagedRead(el);
return el._pendingValue;
// The owning transaction: `_overrideOwner` (#2912), not the stamp — an
// override written directly inside an action never passes the adoption
// loop that stamps `_transition`, and a body-end supersession (#3427)
// stages nothing that would queue it. Without the owner a stale reader of
// a body-ended node read the committed truth beside a display still
// showing the override.
const owner = resolveTransition(el);
if (stale && owner && activeTransition !== owner) return unwrapOverride(el._x?._overrideValue);
// A superseded read is a staged read (A29) whether the truth is staged or
// already committed: the pass that derives from it derives from the
// owning transaction's world (the override is still displayed by it).
enterStagedRead(el, owner);
return el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value;
}

/**
Expand Down
30 changes: 26 additions & 4 deletions packages/signals/src/core/verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import {
STATUS_ERROR,
STATUS_PENDING,
STATUS_UNINITIALIZED,
CONFIG_HAS_COMPANIONS
CONFIG_HAS_COMPANIONS,
CONFIG_OVERRIDE_SUPERSEDED
} from "./constants.js";
import {
context,
Expand Down Expand Up @@ -219,6 +220,15 @@ function computePendingState(el: Signal<any> | Computed<any>): boolean {
// the window's own landing in flight to its commit — verdict-quiet like the
// rest of the window (the UNINITIALIZED check suppresses exactly this frame
// for windowless first loads; born-committed nodes need their own gate, #2990).
// A18 (d) for a body-end supersession (#3427): the truth at hand is the
// COMMITTED value — nothing staged — yet the display still shows the
// override; pending iff they differ, as for a staged arrival below.
if (
el._config & CONFIG_OVERRIDE_SUPERSEDED &&
el._pendingValue === NOT_PENDING &&
hasActiveOverride(el)
)
return !el._equals || !el._equals(el._value as any, unwrapOverride(el._x?._overrideValue));
if (el._pendingValue !== NOT_PENDING && !comp._loading) {
// A18 (d): under a displayed override the observable value is the
// override, so the verdict is "the arrived truth differs from it" —
Expand Down Expand Up @@ -392,6 +402,16 @@ function getLatestValueComputed<T>(el: Signal<T> | Computed<T>): Computed<T> {
}

/** The latest()-mode read path, installed as GlobalQueue._latestRead. */
/** A7: the source has no visible value yet — judged on the OWNER, as read()
* does: a store leaf behind a projection's firewall is a plain signal whose
* `_value` is the seed (A25: a draft, never a value), and read() routes a
* latest() read here before its own firewall/status logic. An override
* displays a value even before the first commit (A17). */
function uninitializedSource(el: Signal<any> | Computed<any>): boolean {
const owner = ((el as FirewallSignal<any>)._firewall || el) as Computed<any>;
return !!(owner._statusFlags & STATUS_UNINITIALIZED) && !hasActiveOverride(el);
}

function latestRead<T>(el: Signal<T> | Computed<T>): T {
const pendingComputed = getLatestValueComputed(el);
const prevPending = latestReadActive;
Expand Down Expand Up @@ -440,13 +460,15 @@ function latestRead<T>(el: Signal<T> | Computed<T>): T {
// uninitialized source has no visible value — latest() throws in every
// scope rather than fabricate `undefined` for a `T` that excludes it
// (A7; the unowned scope used to return undefined here).
if (e instanceof NotReadyError && !((el as Computed<T>)._statusFlags & STATUS_UNINITIALIZED))
return visibleValue;
if (e instanceof NotReadyError && !uninitializedSource(el)) return visibleValue;
throw e;
} finally {
setLatestReadActive(prevPending);
}
if (pendingComputed._statusFlags & STATUS_PENDING) return visibleValue;
if (pendingComputed._statusFlags & STATUS_PENDING) {
if (uninitializedSource(el)) throw new NotReadyError(el);
return visibleValue;
}
if (stale && currentOptimisticLane && pendingComputed._x?._optimisticLane) {
const pcLane = findLane(pendingComputed._x?._optimisticLane);
const curLane = findLane(currentOptimisticLane);
Expand Down
113 changes: 113 additions & 0 deletions packages/signals/tests/body-end-supersession-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* A18 body-end corollary (#3427) — visibility during the correction window.
*
* When the action bodies have ended and nothing authoritative is in flight,
* each override is superseded by the truth at hand — here the COMMITTED value,
* since no landing staged anything. The window that follows must look like a
* landing supersession to every reader: the display keeps the override until
* the commit (A18 c), a fresh derivation is held (A29), and the verdict says
* the truth differs (A18 d). Found by the visibility oracle: with nothing
* staged the node carried no `_transition` stamp (an override written inside
* an action never passes the adoption loop), so `supersededRead` served the
* committed truth to a stale reader beside a display still showing the
* override, a fresh memo published it, and `isPending` read false while
* `latest` read the truth. Ownership for an override node is `_overrideOwner`
* (#2912); the read path and the verdict now resolve it.
*/
import { describe, expect, it } from "vitest";
import {
action,
createMemo,
createOptimistic,
createRenderEffect,
createRoot,
createSignal,
flush,
isPending,
latest
} from "../src/index.js";

const settle = async () => {
await Promise.resolve();
await Promise.resolve();
flush();
};

describe("A18 body-end supersession: the correction window", () => {
it("display keeps the override, a stale re-run keeps it, a fresh derivation is held, the verdict says it differs", async () => {
const flights: Array<() => void> = [];
const [u, setU] = createSignal(0);
const staleLog: number[] = [];
let x!: () => number;
let setX!: (v: number) => void;
createRoot(() => {
[x, setX] = createOptimistic(0);
const downstream = createMemo(() => {
const n = x();
return new Promise<string>(r => flights.push(() => r(`${n}!`)));
});
createRenderEffect(downstream, () => {});
});
flush();
flights.shift()!(); // prime downstream(0)
await settle();
// A pre-existing reader in another root (a sibling component), also
// tracking an unrelated signal so it can be re-run mainline.
createRoot(() => {
createRenderEffect(
() => {
u();
return x();
},
v => {
staleLog.push(v);
}
);
});
flush();
staleLog.length = 0;

action(function* () {
setX(1);
yield Promise.resolve(); // the body ends; downstream(1) is still up
})();
flush();
await settle();
await settle();
// Body-end: the override (1) is superseded by the committed truth (0); the
// graph re-derives (a downstream flight for 0 starts) and the transaction
// waits for it.
expect(flights.length).toBe(2);
expect(x()).toBe(1); // display
expect(latest(x)).toBe(0); // truth
expect(isPending(x)).toBe(true); // differs
// (Reading the verdicts here is deliberate: a latest() pull once entered
// the owning transaction ambiently and the two checks below depended on
// whether latest() had been called first.)

// A stale reader re-run by an unrelated write keeps displaying the override.
setU(1);
flush();
expect(staleLog).toEqual([1]);

// A fresh mainline derivation derives from the truth and is held.
const fresh: number[] = [];
createRoot(() => {
const m = createMemo(() => x());
createRenderEffect(m, v => {
fresh.push(v);
});
});
flush();
expect(fresh).toEqual([]);

// The 0-flight lands: the transaction commits, everything reveals at once.
for (const f of flights.splice(0)) f();
await settle();
await settle();
expect(x()).toBe(0);
expect(isPending(x)).toBe(false);
expect(fresh).toEqual([0]);
expect(staleLog.at(-1)).toBe(0);
});
});
Loading
Loading