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
12 changes: 12 additions & 0 deletions .changeset/hold-consistency-batch-2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@solidjs/signals": patch
---

Five hold-consistency fixes (#3456, #3458, #3460, #3463, #3469)

- #3456: a pass that re-parks on a new pending source set retires the sources it stopped carrying from its dependents, so a conditional whose async branch was cancelled no longer stays pending forever on a flight it has no path to.
- #3458: a stale render reader that is a flight's first observer registers the flight with the transaction it reveals a reader of (INV-3, via the reader's queue chain), so the transaction waits for it instead of revealing its other inputs beside the reader's pre-flight value.
- #3460: lanes mirror transitions from the outside — a render effect off a held lane (mounted mid-hold, or re-run by an unrelated sync write) is served the committed value, publishes at once, entangles nothing, and re-runs at the lane's release; `latest()` and `createOptimistic` sources alike. Only direct reads return the override while the lane holds. Off the lane is provenance, not membership: a pass under the lane's own transaction (its async's landing) is the lane's work.
- Lanes stage (#3479 review): an optimistic derivation is an override. A lane pass on a memo publishes its speculative result as a _derived override_ instead of direct-committing `_value`, so the committed view an outsider sees is a whole frame — the held source and its derivations together, never a committed shadow beside a speculative memo. The revert promotes a derived override the truth confirmed (no re-ask waterfall) and re-derives one it superseded.
- #3463: a reader whose removal is staged in a live transaction (a zombie) is still on screen and keeps holding until the commit that disposes it; it is moot only for that transaction's own verdict.
- #3469 (A30): a pass that changed nothing replaced nothing either — its dependency trim waits on the flush's verdict (`heldTrims`), so a same-value branch switch under a hold still follows its committed inputs.
52 changes: 41 additions & 11 deletions packages/signals/docs/INTERNALS-ASYNC-STATE.md

Large diffs are not rendered by default.

725 changes: 357 additions & 368 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

26 changes: 14 additions & 12 deletions packages/signals/docs/SPEC-ASYNC-SEMANTICS.md

Large diffs are not rendered by default.

30 changes: 25 additions & 5 deletions packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
CONFIG_CHILD_COMPANIONS,
CONFIG_AUTO_DISPOSE,
CONFIG_DERIVED_OVERRIDE,
CONFIG_INPUTS_PUBLISHED,
CONFIG_SYNC,
EFFECT_TRACKED,
Expand All @@ -12,7 +13,8 @@ import {
REACTIVE_ZOMBIE,
STATUS_ERROR,
STATUS_PENDING,
STATUS_UNINITIALIZED
STATUS_UNINITIALIZED,
unwrapOverride
} from "./constants.js";
import { attrHooks } from "./attribution-hooks.js";
import { context, setSignal, untrack, ext, statusNotifierOf } from "./core.js";
Expand Down Expand Up @@ -500,7 +502,14 @@ export function handleAsync<T>(
return;
}
if (wasUninitialized) landStatus(el, true);
} else if (el._x?._overrideValue !== undefined) {
} else if (
el._x?._overrideValue !== undefined &&
!(lane && el._config & CONFIG_DERIVED_OVERRIDE)
) {
// A derived override's landing UNDER its lane is the lane's own work
// (the branch below); demoted — its source superseded (A18) — the
// landing is the truth the correction asked for, and holds and
// supersedes here like the sync twin (recompute). Otherwise:
// Optimistic node — resting OR covered by an active override — holds
// through the shared pending-node path, exactly like a plain async memo,
// so the commit clears STATUS_UNINITIALIZED (#2806) and elevation to
Expand Down Expand Up @@ -539,11 +548,15 @@ export function handleAsync<T>(
} else if (lane) {
// Route through lane's effect queue for independent flushing
const isEffect = (el as any)._type;
const prevValue = el._value;
const prevValue = hasActiveOverride(el) ? unwrapOverride(el._x!._overrideValue) : el._value;
const equals = el._equals;
try {
if ((!isEffect && wasUninitialized) || !equals || !equals(value, prevValue)) {
el._value = value;
// Lanes stage (#3479): a memo's landing under its lane is a derived
// override, as its sync pass's result is (recompute) — `_value`
// stays the committed truth for readers off the lane.
if (isEffect) el._value = value;
else GlobalQueue._laneOverride!(el, value, lane);
el._time = clock;
// The latest() shadow write gives latest() effects independent lanes; the
// _pendingSignal update is a no-op repeat of the clearStatus() call above
Expand Down Expand Up @@ -930,8 +943,15 @@ export function notifyStatus(
const pendingSource =
status === STATUS_PENDING && error instanceof NotReadyError ? error.source : undefined;
const isSource = pendingSource === el;
// An optimistic node (a WRITTEN override slot) pending derivatively is a
// boundary: its override is the answer, pending stops here (A17). A
// derived override (#3479) is a previous speculative answer on a plain
// member — pending flows through it as through any memo.
const isOptimisticBoundary =
status === STATUS_PENDING && el._x?._overrideValue !== undefined && !isSource;
status === STATUS_PENDING &&
el._x?._overrideValue !== undefined &&
!(el._config & CONFIG_DERIVED_OVERRIDE) &&
!isSource;
const startsBlocking = isOptimisticBoundary && hasActiveOverride(el);

if (!blockStatus) {
Expand Down
11 changes: 8 additions & 3 deletions packages/signals/src/core/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type InteractionRef,
type OriginRef
} from "./attribution-hooks.js";
import { $REFRESH, NOT_PENDING } from "./constants.js";
import { $REFRESH, CONFIG_DERIVED_OVERRIDE, NOT_PENDING } from "./constants.js";
import {
anyExcluded,
emitDiagnostic,
Expand Down Expand Up @@ -2101,9 +2101,14 @@ const holdStates = new WeakMap<Transition, HoldState>();
let activeHold: HoldState | null = null;
let holdLog: HoldEvent[] = [];

/** Companions are optimistic nodes too; `_parentSource` marks them. */
/** Companions are optimistic nodes too; `_parentSource` marks them. So is a
* memo carrying a DERIVED override (lanes stage, #3479) — a lane pass's
* result, not a write anyone made: neither is an acknowledgement. */
function isCompanion(node: Signal<any> | Computed<any>): boolean {
return !!node._x && node._x._parentSource !== undefined;
return (
(!!node._x && node._x._parentSource !== undefined) ||
(node._config & CONFIG_DERIVED_OVERRIDE) !== 0
);
}

const HOLD_CENSUS_CAP = 10_000;
Expand Down
8 changes: 8 additions & 0 deletions packages/signals/src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,14 @@ export const CONFIG_INPUTS_PUBLISHED = 1 << 21;
* a write is promoted at that recompute's end: readers in the same block see
* it. Cleared when the next flush begins; set only on that rare path. */
export const CONFIG_PROMOTED = 1 << 22;
/** The node's active override is a DERIVED one: a lane pass published its
* speculative result into the override slot instead of `_value` (lanes
* stage — an optimistic derivation is an override, #3479). Its truth is not
* `_value` but a recompute from its inputs' truth, so the body-end
* supersession (`endOptimism`) and the authoritative-flight blockage
* (`transitionBlocked`) skip it; the revert drops the override and re-derives
* it (`resolveOptimisticNodes`). Cleared with the override. */
export const CONFIG_DERIVED_OVERRIDE = 1 << 23;

export const STATUS_NONE = 0;
export const STATUS_PENDING = 1 << 0;
Expand Down
131 changes: 91 additions & 40 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
CONFIG_INPUTS_PUBLISHED,
CONFIG_NO_SNAPSHOT,
CONFIG_OPTIMISTIC,
CONFIG_DERIVED_OVERRIDE,
CONFIG_OVERRIDE_SUPERSEDED,
CONFIG_OWNED_WRITE,
CONFIG_PROMOTED,
Expand Down Expand Up @@ -97,6 +98,7 @@ import {
insertSubs,
projectionWriteActive,
queuePendingNode,
heldTrims,
runInTransition,
schedule,
wokenTransitions,
Expand Down Expand Up @@ -268,8 +270,11 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
}

let isOptimisticDirty = !!(el._flags & REACTIVE_OPTIMISTIC_DIRTY);
// A derived override (lanes stage, #3479) is override-covered like a written
// one: a plain pass over it — its source superseded (A18) — stages the truth
// and supersedes the override through the sync twin below.
const hasOverride =
(el._config & CONFIG_OPTIMISTIC) !== 0 &&
(el._config & (CONFIG_OPTIMISTIC | CONFIG_DERIVED_OVERRIDE)) !== 0 &&
el._x?._overrideValue !== NOT_PENDING &&
el._x?._overrideValue !== undefined;
const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED);
Expand Down Expand Up @@ -346,6 +351,21 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
// latest()/isPending() pull stages instead of direct-committing (#3009).
// The predicate lives with the engine (recomputeLane).
else if (lane === false) isOptimisticDirty = false;
} else if (el._config & CONFIG_DERIVED_OVERRIDE) {
// Lanes stage (#3479): a pass over a live lane member carrying a derived
// override is the lane's pass whatever channel dirtied it (a boundary
// reset, an unrelated sync write) — its inputs serve the lane's view, so
// its result is the lane's and belongs in the override slot. Run plain,
// A18's sync twin below read that re-derived lane view as a differing
// truth (a fresh array), superseded the override and demoted the lane;
// the lane's next pass then dropped the staged "truth" and left the node
// flagged superseded with nothing to serve (fuzzer latest-1 #2481). A
// demoted node resolves no lane and stays plain: its pass IS the truth.
const lane = GlobalQueue._recomputeLane!(el, true);
if (lane) {
isOptimisticDirty = true;
currentOptimisticLane = lane;
}
} else if (activeTransition && !create && activeTransition._optimisticNodes.length) {
// Lane adoption: parent-deeper-than-owned-child can run before its OPT-dirty
// child propagates. Walk deps once and inherit the OPT lane so this node
Expand Down Expand Up @@ -432,6 +452,18 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
// The replacement source is fully propagated now. If no new flight
// re-owned self, retire the superseded flight and its dependent copies.
if (notReady && wasPendingSource && !el._x?._inFlight) settlePendingSource(el);
// A re-park drops what the earlier pass carried (#3456): a source this
// pass no longer reaches — its branch switched, or a fresh flight
// replaced the inputs' pending with its own — stays copied onto
// dependents that reached it only through here, and its landing walk
// stops at this node (nothing left to retire) before it finds them. A
// dependent then waits forever on a flight it has no path to. The
// re-park twin of the unchanged-value recovery sweep below; dependents
// with another path keep the source (retryReaches).
if (notReady && outgoingPendingSources)
for (const source of outgoingPendingSources)
if (source !== el && !el._x?._pendingSources?.has(source))
settlePendingSource(el, source);
if (reaskChanged) GlobalQueue._repollVerdicts!(el);
}
} finally {
Expand Down Expand Up @@ -573,21 +605,24 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
// round-trip is that separation; it cannot be skipped on any path a
// pull can reach.
) {
el._value = value;
// Lane-propagated correction: upstream data is fresh, correct the
// override unconditionally. The direct _value commit is the lane's
// own reveal schedule; drop any superseded older hold so its queued
// commit can't clobber the fresh value. Override or not: a node that
// adopted the lane through its deps (a `latest()` read — the
// companion is an optimistic node) direct-commits the same way, and
// a hold it staged on an earlier, lane-free pass of the SAME
// transaction is just as superseded — left in place, the commit
// published the older frame over the fresh one (#3377).
if (isOptimisticDirty) {
if (hasOverride)
ext(el)._overrideValue = value === undefined ? OVERRIDE_UNDEFINED : value;
el._pendingValue = NOT_PENDING;
}
// Lanes stage (#3479): a lane pass on a memo publishes its speculative
// result as an OVERRIDE — `_value` stays the committed truth, so a
// reader off the lane (A17's committed view, #3460) sees a whole
// committed frame: the source's shadow and its derivations together,
// never a committed shadow beside a speculative memo. The lane's own
// readers and untracked reads see the override (A17); the revert
// drops it and re-derives (resolveOptimisticNodes). Effects keep the
// direct commit — their `_value` is a run result the lane's queues
// already sequence. Either way the lane pass drops any superseded
// older hold so its queued commit can't clobber the fresh frame: a
// hold staged on an earlier, lane-free pass of the SAME transaction
// is superseded — left in place, the commit published the older
// frame over the fresh one (#3377). A reversion pass (OPT-dirty with
// no lane — the override dropped) commits directly: it IS the truth.
if (isOptimisticDirty && !isEffect && currentOptimisticLane !== null)
GlobalQueue._laneOverride!(el, value, currentOptimisticLane);
else el._value = value;
if (isOptimisticDirty) el._pendingValue = NOT_PENDING;
} else {
el._pendingValue = value;
if (__DEV__) devTrackHeldPending(el);
Expand Down Expand Up @@ -702,18 +737,24 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
// so a write to a dependency the committed value still derives from
// reaches this node — and joins its hold if the stage is transaction-held
// by then (a plain flush decides nothing here: the transaction that holds
// the pass may open later in the same flush). A pass that published, or
// changed nothing, trims now. An errored pass (a throw, NotReady included,
// or a comparator throw above) keeps its full list as before — `_depsTail`
// marks where it stopped — and the commit skips it by the same `_error`.
// An effect's frame is the run its value is applied by, not the value slot
// (#3438): a direct-committed pass that still owes a run (`_modified`) has
// not replaced what the last run published — the same flush may stash that
// run into a transaction it opens later — so its tail waits for `runEffect`
// to trim once the run applies. A pass that changed nothing owes no run
// and trims here.
if (!el._x?._error && el._pendingValue === NOT_PENDING && !(isEffect && (el as any)._modified))
trimStaleDeps(el);
// the pass may open later in the same flush). A pass that published
// directly (a first pass, a lane's own reveal) trims now. An errored pass
// (a throw, NotReady included, or a comparator throw above) keeps its full
// list as before — `_depsTail` marks where it stopped — and the commit
// skips it by the same `_error`. An effect's frame is the run its value is
// applied by, not the value slot (#3438): a direct-committed pass that
// still owes a run (`_modified`) has not replaced what the last run
// published — the same flush may stash that run into a transaction it
// opens later — so its tail waits for `runEffect` to trim once the run
// applies. A pass that changed nothing replaced nothing either (#3469): the
// same flush may park with its inputs held, and the committed frame still
// derives from the tail — its trim waits on the flush's verdict (heldTrims).
// A tracked effect's pass IS its run (it bypasses the heap and runs after
// the commit): the frame is replaced, trim now.
if (!el._x?._error && el._pendingValue === NOT_PENDING && !(isEffect && (el as any)._modified)) {
if (create || isOptimisticDirty || isEffect === EFFECT_TRACKED) trimStaleDeps(el);
else if ((el._depsTail as Link | null)?._nextDep ?? el._deps) heldTrims.push(el);
}
// Attribution hook: fired before the lane restore so `currentOptimisticLane`
// still reflects THIS run's posture. The facts distinguish an overlay
// recompute (optimistic lane, transition replay, transition-held commit)
Expand Down Expand Up @@ -1483,10 +1524,16 @@ export function installAuthoritativeRead(): void {
* reporter the transaction recorded when the flight started may be gone (a
* keyed remount disposed it, #3374); a completion check that found no live
* reporter committed the writes ahead of the answer, tearing the new
* reader's frame (`Count: 1` beside `Details: 0`). Joins only an entry the
* transaction already holds — a staged signal or a settled node has none;
* INV-3: entries open from queue notification alone, so a boundary-consumed
* flight stays consumed — and dies with the reader like every reporter
* reader's frame (`Count: 1` beside `Details: 0`). Joins an entry the
* transaction already holds; a flight nobody had observed yet has none (the
* reader is its first observer — a conditional that just revealed it, #3458)
* and is notified up the reader's own queue chain under that transaction,
* the one sanctioned registration site (INV-3): a collecting boundary above
* the reader consumes it as it would any pending, an unboundaried reader
* opens the entry — and the transaction, judged complete on its other
* flights, revealed the inputs beside the reader's pre-flight value
* otherwise (`Count: 1 | A: 1` beside `B: 0`). A staged signal or a settled
* node registers nothing. Every reporter dies with its reader
* (reporterBlocksSource: the read linked it as a dep). The node's own entry
* is the only one that can matter: a chain's intermediate memo is re-pulled
* by the read (updateIfNecessary's retry) and enters the transaction, so the
Expand All @@ -1500,7 +1547,10 @@ function heldFromStale(el: Signal<any> | Computed<any>, c: Computed<any>): boole
const txn = currentTransition(t);
const vt: Transition | null | undefined = (c as any)._valueTransition;
if (vt == null || currentTransition(vt) !== txn) txn._gatedSubs.add(c);
txn._asyncReporters.get(el as Computed<any>)?.add(c);
const reporters = txn._asyncReporters.get(el as Computed<any>);
if (reporters) reporters.add(c);
else if ((el as Computed<any>)._statusFlags & STATUS_PENDING)
runInTransition(txn, () => c._queue.notify(c, STATUS_PENDING, STATUS_PENDING, el._x!._error));
return true;
}

Expand Down Expand Up @@ -1874,13 +1924,14 @@ export function read<T>(el: Signal<T> | Computed<T>): T {
// yet the active override — fall through to the normal selection (the
// authoritative mark below still applies: until() must wake on landing).
if (!(c && c._config & CONFIG_AUTHORITATIVE_READ) && !unflushedOverride(el)) {
// A18 supersession (#3331): the node's own source answered with a
// DIFFERENT value. The optimism is over for the graph — a tracked
// reader sees the staged truth — while the override remains the
// DISPLAYED value for untracked reads (and for a stale reader of some
// other transaction). The selection lives with the engine.
if (c && el._config & CONFIG_OVERRIDE_SUPERSEDED)
return GlobalQueue._supersededRead!(el) as T;
// A tracked read of an override is the engine's selection (a lane or a
// supersession implies the engine): a render effect OFF the override's
// held lane sees the committed value (#3460, lanes mirror transitions);
// a node whose own source answered with a DIFFERENT value hands a
// tracked reader the staged truth (A18 supersession, #3331). Untracked
// reads display the override.
if (c && el._config & (CONFIG_HAS_LANE | CONFIG_OVERRIDE_SUPERSEDED))
return GlobalQueue._overrideRead!(el as Computed<any>, c as Computed<any>) as T;
return unwrapOverride<T>(el._x?._overrideValue);
}
el._config |= CONFIG_AUTHORITATIVE_OBSERVED;
Expand Down
Loading
Loading