Skip to content
Closed
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/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.
140 changes: 140 additions & 0 deletions packages/signals/docs/DESIGN-CONSOLIDATION.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1602,16 +1602,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 @@ -1641,7 +1643,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 @@ -1746,7 +1748,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
28 changes: 26 additions & 2 deletions packages/signals/src/store/next/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
visibleOverride,
readerSeesCommitted,
recordStaleReplay,
enterStagedRead,
prepareComputed,
read as readNode,
READ_SLOW,
Expand Down Expand Up @@ -424,6 +425,17 @@ function staleReplay(txn: Transition): void {
if (c !== null && !(c._config & CONFIG_CHILDREN_FORBIDDEN)) recordStaleReplay(txn, c);
}

/** The other half of serving a held backing: a deriving reader (a pass in
* owner context) served the pending backing a live transaction holds
* derives from that transaction's world and enters it (A29, core
* enterStagedRead on the same arm) — its result is held with the fold, not
* published into the mainline frame. Without it a mainline memo's untracked
* read of a held key (`untrack(() => s.n)`, `deep(s)`) published the
* unrevealed value while the same read of a signal was born held. */
function enterHeldBacking(target: StoreNextTarget, txn = liveFoldTransition(target)): void {
if (txn !== null && readerContext() !== null) enterStagedRead(null, txn);
}

/** Core read()'s `activeTransition !== el._transition`: a hold belongs to a
* FOREIGN transaction unless the flush running now is that transaction's —
* its own stale readers (a render effect recomputing in it, whose run the
Expand Down Expand Up @@ -1688,6 +1700,10 @@ function readSource(target: StoreNextTarget): Record<PropertyKey, any> {
if (target.ht !== PLAIN_HOLD) staleReplay(currentTransition(target.ht as Transition));
return hv;
}
} else if (target.ht !== null && !latestReadActive && !inDraft(target) && !getWriteOverride()) {
// An owner-context deriving reader served the ADOPTED view under a live
// adoption hold derives from the adoption's transaction (A29).
enterHeldBacking(target, heldAdoptionTransition(target));
}
return pendingBackingVisible(target, false) ? target.pb! : target.v;
}
Expand Down Expand Up @@ -1721,7 +1737,10 @@ function pendingBackingVisible(target: StoreNextTarget, speculative: boolean): b
// ordinary readers keep committed until the transaction's reveal).
// Stale readers and owner-less peeks of a TRANSACTION-held backing see
// committed, as core read() serves them (#3336, heldFromReader).
((speculative || inOwnerContext()) && !heldTruthMasked(target) && !heldFromReader(target)) ||
((speculative || inOwnerContext()) &&
!heldTruthMasked(target) &&
!heldFromReader(target) &&
(enterHeldBacking(target), true)) ||
// A projection's pending backing is authoritative-elect: serve it to
// context-free readers too UNLESS a transition is holding the node
// commits (downstream async hold — stale committed is the contract)
Expand Down Expand Up @@ -1830,7 +1849,12 @@ function nodeValue(node: Signal<any>, backing: any): any {
// parent computed).
(latestReadActive ||
authoritativeServe() ||
!readerSeesCommitted(node, readerContext(), (node as any)._firewall || node, false))
// A deriving reader served the staged value enters its
// transaction (A29) as core read() does on the same arm: the
// pass is the hold's, its result held with it — an untracked read
// inside a mainline memo must not publish the unrevealed frame.
(!readerSeesCommitted(node, readerContext(), (node as any)._firewall || node, false) &&
(enterStagedRead(node), true)))
? node._pendingValue
: backing;
return v === (FORCE as any) ? backing : v;
Expand Down
189 changes: 189 additions & 0 deletions packages/signals/tests/posture-store-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@
* S3 — INV-4 after disposing a projection mid-refetch: its own file,
* tests/inv4-projection-dispose-shadow.test.ts (the live actions S1/S2
* leave behind would mask the quiescence check here). Spec O5.
* S4 — (fixed, 3b steps 2–3) a stale reader's UNTRACKED read of a held store
* key replays at the commit as the signal's does — node, backing, and
* adoption-hold paths (recordStaleReplay).
* S5 — (fixed, 3b step 4) a mainline derivation's UNTRACKED read of a held
* store key is born held (A29) as the signal's is — the store's untracked
* paths served the pending value without entering the transaction.
* S6, S7 — DIVERGENCES recorded at their current values, not ruled (see the
* block comment above them).
* Discovery for S4–S7: the matrix's `memoUntracked` / `effectUntracked`
* reader kinds (an untracked read inside a derivation), added with S5.
*
* (A first cut also reported the projection's seed leaking as a value inside
* boundary content, and `isPending` false / override invisible behind a
Expand All @@ -29,8 +39,11 @@
import { describe, expect, it } from "vitest";
import {
action,
createEffect,
createLoadingBoundary,
createMemo,
createOptimistic,
createOptimisticStore,
createRenderEffect,
createRoot,
createSignal,
Expand Down Expand Up @@ -260,3 +273,179 @@ describe("S4 — a stale reader's untracked read of a foreign hold replays at th
});
}
});

/** A deriving reader (memo, user effect) created MAINLINE whose UNTRACKED
* read is of a value held by a live action: the pass is served the staged
* value and enters the transaction — born held (A29) — so nothing is
* published until the action commits. The signal did this (core read()
* enters on the same arm that serves the staged value; `context` persists
* under untrack). The store's untracked paths (nodeValue, the backing's
* pendingBackingVisible, the adoption-hold view) served the pending value
* WITHOUT entering: a mainline memo published the action's unrevealed write
* to the screen while the same read of a signal was held. */
type HeldShape = "signal" | "store+node" | "store" | "store reconcile" | "store reconcile+node";
function heldShape(shape: HeldShape) {
if (shape === "signal") {
const [x, setX] = createSignal(0);
return { read: x, write: () => setX(1) };
}
const [s, setS] = createStore({ n: 0 });
if (shape.endsWith("+node")) {
createRoot(() =>
createRenderEffect(
() => s.n,
() => {}
)
);
flush();
}
return {
read: () => s.n,
write: () =>
shape.startsWith("store reconcile")
? setS(reconcile({ n: 1 }))
: setS(d => {
d.n = 1;
})
};
}
describe("S5 — a mainline derivation's UNTRACKED read of a held value is born held (A29) — signal vs store", () => {
for (const shape of [
"signal",
"store+node",
"store",
"store reconcile",
"store reconcile+node"
] as HeldShape[]) {
it(`${shape}: memo → render effect publishes nothing until the action commits`, async () => {
const { read, write } = heldShape(shape);
let release!: () => void;
action(function* () {
write();
yield new Promise<void>(res => (release = res));
})();
flush();
const log: number[] = [];
const [u, setU] = createSignal(0);
createRoot(() => {
const m = createMemo(() => {
u();
return untrack(read);
});
createRenderEffect(m, v => {
log.push(v);
});
});
flush();
setU(1); // a re-run off the hold is held too
flush();
expect(log).toEqual([]);
release();
await settle();
expect(log).toEqual([1]);
});
it(`${shape}: user effect runs once, after the commit`, async () => {
const { read, write } = heldShape(shape);
let release!: () => void;
action(function* () {
write();
yield new Promise<void>(res => (release = res));
})();
flush();
const log: number[] = [];
createRoot(() => {
createEffect(
() => untrack(read),
v => {
log.push(v);
}
);
});
flush();
expect(log).toEqual([]);
release();
await settle();
expect(log).toEqual([1]);
});
}
});

