Skip to content

Commit 233e7b0

Browse files
fix(signals): arm the optimistic store mask only on effective writes
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 38233a2 commit 233e7b0

4 files changed

Lines changed: 122 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/signals": patch
3+
---
4+
5+
Ineffective optimistic store writes no longer arm the store-wide isPending mask or entangle the transition. Previously the mask armed on any write-trap fire before the equality short-circuit, so `setStore(s => ({ ...s }))` (which replays every key with equal values) and same-value property writes silently decreed the store settled while the semantically identical `setStore(s => s)` did not. The mask and reversion tracking now arm only when data actually changes, matching the signal path where an equal-value first optimistic write creates no override.

packages/solid-signals/SPEC-ASYNC-SEMANTICS.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ optimistic lanes.
3737
| A18 | (was B4; **refined by re-rule 2026-07-07b**) An override's lifetime is bound to **its own transition** — which, because lanes keep their transitions separate from unrelated work, contains exactly the override's own async cascade. Mechanically: authoritative values arriving under an active override hold in `_pendingValue` like any other transition write and **elevate to `_value` only at their transition's commit** (`_value` changes at commit points, period); the elevation is unobservable under the override mask (A17); reverting is a pure drop — there is no revert target and reverts commit nothing. Consequences: (1) in unmerged graphs, own-source resolution IS the lane-transition's completion, so the correction reveals on arrival — the original A18 pins hold unchanged; (2) matching confirmations collapse silently (revert sees value == override, nobody re-runs); (3) when the override's transition genuinely merges with unrelated async, the correction reveals atomically with that merged completion — reading `isPending === false` throughout (A20 mask: the override is decreed settled) — corrections still *propagate* internally on arrival (fresh readers/async drivers see the hold), so downstream refetches start immediately and no waterfalls form; only the reveal is gated. This supersedes the earlier "bound to its own async source, not its transition" formulation, which was implemented by escaping the transition commit (revert-target commit at revert) and allowed a mid-flight arrival to reveal before its own transition completed. | maintainer rulings, 2026-07-07 (original) and 2026-07-07b (re-rule: "the non-blocking aspect… only gates the reveal"; `_value` elevation at commit points) | `tests/spec-async-semantics.test.ts` (same pins — behavior coincides in unmerged graphs) |
3838
| A19 | (was C1 — **partially reverses an earlier decision**) **Definition: `isPending(x)` ≡ the value you can currently observe for `x` is not the final one.** Three causes of non-finality, each ending on its own terms: (i) a write held by a live transition — ends at commit; (ii) the node's own async in flight — ends at resolution; (iii) a fresh value that arrived but is held uncommitted by a transition it's entangled with — ends at that commit. A node is pending while *any* cause holds it and final the moment none does — cascading async falls out of the definition rather than needing a rule ("once it can show its landed value it is no longer pending"). **Two exceptions.** (1) The initial NotReady: an uninitialized source is *loading*, not pending (A16/A12) — no observable value exists to be non-final — and its thrown `NotReadyError` must propagate to loading boundaries (A16/B5a) because SSR streaming and hydration reveal are driven by boundaries. (2) Decree (amended 2026-07-07c): an active optimistic override is final *because its writer declared it so* — the A20 mask exempts the node (and, for derived stores, the whole store — A21) from every cause above for the override's lifetime. "Not the final one" is judged per channel read (A20 §2), and an override supersedes both channels. Everywhere else, boundaries and reporters never enter the definition: they decide what renders and what a transition waits for, not verdicts. The rejected earlier framing ("if it isn't read somewhere that reports to the transition, it isn't actually pending") was a proxy for cause (i) wrongly applied to causes (ii)/(iii), tying data verdicts to graph-topology accidents. Causes (ii)/(iii) were implemented by the #2838 shadow/companion redesign (2026-07-07) — see V3/V1 under Known violations (fixed). | maintainer ruling, 2026-07-07 | cause (i) + boundary interplay: `tests/spec-async-semantics.test.ts`; causes (ii)/(iii): same file, "V1–V5" describe |
3939
| A20 | (**re-ruled 2026-07-07c** — supersedes the 2026-07-07 "overrides are unsettled" ruling, which held for one day) **The mask: an optimistic override is certainty by decree; `isPending` follows the channel you read.** (1) An *active* override reads `isPending === false` — uniformly, on every node kind, in **both** forms, for the override's whole lifetime (until its own source confirms it, A18, or the transition reverts it). Writing optimistically *declares* the shown value the outcome; a decree cannot be superseded by work already in motion, because the writer just asserted it won't be. `isPending` is reserved for data being updated by machinery the reader did *not* decree — refetches, transition-held commits — never for the provisional nature of an override ("isPending is about the data being in the process of being updated, not about an action being in progress"). Action-scoped affordances ("Saving…", per-row spinners) therefore belong **in the data**: a co-written flag (`todo.pending = true` — the repo's todos example) or a separate `createOptimistic(false)`. You are already writing the optimistic update; the flag rides along. The old no-extra-boolean idiom (`isPending(() => books.length)` as the "Adding…" label) is rejected — it derived an action's progress from a data verdict. (2) Verdicts are per-channel: the plain form watches the *committed* channel — pending while its own fetch is in flight and while a resolved value is held uncommitted by a transition; the latest form watches the *fresh* channel — `latest` is an override the system writes for itself the moment a held value exists (A8), and that self-override masks holds like any user override, leaving only actually-in-flight async as its pending cause. Pairing falls out for both forms: neither ever pairs `true` with the value that made it false. (3) Scope: the mask covers the primitive that was written — node-scoped for signals and computeds; store-scoped for derived optimistic stores (A21). (4) Non-derived optimistic signals/stores are never pending *from themselves* — there is no source to confirm or refetch, the write is an instantly-visible decree — they pend only via a transition hold on the trigger like any plain signal. (5) No tension with A17/A18: the override is THE value (A17), its lifetime is transition-bound (A18), and the mask simply says the verdict agrees with the decree for exactly that lifetime — mask on at write, off at revert/confirm, in the same atomic settle. | GabbeV model adopted, maintainer re-rule 2026-07-07c (#2844/#2728 discussions) | A20 describe in `tests/spec-async-semantics.test.ts`; `tests/createOptimistic.test.ts` (mask + source-still-pends contrast); latest-channel: `tests/createMemo.test.ts`, solid-web `test/latest-async.spec.tsx`; INV-10 enforces the mask in dev |
40-
| A21 | **The store-wide mask: for a derived optimistic store, the store is the primitive — any active optimistic write masks `isPending` for the *entire* store.** Written leaves, untouched siblings, structural reads (`length`, iteration), and the firewall's own refetch all read `false` while any override on the store is live, in both forms; the mask lifts when the store's optimistic state fully clears (same lane lifetime as A20). Rationale: a refetch pends the whole store because the authority's change set is unbounded (A9) — the decree that silences it must speak for the same unbounded scope, or `isPending(() => store.items.length)` would flip on a refresh the writer already declared the outcome of ("If I do `setOptimisticFormOptions(x => x.cities.push("London"))` then I expect the select to consider it settled" — same for `x.cities[i] = "London"`). Once you write optimistically you own the store's pending affordances (A20 §1: flags in the data). Consequences: (1) optimistic writes to the same store entangle — not just writes to the same property; (2) plain (non-derived) optimistic stores get this for free — with no source they were never pending from themselves (A20 §4); (3) A9 is the unmasked rule: with **no** active override, every leaf reports the firewall's refetch in both forms — the store-wide mask is an override-lifetime exception, not a repeal. | GabbeV/maintainer, 2026-07-07c ("any optimistic write turns off isPending for the whole store"; "the store is the boundary") | "store-wide mask" pin in the A20 describe, `tests/spec-async-semantics.test.ts`; `tests/store/createOptimisticStore.test.ts` (refresh-pends → write-masks → lift contrasts); INV-10 store-mask arm |
40+
| A21 | **The store-wide mask: for a derived optimistic store, the store is the primitive — any active optimistic write masks `isPending` for the *entire* store.** Written leaves, untouched siblings, structural reads (`length`, iteration), and the firewall's own refetch all read `false` while any override on the store is live, in both forms; the mask lifts when the store's optimistic state fully clears (same lane lifetime as A20). Rationale: a refetch pends the whole store because the authority's change set is unbounded (A9) — the decree that silences it must speak for the same unbounded scope, or `isPending(() => store.items.length)` would flip on a refresh the writer already declared the outcome of ("If I do `setOptimisticFormOptions(x => x.cities.push("London"))` then I expect the select to consider it settled" — same for `x.cities[i] = "London"`). Once you write optimistically you own the store's pending affordances (A20 §1: flags in the data). Consequences: (1) optimistic writes to the same store entangle — not just writes to the same property; (2) plain (non-derived) optimistic stores get this for free — with no source they were never pending from themselves (A20 §4); (3) A9 is the unmasked rule: with **no** active override, every leaf reports the firewall's refetch in both forms — the store-wide mask is an override-lifetime exception, not a repeal; (4) (added 2026-07-08) only **effective** writes arm the mask and entangle — the decree is about data actually asserted, so trap fires that change nothing (`s => s`, `s => ({ ...s })` replaying equal values, same-value property writes, deletes of absent properties) are no-ops with no decree, matching the signal path where an equal-value first optimistic write short-circuits before any override exists. A deliberate "silence this refresh" affordance is future explicit API (#2844 family), not an emergent no-op write. | GabbeV/maintainer, 2026-07-07c ("any optimistic write turns off isPending for the whole store"; "the store is the boundary"); effective-write gate ruled 2026-07-08 (brenelz/GabbeV probing `setOptStore(s => s)`) | "store-wide mask" pin in the A20 describe, `tests/spec-async-semantics.test.ts`; `tests/store/createOptimisticStore.test.ts` (refresh-pends → write-masks → lift contrasts); INV-10 store-mask arm |
4141

4242
## Tier B (inferred — needs verdict)
4343

@@ -170,6 +170,12 @@ rippled through:
170170
- **INV-10** added: dev-mode asserts a companion's observable verdict is
171171
`false` whenever its owner has an active override or its firewall's
172172
store-wide mask is up.
173+
- **A21 consequence (4)** added 2026-07-08: the mask/entanglement arm only on
174+
*effective* writes. Previously arming ran in `prepareStoreWrite` before the
175+
equality short-circuit, so `s => ({ ...s })` and same-value writes masked
176+
while the semantically identical `s => s` did not — the boundary was "did a
177+
trap fire", not "did data change". Arming now happens per-trap after the
178+
effective-write determination (`armOptimisticStoreWrite`).
173179

174180
What did *not* change: A17 (override is THE value), A18's lifetime/hold
175181
mechanics, A19's cause algebra for non-decreed data, A13/V1–V3/V5 (resting

packages/solid-signals/src/store/store.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -422,11 +422,20 @@ function prepareStoreWrite(target: StoreNode, store: any, property: PropertyKey)
422422
}
423423
const useOptimistic = target[STORE_OPTIMISTIC] && !projectionWriteActive;
424424
const overrideKey = useOptimistic ? STORE_OPTIMISTIC_OVERRIDE : STORE_OVERRIDE;
425-
if (useOptimistic) {
425+
return { base, overrideKey, state };
426+
}
427+
428+
/**
429+
* Registers the store for transition reversion and arms the store-wide
430+
* isPending mask (A21). Called only once a write is known to be effective —
431+
* ineffective writes (same value, delete of an absent property) are no-ops
432+
* and must not decree the store settled or entangle it.
433+
*/
434+
function armOptimisticStoreWrite(target: StoreNode, store: any): void {
435+
if (target[STORE_OPTIMISTIC] && !projectionWriteActive) {
426436
trackOptimisticStore(store);
427437
maskStoreTarget(target, true);
428438
}
429-
return { base, overrideKey, state };
430439
}
431440

432441
function upsertStoreNode(
@@ -643,6 +652,7 @@ export const storeTraps: ProxyHandler<StoreNode> = {
643652
const nextLength = isArrayIndexWrite && nextIndex > len ? nextIndex : undefined;
644653

645654
if (prev === value && nextLength === undefined) return true;
655+
armOptimisticStoreWrite(target, store);
646656
if (value !== undefined && value === base && nextLength === undefined)
647657
delete target[overrideKey]?.[property];
648658
else {
@@ -698,6 +708,7 @@ export const storeTraps: ProxyHandler<StoreNode> = {
698708
if (writeOnly(store)) {
699709
untrack(() => {
700710
const { base, overrideKey } = prepareStoreWrite(target, store, property);
711+
armOptimisticStoreWrite(target, store);
701712
const normalizedDescriptor =
702713
"value" in descriptor
703714
? {
@@ -734,19 +745,16 @@ export const storeTraps: ProxyHandler<StoreNode> = {
734745
untrack(() => {
735746
const useOptimistic = target[STORE_OPTIMISTIC] && !projectionWriteActive;
736747
const overrideKey = useOptimistic ? STORE_OPTIMISTIC_OVERRIDE : STORE_OVERRIDE;
737-
// Track store for reversion when writing optimistically
738-
if (useOptimistic) {
739-
trackOptimisticStore(target[$PROXY]);
740-
maskStoreTarget(target, true);
741-
}
742748
const prevLayer = getOverlayLayer(target, property);
743749
const prev = prevLayer ? prevLayer[property] : target[STORE_VALUE][property];
744750
if (
745751
property in target[STORE_VALUE] ||
746752
(target[STORE_OVERRIDE] && property in target[STORE_OVERRIDE])
747753
) {
754+
armOptimisticStoreWrite(target, target[$PROXY]);
748755
(target[overrideKey] || (target[overrideKey] = Object.create(null)))[property] = $DELETED;
749756
} else if (target[overrideKey] && property in target[overrideKey]) {
757+
armOptimisticStoreWrite(target, target[$PROXY]);
750758
delete target[overrideKey][property];
751759
} else return true;
752760
notifyStoreProperty(target, property, "delete", undefined, prev, true);

packages/solid-signals/tests/store/createOptimisticStore.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1733,6 +1733,101 @@ describe("createOptimisticStore", () => {
17331733
expect(isPending(() => state!.data)).toBe(false);
17341734
});
17351735

1736+
// The mask is armed by effective writes (data actually changed), not by
1737+
// trap fires: `s => s`, `s => ({ ...s })` (replays every key with equal
1738+
// values), same-value property writes, and deletes of absent properties
1739+
// are all no-ops and must not decree the store settled or entangle it.
1740+
describe("ineffective optimistic writes do not arm the mask", () => {
1741+
async function setup() {
1742+
const [$id, setId] = createSignal(1);
1743+
let state!: { data: number };
1744+
let setState!: (fn: (s: { data: number }) => any) => void;
1745+
1746+
createRoot(() => {
1747+
[state, setState] = createOptimisticStore(
1748+
async (s: { data: number }) => {
1749+
const id = $id();
1750+
await Promise.resolve();
1751+
s.data = id * 10;
1752+
},
1753+
{ data: 0 }
1754+
);
1755+
createRenderEffect(
1756+
() => state.data,
1757+
() => {}
1758+
);
1759+
});
1760+
1761+
flush();
1762+
await new Promise(r => setTimeout(r, 0));
1763+
expect(state.data).toBe(10);
1764+
1765+
// Refetch in flight: the leaf pends (live-probe baseline).
1766+
setId(2);
1767+
flush();
1768+
expect(isPending(() => state.data)).toBe(true);
1769+
1770+
return { state, setState };
1771+
}
1772+
1773+
async function finish(state: { data: number }) {
1774+
await new Promise(r => setTimeout(r, 0));
1775+
expect(state.data).toBe(20);
1776+
expect(isPending(() => state.data)).toBe(false);
1777+
}
1778+
1779+
it("pure no-op setter (s => s) leaves the refetch pending", async () => {
1780+
const { state, setState } = await setup();
1781+
setState(s => s);
1782+
expect(isPending(() => state.data)).toBe(true); // not masked pre-flush either
1783+
flush();
1784+
expect(isPending(() => state.data)).toBe(true);
1785+
await finish(state);
1786+
});
1787+
1788+
it("same-value property write leaves the refetch pending", async () => {
1789+
const { state, setState } = await setup();
1790+
setState(s => {
1791+
s.data = s.data;
1792+
});
1793+
expect(isPending(() => state.data)).toBe(true);
1794+
flush();
1795+
expect(isPending(() => state.data)).toBe(true);
1796+
await finish(state);
1797+
});
1798+
1799+
it("returned shallow copy leaves the refetch pending", async () => {
1800+
const { state, setState } = await setup();
1801+
setState(s => ({ ...s }));
1802+
expect(isPending(() => state.data)).toBe(true);
1803+
flush();
1804+
expect(isPending(() => state.data)).toBe(true);
1805+
await finish(state);
1806+
});
1807+
1808+
it("delete of an absent property leaves the refetch pending", async () => {
1809+
const { state, setState } = await setup();
1810+
setState(s => {
1811+
delete (s as any).missing;
1812+
});
1813+
expect(isPending(() => state.data)).toBe(true);
1814+
flush();
1815+
expect(isPending(() => state.data)).toBe(true);
1816+
await finish(state);
1817+
});
1818+
1819+
it("control: a real write mid-refetch masks", async () => {
1820+
const { state, setState } = await setup();
1821+
setState(s => {
1822+
s.data = 999;
1823+
});
1824+
flush();
1825+
expect(state.data).toBe(999);
1826+
expect(isPending(() => state.data)).toBe(false);
1827+
await finish(state);
1828+
});
1829+
});
1830+
17361831
it("isPending preserves async optimistic store overrides", async () => {
17371832
const [$id, setId] = createSignal(1);
17381833
let state: { data: number };

0 commit comments

Comments
 (0)