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-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.
6 changes: 3 additions & 3 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1557,12 +1557,22 @@ 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);
}

function heldFromStale(el: Signal<any> | Computed<any>, c: Computed<any>): boolean {
const t = el._transition;
if (t === null || t === activeTransition) 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
23 changes: 21 additions & 2 deletions packages/signals/src/store/next/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
hasActiveOverride,
visibleOverride,
readerSeesCommitted,
recordStaleReplay,
prepareComputed,
read as readNode,
READ_SLOW,
Expand Down Expand Up @@ -408,7 +409,19 @@ function heldFoldTransition(target: StoreNextTarget): Transition | null {
function heldFromReader(target: StoreNextTarget): boolean {
if (!stale && inOwnerContext()) return false;
const txn = liveFoldTransition(target);
return txn !== null && foreignHold(txn);
return txn !== null && foreignHold(txn) && (staleReplay(txn), true);
}

/** The replay half of the clause (core recordStaleReplay): the stale reader
* just denied the held value re-runs at the hold's commit. Without it the
* node path replayed (heldFromStale) and the backing paths did not — an
* effect's untracked read of a key with no node stayed on the pre-action
* value after the action settled (posture-store-parity S4). Only a reader in
* context has a pass to replay; a children-forbidden reader sees the frame
* and never the graph (A32). */
function staleReplay(txn: Transition): void {
const c = readerContext();
if (c !== null && !(c._config & CONFIG_CHILDREN_FORBIDDEN)) recordStaleReplay(txn, c);
}

/** Core read()'s `activeTransition !== el._transition`: a hold belongs to a
Expand Down Expand Up @@ -1668,7 +1681,13 @@ function readSource(target: StoreNextTarget): Record<PropertyKey, any> {
(stale && target.ht !== PLAIN_HOLD && foreignHold(currentTransition(target.ht))))
) {
const hv = heldMaskView(target);
if (hv !== null) return hv;
if (hv !== null) {
// The reader denied the adopted view replays at the adoption's commit
// (the replay half of the clause; a latest()-pull PLAIN_HOLD is not a
// transaction and has no commit).
if (target.ht !== PLAIN_HOLD) staleReplay(currentTransition(target.ht as Transition));
return hv;
}
}
return pendingBackingVisible(target, false) ? target.pb! : target.v;
}
Expand Down
38 changes: 34 additions & 4 deletions packages/signals/tests/posture-store-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
flush,
isPending,
latest,
reconcile,
untrack
} from "../src/index.js";

Expand Down Expand Up @@ -208,10 +209,9 @@ describe("S4 — a stale reader's untracked read of a foreign hold replays at th
expect(r.log).toEqual([0, 0, 1]);
});
// The backing twin (pendingBackingVisible / heldFromReader) serves the key
// with no node the same committed value but has no node to record the
// reader on — the effect never replays. Rule 1's backing-level form is an
// open item of DESIGN-CONSOLIDATION move 3b (step 3).
it.fails("store leaf WITHOUT a node: the same replay (backing twin gap)", async () => {
// with no node the same committed value; it records the reader on the
// holding transaction directly (staleReplay → core recordStaleReplay).
it("store leaf WITHOUT a node: the same replay (backing twin)", async () => {
const [s, setS] = createStore({ n: 0 });
let release!: () => void;
const r = untrackedStaleReplay(
Expand All @@ -229,4 +229,34 @@ describe("S4 — a stale reader's untracked read of a foreign hold replays at th
await settle();
expect(r.log).toEqual([0, 0, 1]);
});
// The other hold kind: an adoption (reconcile inside an action) holds at
// the backing (`ht`, #3074) — the held view is served to the stale reader
// by readSource, and the same replay is recorded there.
for (const withNode of [false, true]) {
it(`store reconcile held by a live action, ${withNode ? "with" : "without"} a node: the same replay`, async () => {
const [s, setS] = createStore({ n: 0 });
if (withNode) {
createRoot(() =>
createRenderEffect(
() => s.n,
() => {}
)
);
flush();
}
let release!: () => void;
const r = untrackedStaleReplay(
() => s.n,
() =>
action(function* () {
setS(reconcile({ n: 1 }));
yield new Promise<void>(res => (release = res));
})(),
() => release()
);
expect(r.held).toEqual([0, 0]);
await settle();
expect(r.log).toEqual([0, 0, 1]);
});
}
});
24 changes: 21 additions & 3 deletions scripts/size/.size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,13 @@ module.exports = [
// pure-signals fixtures -4 / -29 / -5 B. This scenario's esbuild bundle
// measured at 16,509 B against `next`'s 16,495 — compressor layout, the
// deltas across the ten scenarios run -19…+45 B in both directions.
limit: "16.55 KB",
// Stale-reader replay at the backing (move 3b step 3, 2026-09-17): a
// render effect's untracked read of a store key held by a foreign action
// — no node, or an adoption hold — is recorded for replay at the commit
// as the signal path always was (core recordStaleReplay, shared with
// heldFromStale). +31 B minified core, +123 B minified store (the fix,
// posture-store-parity S4); measured at 16,580 B.
limit: "16.60 KB",
modifyEsbuildConfig
},
{
Expand Down Expand Up @@ -925,7 +931,13 @@ module.exports = [
// pure-signals fixtures -4 / -29 / -5 B. This scenario's esbuild bundle
// measured at 19,894 B against `next`'s 19,849 — compressor layout, the
// deltas across the ten scenarios run -19…+45 B in both directions.
limit: "19.90 KB",
// Stale-reader replay at the backing (move 3b step 3, 2026-09-17): a
// render effect's untracked read of a store key held by a foreign action
// — no node, or an adoption hold — is recorded for replay at the commit
// as the signal path always was (core recordStaleReplay, shared with
// heldFromStale). +31 B minified core, +123 B minified store (the fix,
// posture-store-parity S4); measured at 19,902 B.
limit: "19.95 KB",
modifyEsbuildConfig
},
{
Expand Down Expand Up @@ -1112,7 +1124,13 @@ module.exports = [
// A projection's leaf companions die with it; latest() of a dead leaf creates
// none (spec O5, 2026-09-16): 29,953 B (+43 over the cap); +104 B minified in
// owner.ts (core floor), the shadow retirement lives in verdict.ts.
limit: "30.00 KB",
// Stale-reader replay at the backing (move 3b step 3, 2026-09-17): a
// render effect's untracked read of a store key held by a foreign action
// — no node, or an adoption hold — is recorded for replay at the commit
// as the signal path always was (core recordStaleReplay, shared with
// heldFromStale). +31 B minified core, +123 B minified store (the fix,
// posture-store-parity S4); measured at 30,034 B.
limit: "30.05 KB",
modifyEsbuildConfig
},
{
Expand Down