/** DIVERGENCES the 7-reader matrix shows and this file only RECORDS (both
* sides pinned at their current value; not ruled — flip the store or the
* signal when the maintainer rules):
*
* S6 — staged, ambient (a write before any flush), reader created INSIDE a
* foreign action (which adopts the write, spec O1): the signal's memo →
* render effect publishes the committed 0 (A28 / #3510: adopted before
* any flush = unflushed, served committed); the store's publishes the
* pending 1 (pendingBackingVisible: owner context → pending backing).
* The verdict channels already agree (S1); the derivation reads do not.
* S7 — optimistic store, override active, the only reader gated away: the
* signal's x() still reads the override 5 while the action is live
* (A17); the store's s.n reads 0 — the override is invisible to an
* untracked read once no reader observes the key.
*/
describe("S6 — DIVERGENCE (recorded): staged-ambient write read by a derivation created inside a foreign action", () => {
function publishedInsideForeignAction(read: () => number) {
const log: number[] = [];
action(function* () {
createRoot(() => {
const m = createMemo(read);
createRenderEffect(m, v => {
log.push(v);
});
});
yield never();
})();
flush();
return log;
}
it("signal: publishes the committed 0", () => {
const [x, setX] = createSignal(0);
setX(1);
expect(publishedInsideForeignAction(x)).toEqual([0]);
});
it("store: publishes the pending 1", () => {
const [s, setS] = createStore({ n: 0 });
setS(d => {
d.n = 1;
});
expect(publishedInsideForeignAction(() => s.n)).toEqual([1]);
});
});

describe("S7 — DIVERGENCE (recorded): optimistic override, the only reader gated away", () => {
function gateAway(read: () => number) {
const [show, setShow] = createSignal(true);
createRoot(() => {
createRenderEffect(
() => (show() ? read() : "gated"),
() => {}
);
});
flush();
setShow(false);
flush();
return read();
}
it("signal: x() still reads the override while the action is live", () => {
const [x, setX] = createOptimistic(0);
action(function* () {
setX(5);
yield never();
})();
flush();
expect(gateAway(x)).toBe(5);
});
it("store: s.n reads the committed 0 once nothing observes the key", () => {
const [s, setS] = createOptimisticStore({ n: 0 });
action(function* () {
setS(d => {
d.n = 5;
});
yield never();
})();
flush();
expect(gateAway(() => s.n)).toBe(0);
});
});
Loading