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

A store write made outside a flush by imperative code is no longer visible to computations until the flush that carries it, as a signal write is not (A28): a memo created inside an action that adopted such a write published the pending value where the same memo over a signal published the committed one. A property node born in that window stages the write so the carrying flush delivers it through the node.
727 changes: 369 additions & 358 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

72 changes: 64 additions & 8 deletions packages/signals/src/store/next/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import {
unwrapOverride,
CONFIG_AUTHORITATIVE_READ,
CONFIG_HELD_TRUTH,
CONFIG_OPTIMISTIC
CONFIG_OPTIMISTIC,
CONFIG_ADOPTED_UNFLUSHED
} from "../../core/constants.js";
import {
context,
Expand All @@ -41,6 +42,7 @@ import {
visibleOverride,
recordStaleReplay,
enterStagedRead,
markLateLinker,
ownsHold,
serve,
prepareComputed,
Expand All @@ -58,10 +60,12 @@ import {
} from "../../core/core.js";
import {
activeTransition,
clock,
currentTransition,
deferSlotRelease,
globalQueue,
insertSubs,
queuePendingNode,
type Transition
} from "../../core/scheduler.js";
import { devTrackHeldPending } from "../../core/invariants.js";
Expand Down Expand Up @@ -152,6 +156,7 @@ function TargetShape(this: any) {
this.wk = undefined;
this.hv = undefined;
this.ht = undefined;
this.uf = undefined;
}
TargetShape.prototype = Object.prototype;

Expand Down Expand Up @@ -192,6 +197,7 @@ function createTarget(
t.del = null;
t.hv = null;
t.ht = null;
t.uf = -1;
t.px = new Proxy(t, traps);
// Legacy interop: shared machinery (affects walks, wrap dedupe) reads the
// proxy off looked-up targets as a field.
Expand Down Expand Up @@ -343,7 +349,7 @@ export function getNode(
// (the declaration walk could only cover nodes existing then).
if (key !== $AFFECTS && affectsScopesLive()) inheritAffectsMarks(created, target.v, key);
if (held !== null)
stageHeldKey(
stageKey(
created,
fold !== null
? target.del !== null && target.del.has(key)
Expand All @@ -352,6 +358,19 @@ export function getNode(
: (target.v as any)[key],
held
);
// Third kind (A28 at the backing): born in the UNFLUSHED window of an
// ambient fold — a setter ran outside a flush, no flush has carried it,
// and the key had no node to take the write at setter exit. `current` is
// the committed value (readSource served it under unflushedBacking);
// stage the pending backing's value so the carrying flush commits it
// through the node and the reader served committed (and marked late
// linker) finds the write there.
else if (target.pb !== null && unflushedBacking(target) && plainFold(target))
stageKey(
created,
target.del !== null && target.del.has(key) ? undefined : (target.pb as any)[key],
null
);
nodes[key] = node;
target.nc++;
markDescendants(target);
Expand Down Expand Up @@ -399,8 +418,11 @@ function liveFoldTransition(target: StoreNextTarget): Transition | null {
* heldTruthMasked — neither is a plain staged write to mirror. Chained
* backings serve the inner store's live value, never a node value. */
function heldFoldTransition(target: StoreNextTarget): Transition | null {
if (target.ch || target.fam?.opt === true || inDraft(target)) return null;
return liveFoldTransition(target);
return plainFold(target) ? liveFoldTransition(target) : null;
}
/** A backing whose staged writes a node can mirror (see heldFoldTransition). */
function plainFold(target: StoreNextTarget): boolean {
return !target.ch && target.fam?.opt !== true && !inDraft(target);
}

/**
Expand All @@ -426,11 +448,25 @@ function holdVisible(txn: Transition | null, c: Computed<any>): boolean {
return true;
}

function stageHeldKey(node: Signal<any>, nv: any, txn: Transition): void {
function stageKey(node: Signal<any>, nv: any, txn: Transition | null): void {
if (slotNodeEquals.call(node, node._value, nv)) return;
node._pendingValue = nv;
node._transition = txn;
txn._pendingNodes.push(node);
if (txn !== null) {
// A hold's staging: transition-stamped, in its pending list — no
// parked-flush pass will stamp it (runFolded's shape).
node._transition = txn;
txn._pendingNodes.push(node);
} else {
// The unflushed window of an ambient fold (A28): core's ambient staging
// — queued for the carrying flush to commit through the node, adopted
// before any flush if a transaction body is running (initTransition's
// mark for a node that existed at the write).
if (activeTransition !== null) {
node._transition = activeTransition;
node._config |= CONFIG_ADOPTED_UNFLUSHED;
}
queuePendingNode(node);
}
if (__DEV__) devTrackHeldPending(node);
}

Expand Down Expand Up @@ -752,6 +788,9 @@ function ensurePB(target: StoreNextTarget): Record<PropertyKey, any> {
}
}
queueFold(target);
// A28 stamp, once per draft open (not per write — ensurePB runs on every
// trap write of the draft).
if (!globalQueue._running && getOwner() === null) target.uf = clock;
}
return pb;
}
Expand Down Expand Up @@ -886,6 +925,19 @@ function queueFold(target: StoreNextTarget): void {
* Refreshed on every write; resolved through currentTransition at drain
* (transitions merge — same rule as heldMaskView). */
const foldBatches = new WeakMap<StoreNextTarget, Transition>();
/** A28 for backings — `target.uf` is the tick (`clock`) the pending backing
* was opened OUTSIDE a flush by a write from outside any owner (core's
* promoted-write exemption, A28 (4): a write inside a computation is visible
* to the rest of its block). `clock` advances after every flush, so "stamped
* this tick, no flush running" is a write no flush has carried: owner-context
* readers see the committed container and re-run in the carrying flush
* (markLateLinker), as core serve() treats an unflushed node (unflushedValue).
* A field, not a side table: a WeakMap.set per draft open cost 7–19% on the
* store write floor (identity hash + ephemeron) and a Map with set/delete
* churn per commit 7–9% (CodSpeed on #3526). */
function unflushedBacking(target: StoreNextTarget): boolean {
return !globalQueue._running && target.uf === clock;
}

/** Parked truth-staged pending backings (#3164 fold): a tentative draft that
* opens while a folded landing's backing is live moves the staged container
Expand Down Expand Up @@ -1711,7 +1763,11 @@ function pendingBackingVisible(target: StoreNextTarget, speculative: boolean): b
if (speculative) return txn === null || ownsHold(txn);
return target.fam !== null && c === null && !foldHeld(target) && txn === null;
}
return holdVisible(liveFoldTransition(target), c);
const txn = liveFoldTransition(target);
// A28: an ambient staging no flush has carried is not yet visible — the
// committed container, and the pass runs again in the carrying flush.
if (txn === null && unflushedBacking(target)) return (markLateLinker(c), false);
return holdVisible(txn, c);
}

/** #3164 fold: HELD truth on an optimistic family — a pending backing
Expand Down
7 changes: 6 additions & 1 deletion packages/signals/src/store/next/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,13 @@ export interface StoreNextTarget {
* (empirically at counts ≡ 0 mod 3 from 18 up on V8 13.x) — every trap
* field read then becomes a hash lookup (~15% uibench, tree suites
* worst). Future write-side state MUST ride an extension object, not new
* named fields. */
* named fields. (Re-measured 2026-09-17 on Node 26 / V8 14: array and
* object targets stay fast-properties at 25–28 named+symbol props, so `uf`
* below was added as a field; CodSpeed's array benches are the guard.) */
wk: Set<PropertyKey> | null;
/** A28 at the backing: the `clock` tick the pending backing was opened
* outside a flush by imperative code (-1 = never). See unflushedBacking. */
uf: number;
/** Lazy deep-witness node: `deep()` subscribes ONE node per record instead
* of one per path; write paths bump it only when it exists. Separate from
* `k` so $TRACK/mapArray never rerun on leaf value changes (R9). */
Expand Down
38 changes: 19 additions & 19 deletions packages/signals/tests/posture-store-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,10 @@
* 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 — RULED (2026-09-17, "store rules follow signal rules"), fix DEFERRED:
* A28 at the backing. Pinned at the store's CURRENT value below so the
* divergence stays visible; the fix is the store half of `serve`
* (DESIGN-CONSOLIDATION move 3b step 6) — done at the twin it costs
* +400 B minified (a node born in the unflushed window must stage the
* write; #3521 first cut).
* S6 — (fixed) A28 at the backing: a write staged outside a flush by
* imperative code is invisible to owner-context readers until the flush
* that carries it, as a signal's is; a node born in that window stages
* the write (one stageKey for holds and the unflushed window).
* S7 — (fixed) an optimistic override survives its key becoming unobserved
* (the slot release defers to the flush that resolves the override).
* S8 — (fixed, 3b step 6c) a derivation's untracked read of a SUPERSEDED
Expand Down Expand Up @@ -380,17 +378,19 @@ describe("S5 — a mainline derivation's UNTRACKED read of a held value is born
}
});

/** S6 — DIVERGENCE, ruled, fix deferred. 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. Ruling: the store follows the signal (0). The
* store side is pinned at its CURRENT value so the divergence stays visible
* until the store's value selection shares core's (`serve`, move 3b step 6);
* flip it to `[0]` then. */
describe("S6 — DIVERGENCE (ruled: store follows signal; fix deferred to `serve`): staged-ambient write read by a derivation created inside a foreign action", () => {
/** S6 (fixed): A28 at the backing. A write staged outside a flush is not
* visible to any reader until the flush that carries it (core serve() serves
* an unflushed node's committed value and re-runs the reader in the carrying
* flush — unflushedValue / markLateLinker). The store's backing selection
* served the pending backing to every owner-context reader at once; a
* derivation created inside a foreign action (which adopts the write, O1)
* published the unflushed 1 where the signal published 0. The backing now
* carries the rule (unflushedBacking: the target stamped `clock` when a
* draft opens outside a flush from outside any owner — A28 (4)'s promoted-
* write exemption), and a node born in that window stages the pending value
* (stageKey with no transaction) so the carrying flush commits it through
* the node and the late-linked reader finds it there. */
describe("S6 — a write before any flush, read by a derivation created inside a foreign action (A28) — signal vs store", () => {
function publishedInsideForeignAction(read: () => number) {
const log: number[] = [];
action(function* () {
Expand All @@ -410,12 +410,12 @@ describe("S6 — DIVERGENCE (ruled: store follows signal; fix deferred to `serve
setX(1);
expect(publishedInsideForeignAction(x)).toEqual([0]);
});
it("store: publishes the pending 1 (CURRENT; rule says 0)", () => {
it("store: publishes the committed 0, as the signal does", () => {
const [s, setS] = createStore({ n: 0 });
setS(d => {
d.n = 1;
});
expect(publishedInsideForeignAction(() => s.n)).toEqual([1]);
expect(publishedInsideForeignAction(() => s.n)).toEqual([0]);
});
});

Expand Down
16 changes: 14 additions & 2 deletions scripts/size/.size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,13 @@ module.exports = [
// drop (signals and writable memos, through commitPendingNode), a
// kept-tail pending mark re-deriving its subscriber (A30), and
// reporterBlocksSource following `_pendingSources` one hop.
limit: "16.70 KB",
// A28 at the store backing (S6, store follows signal, #3526, 2026-09-17,
// rebased over #3519): a write staged outside a flush by imperative code is
// invisible to owner-context readers until the carrying flush; a node born
// in that window stages it (one stageKey); the stamp is a target field
// (`uf`) after a WeakMap and a Map both cost 7–19% on the write floor.
// Measured at 16,766 B.
limit: "16.80 KB",
modifyEsbuildConfig
},
{
Expand Down Expand Up @@ -1242,7 +1248,13 @@ module.exports = [
// drop (signals and writable memos, through commitPendingNode), a
// kept-tail pending mark re-deriving its subscriber (A30), and
// reporterBlocksSource following `_pendingSources` one hop.
limit: "30.35 KB",
// A28 at the store backing (S6, store follows signal, #3526, 2026-09-17,
// rebased over #3519): a write staged outside a flush by imperative code is
// invisible to owner-context readers until the carrying flush; a node born
// in that window stages it (one stageKey); the stamp is a target field
// (`uf`) after a WeakMap and a Map both cost 7–19% on the write floor.
// Measured at 30,374 B.
limit: "30.40 KB",
modifyEsbuildConfig
},
{
Expand Down
Loading