Skip to content

Commit 461b242

Browse files
fix(store): snapshot() and deep() read through the optimistic overlay (closes #2850)
snapshotImpl now resolves values optimistic-over-regular via mergedOverlay, the same order as every proxy trap and reconcile — an active optimistic write is THE value for every reader (A17), and snapshot on regular stores already read the pending-write overlay synchronously. Also lands the specialized no-overlay walk deferred from #2756. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b7c03a7 commit 461b242

4 files changed

Lines changed: 192 additions & 2 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+
`snapshot()` and `deep()` now read through the optimistic overlay on `createOptimisticStore` (#2850), agreeing with every other reader: an active optimistic write is THE value (A17), and snapshot's documented behavior on regular stores is already to read the pending-write overlay synchronously. Resolution order matches the proxy traps and `reconcile` (optimistic over regular). Also lands the specialized no-overlay snapshot walk deferred from #2756.

ISSUE-TRIAGE.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,56 @@ We did **not** merge the PR because it also bundles a `map.ts` `_offset = 0` cha
181181
182182
---
183183
184+
## #2850 — `snapshot()` / `deep()` ignore optimistic writes on `createOptimisticStore`
185+
186+
- **Reporter:** brenelz
187+
- **State:** ENGINEERING DONE (July 7) — fix implemented (changeset `fix-snapshot-optimistic-overlay.md`), awaiting commit + response post.
188+
189+
### Decision
190+
191+
**Ruled (Ryan, July 7): include the overlay.** snapshot is primarily a reactive read feeding the front half of effects (which generally run post-settle anyway) — "sort of a deep untrack," and untrack strips tracking, not value selection. Supporting arguments that make it airtight:
192+
193+
1. **A17 uniformity** — while an override is active every reader sees it, and *no* read form strips an override (`untrack` doesn't; even `latest` doesn't — A20 classifies overrides as confirmation-uncertainty). A committed-value-peeking snapshot would be the only mask-bypassing read in the system.
194+
2. **Regular-store precedent** — snapshot already reads the pending-write overlay synchronously (documented in `utilities.test.ts` as by-design). The optimistic overlay is the same concept under a different key; excluding it was the inconsistency.
195+
3. **Serialization wants it too** — serializing inside an action (POST body from draft state) needs the draft, not stale committed data.
196+
4. **Bounded divergence** — post-settle the overlay is gone and both answers agree; the window where this matters (transition) is exactly where lane render effects and action bodies read, and both want the optimistic view.
197+
198+
No snapshot/deep split (same impl, same answer). Implementation: `mergedOverlay(target)` in `snapshotImpl` — optimistic over regular, the same order as every proxy trap and `reconcile`; merge allocates only in the rare both-layers case. Also landed the `snapshotImpl` specialized no-overlay walk deferred from PR #2756 (this ruling unblocked it). 4 regression tests (direct write, transition+revert, nested/array/delete, `deep()` re-run cycle) — all fail without the fix.
199+
200+
Note: `unwrapStoreValue` (set-trap value extraction) still consults only `STORE_OVERRIDE` — deliberately untouched: writing another store's optimistic *guesses* into a target store's base data is a different semantic question than reading, and the guess would outlive its revert. Flag if it comes up.
201+
202+
### Drafted response (NOT posted)
203+
204+
> Good catch, and the framing in the root-cause section is exactly right — `snapshotImpl` was the one consumer of the three-layer store model that never learned about the optimistic overlay. Fixed on `next`: `snapshot()` and `deep()` now resolve values optimistic-overlay-first, the same order as the proxy traps and `reconcile`. The guiding rule is that an active optimistic write is *the* value for every reader — nothing in the read API peeks behind it — and snapshot on regular stores already read the pending-write overlay synchronously, so this brings optimistic stores in line with the documented behavior. Ships in the next beta.
205+
206+
---
207+
208+
## PR #2756 — perf: optimize signals hot paths
209+
210+
- **Author:** brenelz
211+
- **State:** ENGINEERING DONE (July 7) — safe subset landed as `b7c03a7b`, re-implemented on the current tree (the June 12 diff predates `_pendingObserver` on links and two `setSignal` rewrites, so it no longer applied). The deferred `snapshotImpl` walk landed with the #2850 fix. Response drafted below, NOT posted; close the PR with credit when posted.
212+
213+
### Decision
214+
215+
Four optimizations reviewed individually:
216+
217+
1. **Gen-stamp dep revalidation — TAKEN.** His flamegraph diagnosis was right: `isValidLink` scans the dep list from the head on every non-consecutive re-read of a dep within one recompute pass — O(n²), 51% of the deep-reconcile bench. Links now carry `_gen` stamped from the subscriber's `_depGen` pass counter (bumped at recompute start alongside the `_depsTail = null` reset, so prefix membership ⇔ stamped-this-pass). `isValidLink` deleted. Verified on the current tree: deep-tree reconcile all-paths ~18.9ms → ~2.6ms (**7.3x**), single `deep()` effect ~9.7ms → ~1.8ms (**5.3x**), creation paths within noise.
218+
2. **Reconcile/store allocation trims — TAKEN.** `getAllKeys` same-keys fast path, `unwrap` primitive early-return, `getKeys` untrack-closure skip for plain sources, cached bound effect runner (safe: `enqueue` has no identity dedupe and tracked effects already reuse one `_run`).
219+
3. **`notifyEpoch` skip-walk — DECLINED.** Global cache-invalidation scheme whose correctness requires enumerating every notification-consumption path; a missed path is silently stale UI. It was already accreting escape hatches in June (optimistic always walks, `_snapshotValue` bypass) and the machinery it must not break (lanes, holds, gated subs, transition stash) has been rebuilt since. Its headline 24x is on `update1to1000` — same-signal-written-1000-times, not a real workload. If that bench ever matters, re-derive the invalidation set against the current scheduler as its own designed change.
220+
4. **`snapshotImpl` specialized walk — DEFERRED.** Touches the function whose overlay semantics are pending the #2850 ruling; no point optimizing what may be rewritten.
221+
222+
Size cost of the taken subset: +140B min / +63B gz (new link/node fields minus the deleted scan) — accepted for the asymptotic win on store-heavy workloads.
223+
224+
### Drafted response (NOT posted)
225+
226+
> Great find on `isValidLink` — the head-scan on non-consecutive dep re-reads was exactly the right diagnosis, and it's the dominant cost in store-heavy recomputes (your 51% flamegraph number reproduced on our end). We've landed the gen-stamp revalidation plus the reconcile/store allocation trims (`getAllKeys` fast path, `unwrap` primitive early-return, `getKeys` untrack skip, cached bound runner) on `next`, re-implemented against the current tree since the branch predates a couple of `link()`/`setSignal` rewrites — with your numbers verified: ~7x on deep-tree reconcile with all paths subscribed, ~5x on a `deep()` effect.
227+
>
228+
> The one piece we deliberately didn't take is the `notifyEpoch` skip-walk. It's a global invalidation scheme where correctness depends on catching every path that consumes a queued notification, and a missed one means silently stale UI — and the scheduler internals it has to track (optimistic lanes, transition holds, gated subscribers) have been substantially rebuilt since June. The bench it targets (writing one signal 1000× in a batch) is also not a shape real apps hit. If that path ever shows up in real workloads we'd want to re-derive the invalidation set against the current scheduler as its own change. The `snapshotImpl` specialized walk landed alongside the #2850 fix (it was waiting on that ruling, since it touches the same function).
229+
>
230+
> Closing since the taken parts are in — thanks, this was a genuinely valuable profile-driven find.
231+
232+
---
233+
184234
## #2801 — "Many hydration bugs" (six-bug report)
185235
186236
- **Reporter:** dangkyokhoang

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getPropertyDescriptor,
1010
isWrappable,
1111
ownEnumerableKeys,
12+
STORE_OPTIMISTIC_OVERRIDE,
1213
STORE_OVERRIDE,
1314
STORE_LOOKUP,
1415
STORE_VALUE,
@@ -19,6 +20,18 @@ import {
1920
type StoreNode
2021
} from "./store.js";
2122

23+
/**
24+
* The write overlay a snapshot must read through: optimistic writes shadow
25+
* regular pending writes, the same resolution order as every proxy trap and
26+
* `reconcile` (#2850). Merging allocates only in the rare both-present case
27+
* (a derived optimistic store with an in-flight projection commit).
28+
*/
29+
function mergedOverlay(target: StoreNode): Record<PropertyKey, any> | undefined {
30+
const override = target[STORE_OVERRIDE];
31+
const opt = target[STORE_OPTIMISTIC_OVERRIDE];
32+
return override && opt ? { ...override, ...opt } : (opt ?? override);
33+
}
34+
2235
function snapshotImpl<T>(
2336
item: any,
2437
track: boolean,
@@ -31,7 +44,7 @@ function snapshotImpl<T>(
3144
if (!map) map = new Map();
3245
if ((target = item[$TARGET] || lookup?.get(item)?.[$TARGET])) {
3346
if (track) trackSelf(target, $TRACK);
34-
override = target[STORE_OVERRIDE];
47+
override = mergedOverlay(target);
3548
isArray = Array.isArray(target[STORE_VALUE]);
3649
map.set(
3750
item,
@@ -56,13 +69,32 @@ function snapshotImpl<T>(
5669
result[i] = unwrapped;
5770
}
5871
}
72+
} else if (!override) {
73+
// Specialized walk for the common no-overlay case (from #2756): the own
74+
// descriptor gives the value directly, so each property is read once with
75+
// no overlay membership checks.
76+
const keys = getKeys(item, undefined);
77+
for (let i = 0, l = keys.length; i < l; i++) {
78+
const prop = keys[i];
79+
const desc = Object.getOwnPropertyDescriptor(item, prop)!;
80+
if (desc.get) continue;
81+
v = desc.value;
82+
if (track && isWrappable(v)) wrap(v, target);
83+
if ((unwrapped = snapshotImpl(v, track, map, lookup)) !== v || result) {
84+
if (!result) {
85+
result = Object.create(Object.getPrototypeOf(item)) as Record<PropertyKey, any>;
86+
Object.assign(result, item);
87+
}
88+
result[prop] = unwrapped;
89+
}
90+
}
5991
} else {
6092
const keys = getKeys(item, override);
6193
for (let i = 0, l = keys.length; i < l; i++) {
6294
let prop = keys[i];
6395
const desc = getPropertyDescriptor(item, override, prop)!;
6496
if (desc.get) continue;
65-
v = override && prop in override ? override[prop] : item[prop];
97+
v = prop in override ? override[prop] : item[prop];
6698
if (track && isWrappable(v)) wrap(v, target);
6799
if ((unwrapped = snapshotImpl(v, track, map, lookup)) !== item[prop] || result) {
68100
if (!result) {

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
latest,
1414
mapArray,
1515
refresh,
16+
snapshot,
1617
untrack,
1718
type Refreshable
1819
} from "../../src/index.js";
@@ -1544,6 +1545,108 @@ describe("createOptimisticStore", () => {
15441545
});
15451546
});
15461547

1548+
// #2850: snapshot()/deep() must agree with every other reader — an active
1549+
// optimistic overlay is THE value (A17), and snapshot's documented behavior
1550+
// on regular stores is to read the pending-write overlay synchronously. The
1551+
// optimistic overlay is the same concept under a different key.
1552+
describe("snapshot and deep see optimistic writes (#2850)", () => {
1553+
it("snapshot sees an optimistic write immediately", () => {
1554+
const [state, setState] = createOptimisticStore({ name: "John" });
1555+
setState(s => {
1556+
s.name = "Jake";
1557+
});
1558+
expect(state.name).toBe("Jake");
1559+
expect(snapshot(state).name).toBe("Jake");
1560+
});
1561+
1562+
it("snapshot shows the overlay during a transition and the committed value after revert", async () => {
1563+
const [state, setState] = createOptimisticStore({ count: 0, label: "a" });
1564+
1565+
const doAsync = action(function* () {
1566+
setState(s => {
1567+
s.count = 1;
1568+
});
1569+
yield Promise.resolve();
1570+
});
1571+
1572+
doAsync();
1573+
flush();
1574+
const during = snapshot(state);
1575+
expect(during).toEqual({ count: 1, label: "a" });
1576+
// Overlay present: snapshot allocates a fresh plain object, like a
1577+
// regular store with pending writes.
1578+
expect(during).not.toBe(snapshot(state));
1579+
1580+
await Promise.resolve();
1581+
expect(snapshot(state)).toEqual({ count: 0, label: "a" });
1582+
});
1583+
1584+
it("snapshot sees nested writes, array mutations, and deletes through the overlay", async () => {
1585+
const [state, setState] = createOptimisticStore<{
1586+
user: { name: string; tmp?: number };
1587+
todos: { id: number; text: string }[];
1588+
}>({
1589+
user: { name: "John", tmp: 1 },
1590+
todos: [{ id: 1, text: "one" }]
1591+
});
1592+
1593+
const doAsync = action(function* () {
1594+
setState(s => {
1595+
s.user.name = "Jake";
1596+
delete s.user.tmp;
1597+
s.todos.push({ id: 2, text: "two" });
1598+
});
1599+
yield Promise.resolve();
1600+
});
1601+
1602+
doAsync();
1603+
flush();
1604+
const snap = snapshot(state);
1605+
expect(snap.user).toEqual({ name: "Jake" });
1606+
expect("tmp" in snap.user).toBe(false);
1607+
expect(snap.todos).toEqual([
1608+
{ id: 1, text: "one" },
1609+
{ id: 2, text: "two" }
1610+
]);
1611+
1612+
await Promise.resolve();
1613+
expect(snapshot(state)).toEqual({
1614+
user: { name: "John", tmp: 1 },
1615+
todos: [{ id: 1, text: "one" }]
1616+
});
1617+
});
1618+
1619+
it("deep() in a render effect re-runs on the optimistic write and again on revert", async () => {
1620+
const [state, setState] = createOptimisticStore({ count: 0 });
1621+
const values: number[] = [];
1622+
1623+
createRoot(() => {
1624+
createRenderEffect(
1625+
() => deep(state),
1626+
v => {
1627+
values.push(v.count);
1628+
}
1629+
);
1630+
});
1631+
flush();
1632+
expect(values).toEqual([0]);
1633+
1634+
const doAsync = action(function* () {
1635+
setState(s => {
1636+
s.count = 1;
1637+
});
1638+
yield Promise.resolve();
1639+
});
1640+
1641+
doAsync();
1642+
flush();
1643+
expect(values).toEqual([0, 1]);
1644+
1645+
await Promise.resolve();
1646+
expect(values).toEqual([0, 1, 0]);
1647+
});
1648+
});
1649+
15471650
describe("isPending and latest() with async optimistic store", () => {
15481651
it("async store re-runs on dependency change", async () => {
15491652
const [$id, setId] = createSignal(1);

0 commit comments

Comments
 (0)