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/owns-hold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

One ownership relation, `ownsHold`, answers "is this hold part of the running pass's world" for the stale-reader clause, the lane arm and the store's backing holds — a refactor with no behavior change, recording the ruling that a lane is a transaction with an override whose world includes the transition that owns it.
5 changes: 5 additions & 0 deletions .changeset/serve-one-slow-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

One slow value selection, `serve`, for signal reads and store property nodes (the fast paths keep their inline ternary). Fixes a derivation's untracked read of a derived optimistic store's key after its own truth landed differently from the optimistic edit: the store served the memo the superseded override and let it publish, where a signal serves the landed truth and holds the memo with the action (A18).
5 changes: 5 additions & 0 deletions .changeset/store-backing-stale-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A render effect's untracked read of a store key held by a foreign action — a key with no node, or a `reconcile` adoption held by the action — is now recorded for replay at the action's commit, as the signal path always was. Previously the store's backing-level selection served the committed value but skipped the registration, so the effect stayed on the pre-action value after the action settled. One registration (`recordStaleReplay`) is shared by the node path and the store's backing paths.
5 changes: 5 additions & 0 deletions .changeset/store-hold-visible.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

The store's backing-level visibility (which container — committed or staged — a reader of a held store sees, for property reads, `in`, `Object.keys` and descriptors) is one `holdVisible` on the core's shared predicates for both hold kinds (a setter's fold, an adoption under a transaction), replacing six store-local helpers. No behavior change; −313 B minified in the store.
5 changes: 5 additions & 0 deletions .changeset/store-node-rule1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

Store node reads select committed-vs-staged by the core's `readerSeesCommitted` (one Rule 1 implementation for signals and store nodes). Fixes a render effect's untracked read of a store key held by a foreign action never replaying at that action's commit — the store's hand-restated stale-of-foreign clause served the committed value but skipped the replay registration the signal path performs, leaving the effect on the pre-action value permanently.
5 changes: 5 additions & 0 deletions .changeset/store-override-survives-unobserved.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

An optimistic store override now survives its key becoming unobserved. The property node was released with the override on it the moment its last reader left, so an untracked read of the key (`s.n`) returned the committed value while the action was still live; the release now waits for the flush that resolves the override, as an optimistic signal keeps its override whether or not anything reads it.
5 changes: 5 additions & 0 deletions .changeset/store-presence-override-survives-unobserved.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

An optimistic add or delete on a store now survives its only structural observer leaving: the key's presence node was released with the membership override on it, so `in`, `Object.keys` and property descriptors fell back to the committed structure while the action was still live. The release now waits for the flush that resolves the override, as the value slot's does.
5 changes: 5 additions & 0 deletions .changeset/store-untracked-born-held.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A memo or user effect created on mainline whose untracked read (`untrack(() => s.n)`, `deep(s)`) is of a store key held by a live action is now born held (A29), as the same read of a signal is: the pass enters the action's transaction and publishes nothing until the action commits. Previously the store's untracked paths served the held value without entering, so a mainline memo published the action's unrevealed write to the screen. Covers keys with and without a node and `reconcile` adoptions held by an action.
144 changes: 144 additions & 0 deletions packages/signals/docs/DESIGN-CONSOLIDATION.md

Large diffs are not rendered by default.

727 changes: 369 additions & 358 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

140 changes: 100 additions & 40 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1562,12 +1562,40 @@ export function installAuthoritativeRead(): void {
* also pending on an upstream re-ask blocks through that flight until it
* lands, and its landing re-runs the reader into the normal path.
*/
/** The replay half of the stale-of-foreign clause (A15 / A26): a stale reader
* served the committed value because `txn` holds what it read re-runs at
* txn's commit, when the value it was denied becomes the frame — unless its
* own last value already came from that transaction. One registration for
* the node path (heldFromStale) and the store's backing paths, which have
* no node to carry the hold (heldFromReader, the adoption hold view). */
export function recordStaleReplay(txn: Transition, c: Computed<any>): void {
const vt: Transition | null | undefined = (c as any)._valueTransition;
if (vt == null || currentTransition(vt) !== txn) txn._gatedSubs.add(c);
}

/**
* The ownership relation (DESIGN-CONSOLIDATION §6, ruled 2026-09-17): is
* `hold` part of the running pass's world? A plain reader's world is the
* transaction it runs under, through merges. A lane reader's world is its
* lane AND the transition that owns the lane — the one asymmetry between a
* lane and a separate transaction (a lane sees what lands from its parent as
* its own; a separate transaction would wait for the parent to settle) —
* see `ownsLane` in lanes.ts, built on this. One relation for the
* stale-of-foreign clause (heldFromStale), the lane arm (readsHeldCommitted)
* and the store's backing holds (foreignHold); `serve` has no lane arm of
* its own, the lane's extra visibility lives here.
*/
export function ownsHold(hold: Transition): boolean {
return (
activeTransition !== null && currentTransition(hold) === currentTransition(activeTransition)
);
}

function heldFromStale(el: Signal<any> | Computed<any>, c: Computed<any>): boolean {
const t = el._transition;
if (t === null || t === activeTransition) return false;
if (t === null || ownsHold(t)) return false;
const txn = currentTransition(t);
const vt: Transition | null | undefined = (c as any)._valueTransition;
if (vt == null || currentTransition(vt) !== txn) txn._gatedSubs.add(c);
recordStaleReplay(txn, c);
const reporters = txn._asyncReporters.get(el as Computed<any>);
if (reporters) reporters.add(c);
else if ((el as Computed<any>)._statusFlags & STATUS_PENDING)
Expand Down Expand Up @@ -1597,16 +1625,18 @@ function heldFromStale(el: Signal<any> | Computed<any>, c: Computed<any>): boole
let stagedEntry: Transition | null = null;

export function enterStagedRead(
el: Signal<any> | Computed<any>,
t: Transition | null | undefined = el._transition
el: Signal<any> | Computed<any> | null,
t: Transition | null | undefined = el!._transition
): void {
if (!t || t === activeTransition || pendingCheckActive) return;
// A companion (the latest() shadow, the isPending() verdict signal) is the
// engine's mirror of the flushed world — reading it, or being it, is an
// observation, not a derivation from the hold: latest(x) never enters x's
// transaction, and the shadow's own pass never enters either (it would
// flip activeTransition under the reader that pulled it).
if (el._x?._parentSource || (context as Computed<any> | null)?._x?._parentSource) return;
// flip activeTransition under the reader that pulled it). (`el` is null for
// a store backing served under a hold — no node, the transaction is the
// fold's.)
if (el?._x?._parentSource || (context as Computed<any> | null)?._x?._parentSource) 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
Expand Down Expand Up @@ -1636,7 +1666,7 @@ export function enterStagedRead(
* node's COMMITTED value? One implementation of the rule the fast paths
* (readNodeFast, read's fast block) carry as their trivial ternary and that
* every slow site — read's tail, the store's backing selection, the lane and
* verdict arms — used to restate by hand (DESIGN-CONSOLIDATION, move 3b). In order:
* verdict arms — used to restate by hand (docs/DESIGN-CONSOLIDATION.md, move 3b). In order:
* - no reader at all (an untracked read) — the committed frame;
* - a reader under an optimistic lane the engine says reads committed
* (laneReadsCommitted: another lane's hold, #3460);
Expand Down Expand Up @@ -1702,7 +1732,7 @@ export function unflushed(el: Signal<any> | Computed<any>): boolean {
* in-computation writes) and engine companions (the isPending() verdict
* signal, the latest() shadow: the system's own writes, made at the source's
* write to mirror it, installing eagerly — A28, A8). */
export function unflushedValue(el: Signal<any> | Computed<any>): unknown {
export function unflushedValue(el: Signal<any> | Computed<any>, committed = el._value): unknown {
if (
globalQueue._running ||
el._pendingValue === NOT_PENDING ||
Expand All @@ -1712,9 +1742,9 @@ export function unflushedValue(el: Signal<any> | Computed<any>): unknown {
return NOT_PENDING;
// Ambient, or adopted by a transaction before any flush carried the staging
// (CONFIG_ADOPTED_UNFLUSHED): nothing flushed is staged — the committed
// value answers. A held node: unflushed only if rewritten since the last
// flush (stash).
if (el._transition === null || el._config & CONFIG_ADOPTED_UNFLUSHED) return el._value;
// value answers (the caller's notion of committed: a store node's backing).
// A held node: unflushed only if rewritten since the last flush (stash).
if (el._transition === null || el._config & CONFIG_ADOPTED_UNFLUSHED) return committed;
return el._x === null ? NOT_PENDING : el._x._flushedStaged;
}
/** Held nodes rewritten since the last flush (setSignal); the flush clears
Expand All @@ -1741,7 +1771,7 @@ export function hasActiveOverride(el: Signal<any> | Computed<any>): boolean {
* an optimistic write is a write; until its flush no reader sees it). One
* implementation for read()'s override arm, the verdict channels
* (latestRead, computePendingState) and the store's selection
* (DESIGN-CONSOLIDATION, move 3b). */
* (docs/DESIGN-CONSOLIDATION.md, move 3b). */
export function visibleOverride(el: Signal<any> | Computed<any>): boolean {
return hasActiveOverride(el) && !unflushedOverride(el);
}
Expand Down Expand Up @@ -1993,6 +2023,53 @@ export function read<T>(el: Signal<T> | Computed<T>): T {
nodeName: (owner as any)?._name
});

const value = serve(el, c as Computed<any> | null, owner, el._value) as T;
if (
!c &&
owner === el &&
typeof computed._fn === "function" &&
el._config & CONFIG_AUTO_DISPOSE &&
!(owner._statusFlags & STATUS_PENDING) &&
!el._subs &&
// An untracked read served a visible override returned before this
// sweep registration when the arm was inline; keep that.
!visibleOverride(el)
) {
// Deferred, not inline (#3078): an inline unobserved() here made untracked
// reads destructive — dispose on this read, full revival recompute on the
// next — so consecutive reads could answer differently with no write in
// between (the revival samples the ambient transition/lane context).
// The sweep at flush finalization re-validates and reclaims; schedule()
// guarantees that flush happens even if nothing else is queued.
dormantNodes.add(el as Computed<unknown>);
schedule();
}
return value;
}

/**
* Rule 1, the one slow implementation (DESIGN-CONSOLIDATION move 3b, step
* 6c): the value a reader `c` (null = untracked, no pass) is served from
* `el`, whose committed value is `committed` — the node's own `_value` for
* a signal or memo, the BACKING for a store property node (single-home rule,
* O6: committed truth lives in the backing and a node's `_value` is never
* served for one). Called by read()'s slow tail and by the store's untracked
* node path (nodeValue); the fast paths (readNodeFast, read's fast block)
* keep their trivial ternary by design (perf, see the doc). Arms, in order:
* - the override (A17), routed through the engine for a tracked reader under
* a lane or a supersession (A18), an authoritative reader marked instead;
* - the lane entanglement gate (committed, recorded for replay);
* - a node born held has nothing for an untracked reader (A19 exception 1);
* - an unflushed write serves committed and re-runs the reader in the
* carrying flush (A28);
* - readerSeesCommitted, else the staged value and the transaction (A29).
*/
export function serve(
el: Signal<any> | Computed<any>,
c: Computed<any> | null,
owner: Signal<any> | Computed<any>,
committed: unknown
): unknown {
if (hasActiveOverride(el)) {
// A17: the override IS the value for every reader — except an authoritative
// reader (until()'s predicate carries CONFIG_AUTHORITATIVE_READ): it must
Expand All @@ -2015,8 +2092,8 @@ export function read<T>(el: Signal<T> | Computed<T>): T {
// 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);
return GlobalQueue._overrideRead!(el as Computed<any>, c);
return unwrapOverride(el._x?._overrideValue);
}
el._config |= CONFIG_AUTHORITATIVE_OBSERVED;
}
Expand All @@ -2032,9 +2109,9 @@ export function read<T>(el: Signal<T> | Computed<T>): T {
currentOptimisticLane !== null &&
activeTransition !== null &&
c !== null &&
GlobalQueue._gatedRead!(el as Signal<any>, owner, c as Computed<any>)
GlobalQueue._gatedRead!(el as Signal<any>, owner, c)
) {
return el._value as T;
return committed;
}

// In optimistic lane context, return _value for optimistic/lane-assigned signals
Expand All @@ -2051,35 +2128,18 @@ export function read<T>(el: Signal<T> | Computed<T>): T {
el._pendingValue !== NOT_PENDING &&
((el as Computed<any>)._statusFlags & STATUS_UNINITIALIZED) !== 0;
if (noCommitted && !c) throw new NotReadyError(null);
const u = c && unflushedStaged ? unflushedValue(el) : NOT_PENDING;
const u = c && unflushedStaged ? unflushedValue(el, committed) : NOT_PENDING;
if (u !== NOT_PENDING) {
markLateLinker(c as Computed<any>);
markLateLinker(c!);
if (pendingCheckActive) GlobalQueue._recordFresh!(el, u);
return u as T;
return u;
}
const value = readerSeesCommitted(el, c as Computed<any> | null, owner, noCommitted)
? el._value
: (enterStagedRead(el), el._pendingValue as T);
const value = readerSeesCommitted(el, c, owner, noCommitted)
? committed
: (enterStagedRead(el), el._pendingValue);
// Record that this isPending() probe observed the fresh pending value, so
// the probe doesn't pair "pending" with the new value (#2831).
if (pendingCheckActive) GlobalQueue._recordFresh!(el, value);
if (
!c &&
owner === el &&
typeof computed._fn === "function" &&
el._config & CONFIG_AUTO_DISPOSE &&
!(owner._statusFlags & STATUS_PENDING) &&
!el._subs
) {
// Deferred, not inline (#3078): an inline unobserved() here made untracked
// reads destructive — dispose on this read, full revival recompute on the
// next — so consecutive reads could answer differently with no write in
// between (the revival samples the ambient transition/lane context).
// The sweep at flush finalization re-validates and reclaims; schedule()
// guarantees that flush happens even if nothing else is queued.
dormantNodes.add(el as Computed<unknown>);
schedule();
}
return value;
}

Expand Down
21 changes: 14 additions & 7 deletions packages/signals/src/core/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
NOT_PENDING,
REACTIVE_DISPOSED
} from "./constants.js";
import { currentOptimisticLane, ext, hasActiveOverride } from "./core.js";
import { currentOptimisticLane, ext, hasActiveOverride, ownsHold } from "./core.js";
export { hasActiveOverride };
import { enqueueSub } from "./heap.js";
import {
Expand Down Expand Up @@ -144,16 +144,23 @@ export function laneHeld(lane: OptimisticLane): boolean {
export function readsHeldCommitted(owner: Computed<any>, c: Computed<any>): boolean {
const lane = resolveLane(owner);
if (!lane || !laneHeld(lane)) return false;
const t = activeTransition && resolveTransition(owner);
if (
(t && currentTransition(t) === currentTransition(activeTransition!)) ||
(currentOptimisticLane !== null && findLane(currentOptimisticLane) === lane)
)
return false;
if (ownsLane(lane, owner)) return false;
lane._effectQueues[0].push(() => c._flags & REACTIVE_DISPOSED || enqueueSub(c));
return true;
}

/** The ownership relation for a lane hold (core `ownsHold`, §6 ruling 2): the
* running pass owns `lane`'s hold if it runs under the transition that owns
* the lane (the node's, resolved through override ownership and merges) or
* inside the lane itself. */
export function ownsLane(lane: OptimisticLane, owner: Computed<any>): boolean {
if (activeTransition !== null) {
const t = resolveTransition(owner);
if (t && ownsHold(t)) return true;
}
return currentOptimisticLane !== null && findLane(currentOptimisticLane) === lane;
}

/**
* Merge two lanes when their dependency graphs overlap.
*/
Expand Down
16 changes: 12 additions & 4 deletions packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,19 @@ export let _hitUnhandledAsync = false;
// pending render effect — N async siblings at mount used to produce N copies.
let _reportedUnhandledAsync = false;

// Store property nodes that were created solely to carry a pending write (no
// subscribers at write time). Swept after each flush that commits pending
// values — any still without subs get disposed via their `_unobserved` hook,
// releasing the slot in the parent store's node map.
// Store property nodes whose last subscriber left while they carried state
// the backing cannot reconstruct — an optimistic override (overrides live on
// nodes, over a clone the setter discards) or a staged write. Releasing the
// slot then would drop the override: an optimistic store key read `0` the
// moment its only reader gated away while the action was live (S7). Swept
// after each flush — a node still without subs whose override and staging
// have resolved is released through the slot hook; one that regained a
// subscriber leaves the set.
const transientStoreNodes = new Set<Signal<any>>();
/** Slot hook's deferral: release this node when its carried state resolves. */
export function deferSlotRelease(node: Signal<any>): void {
transientStoreNodes.add(node);
}

function canUseSimpleSyncFlush(queue: GlobalQueue): boolean {
const batch = queue._batch;
Expand Down
Loading
Loading