Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/born-held-effect-lane-run-restages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A born-held render effect re-run as lane work (a `latest()` reader remounted during an action's hold) re-stages its value, so the commit applies the latest pass instead of the stale born-held one (fuzzer F13).
5 changes: 5 additions & 0 deletions .changeset/errored-pass-tail-still-observes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A render reader whose pass errored (NotReady included) before reaching a flight it read last time still holds that flight's transaction: a write no longer shows beside a reader still derived from the previous value while the flight is in the air (fuzzer F4).
5 changes: 5 additions & 0 deletions .changeset/landing-rejudges-parked-transactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A landing whose commits dispose a zombie re-judges the transactions parked at the same seam: a write held only because that zombie observed its flight now lands with the commit that disposes it instead of staying unpublished until an unrelated flush (fuzzer F3).
5 changes: 5 additions & 0 deletions .changeset/verdict-pull-outside-window.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A memo pulled by an `isPending`/`latest` probe computes as its own pass, outside the probe's window: it no longer caches the committed input in place of a flushed write (fuzzer F10), or reads an uninitialized input as `undefined` instead of suspending (F11).
5 changes: 5 additions & 0 deletions .changeset/verdict-reader-silent-landing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

An `isPending` reader of an async memo settles when the memo's flight lands equal to its committed value: the settle walk re-derives the verdict reader instead of leaving it `true` (fuzzer F9).
12 changes: 6 additions & 6 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

25 changes: 16 additions & 9 deletions packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,15 +249,22 @@ export function settlePendingSource(el: Computed<any>, source: Computed<any> = e
let released: Computed<any>[] | undefined;
const visited = new Set<Computed<any>>();
const settle = (node: Computed<any>) => {
if (visited.has(node)) return;
// A conditional dropped this source, but another dependency can still
// carry it. Only retire pending state inherited through the recovered
// branch. Deliberately NOT marked visited on this early return: the
// carrying dependency may itself be a later branch of this same walk
// (two unchanged memos converging), and its visit must be free to
// re-examine this node once that branch has retired the source.
if (source !== el && retryReaches(node, source)) return;
if (!removePendingSource(node, source)) return;
if (
visited.has(node) ||
// A conditional dropped this source, but another dependency can still
// carry it. Only retire pending state inherited through the recovered
// branch. Deliberately NOT marked visited on this early return: the
// carrying dependency may itself be a later branch of this same walk
// (two unchanged memos converging), and its visit must be free to
// re-examine this node once that branch has retired the source.
(source !== el && retryReaches(node, source)) ||
!removePendingSource(node, source)
)
// A19: a verdict reader holds no pending of its own (it was re-derived
// when the source went pending, `propagateStatus`), and a landing
// equal to the committed value notifies nobody — the source settling
// is its verdict changing: it runs again.
return node._config & CONFIG_VERDICT && enqueueSub(node);
visited.add(node);
node._time = clock;
const remaining = node._x?._pendingSources?.values().next().value;
Expand Down
7 changes: 4 additions & 3 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,9 +620,10 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
// revealed (its reveal shows it), as its staging once it has (the
// next reveal promotes it; the screen keeps the revealed value). An
// effect's value slot is private: the run the lane holds is what
// shows it.
if (isEffect) el._value = value;
else if (lane._shown) el._pendingValue = value;
// shows it — unless the effect was born held, whose first run is the
// commit's (A29): it re-stages, as below.
if (isEffect && el._pendingValue === NOT_PENDING) el._value = value;
else if (isEffect || lane._shown) el._pendingValue = value;
else el._x!._lane = value;
} else if (
create
Expand Down
10 changes: 8 additions & 2 deletions packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,9 @@ export function merge(t: Transaction, f: Transaction): void {
* one hop of `_subs` is the chain. Reads of the reader's LAST pass only (the
* link's generation is the pass's): a tail kept for A30 — the committed
* frame's dependency, awaiting the run that retires it — is not a read of
* the flight, and a reader that stopped reading releases it (O3, #3494). A
* the flight, and a reader that stopped reading releases it (O3, #3494).
* An errored pass (NotReady included) did not stop: it never got there, and
* its full list stands (A30) — the reads it did not reach still observe. A
* flight nobody renders holds nothing (A29). Only the nodes `t` still owns
* count: one a lane took over since is the lane's to wait on. A guess whose
* own truth is in flight blocks the lane's parent, not the lane — it stands
Expand Down Expand Up @@ -314,7 +316,7 @@ function blockedBy(nodes: Signal<any>[], owner: Transaction, own = false): boole
// over a held window, V5/A17). A probe alone does not hold.
if (
((r as any)._type === EFFECT_RENDER || r._flags & REACTIVE_FRAME_READ) &&
s._gen === r._depGen &&
(s._gen === r._depGen || r._x?._error != null) &&
!(r._flags & REACTIVE_DISPOSED) &&
onScreen(r, judge ?? owner)
)
Expand Down Expand Up @@ -834,6 +836,10 @@ export class GlobalQueue implements IQueue {
transactions.splice(k, 1);
GlobalQueue._endLanes?.(u);
land(u);
// A landing's commits can dispose a zombie a transaction judged above
// was blocked on (#3463: live "until the commit that disposes it") —
// judge them again.
k = transactions.length;
}
// The store folds the pending backings whose container nodes committed
// — this flush's, or a landing's (store/store.ts installs it).
Expand Down
32 changes: 20 additions & 12 deletions packages/signals/src/core/verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ import {
spectating,
strictRead,
tracking,
unflushedValue
unflushedValue,
verdict
} from "./core.js";
import { warnStrictReadUntracked } from "./dev.js";
import { NotReadyError } from "./error.js";
Expand Down Expand Up @@ -113,10 +114,6 @@ function observeFlight(c: Computed<any>, el: Computed<any>): void {
let latestActive = false;
let probing = false;
let probeFound = false;
/** `read` dispatches here while a window is open. */
function setWindows(): void {
setVerdict(latestActive || probing ? verdictValue : null);
}

/** A verdict read of a node a transaction (or lane) holds: the reading pass
* is the holder's verdict lane's and re-derives at the holder's landing
Expand Down Expand Up @@ -228,27 +225,31 @@ function markVerdictReader(window: number): void {
}
}

/** The windows. */
/** The windows. `read` dispatches to `verdictValue` while one is open; each
* restores the dispatch it found — none inside a memo's pass a window
* pulled (`verdictValue`). */
export function latest<T>(fn: () => T): T {
markVerdictReader(2);
const prev = latestActive;
const prev = latestActive,
prevVerdict = verdict;
latestActive = true;
setWindows();
setVerdict(verdictValue);
try {
return fn();
} finally {
latestActive = prev;
setWindows();
setVerdict(prevVerdict);
}
}

export function isPending(fn: () => any): boolean {
markVerdictReader(1);
const prevProbing = probing;
const prevFound = probeFound;
const prevVerdict = verdict;
probing = true;
probeFound = false;
setWindows();
setVerdict(verdictValue);
try {
fn();
return probeFound;
Expand All @@ -264,7 +265,7 @@ export function isPending(fn: () => any): boolean {
} finally {
probing = prevProbing;
probeFound = prevFound;
setWindows();
setVerdict(prevVerdict);
}
}

Expand Down Expand Up @@ -303,7 +304,14 @@ function verdictValue(el: Signal<any> | Computed<any>, c: Computed<any> | null):
const tracked = c !== null && tracking && !(c._config & CONFIG_CHILDREN_FORBIDDEN);
if (c !== null && tracking) {
link(el, c);
if (owner._fn !== undefined) pullComputed(owner, c);
if (owner._fn !== undefined) {
// A31: the pulled memo's pass is its own, not the probe's — it reads
// outside the window, and the probe's read below answers for what
// the pass produced.
setVerdict(null);
pullComputed(owner, c);
setVerdict(verdictValue);
}
}
// A live `affects()` mark on the node or a dependency (affects.ts): not
// final by declaration — the value itself is read as below. The verdict is
Expand Down
Loading
Loading