Repository navigation
Description
Summary
On [email protected] (browser build), an effect that tracks a createOptimisticStore view through deep() never re-runs when the base store it derives from is written. A per-key read on the same view row re-runs fine, and reading the value afterwards shows the new data — so the write lands and the view serves it, but deep() never hears about it.
This holds for both shapes — deep(view[0]) on a row proxy and deep(view) on the root — and needs no action, no optimistic write, and no in-flight transaction. Just a plain base write with the optimistic view sitting there.
Works on rc.0. Broken from rc.1 onward (verified with @solidjs/signals pinned to rc.1), which lines up with the store rewrite landing in rc.1 (0797215, "deep() subscribes one witness node per record").
Minimal reproduction
mkdir solid-deep-optimistic && cd solid-deep-optimistic
npm init -y >/dev/null && npm i [email protected] tsx >/dev/null
cat > repro.ts <<'TS'
import { createEffect, createOptimisticStore, createRoot, createStore, deep, flush } from "solid-js";
type Row = { id: string; qty: number };
createRoot((dispose) => {
const [base, setBase] = createStore<Row[]>([{ id: "a", qty: 1 }]);
const [view] = createOptimisticStore<Row[]>(base);
flush();
const runs = { "deep(view[0])": 0, "deep(view)": 0, "view[0].qty": 0 };
const row = view[0];
createEffect(() => deep(row), () => { runs["deep(view[0])"]++; });
createEffect(() => deep(view), () => { runs["deep(view)"]++; });
createEffect(() => row.qty, () => { runs["view[0].qty"]++; });
flush();
const first = { ...runs };
// authoritative write to the row — no action, no optimistic write anywhere
setBase((draft) => { draft[0].qty = 2; });
flush();
for (const key of Object.keys(runs) as (keyof typeof runs)[]) {
const fired = runs[key] - first[key];
console.log(`${key.padEnd(14)} re-ran ${fired}x ${key === "deep(view[0])" ? (fired ? "PASS" : "FAIL") : ""}`);
}
console.log("view[0].qty reads as", view[0].qty);
dispose();
});
TS
echo "--- browser build ---"; NODE_OPTIONS="--conditions=browser" npx tsx repro.ts
echo "--- browser build (dev) ---"; NODE_OPTIONS="--conditions=browser --conditions=development" npx tsx repro.tsResults (Node 24.13, macOS)
| version | deep(view[0]) |
deep(view) |
view[0].qty |
view[0].qty reads as |
|---|---|---|---|---|
| rc.7 browser | re-ran 0x ❌ | re-ran 0x ❌ | re-ran 1x ✅ | 2 |
| rc.7 browser + development | re-ran 0x ❌ | re-ran 0x ❌ | re-ran 1x ✅ | 2 |
rc.1 browser (@solidjs/signals pinned to rc.1) |
re-ran 0x ❌ | re-ran 0x ❌ | re-ran 1x ✅ | 2 |
| rc.0 browser (control) | re-ran 1x ✅ | re-ran 1x ✅ | re-ran 1x ✅ | 2 |
The same deep(row) effect on a plain createStore row (no optimistic view) re-runs on rc.7 as expected, so this is specific to the optimistic-view composition.
Swapping setBase for an optimistic setView write does wake deep(view[0]) on rc.7 (it fires for the write and again for the revert). So the deep witness sees writes that go through the view's own family, but not writes that arrive from the base family it composes over.
Where it looks like it goes wrong (from the rc.7 @solidjs/signals source, src/store/next/store.ts)
deep()→deepNext()walks targets directly and subscribes two nodes per record:getKeySetNode(t)and the lazy deep-witnessgetDeepNode(t)(target.dk). The child keys come fromReflect.ownKeys(readSource(t)), so no per-key nodes get linked on the way down.- Write paths bump the witness via
bumpDeep(t)— in the setter's notify (written-keys diff),notifyFold, and the reconcile walk. Those bumps land on the target that was written: the base family's target. - A derived optimistic view's targets are a separate family with a chained backing (
t.ch, §7b). Per-key reads survive this becauseserveDataKeylinks through the chain. The$TRACKget-trap path also handles it explicitly: "a chained backing's$TRACKreads through to the INNER store's key-set" (2.0.0-beta-16 | optimistic update is not being rolledback when rendered from a derived store and a<For>#2864 / core R21,readSource(target)[$TRACK]). deepNext'swalkThas no equivalent read-through: it reads the view target'skanddkonly. Nothing that happens on the base family ever touches those two nodes, so a base write is invisible to adeep()subscriber on the view — while an optimistic write (which goes through the view family's own setter) bumps them and fires.
The $TRACK read-through rule looks like the closest precedent: walkT has no equivalent for the inner target's key-set and deep-witness nodes when t.ch is set.
The question
- By design? If
deep()over an optimistic view is meant to be a view-family-only subscription and base changes are expected to be tracked per key, that's a real contract change from rc.0 and worth a line in the migration notes — happy to close and switch our projection to per-key tracking. - Gap? If
deep()is meant to track the composed view (which is what rc.0 did and what the deep()/snapshot readers and per-key readers disagree while a transaction holds store landings #3147 test's "reader families agree" ruling reads like), then base-family writes are not bumping the view's deep witness. I looked atdeep-held-visibility.test.tsandoptimistic-observer-invariance.test.tsand both drive changes through the optimistic setter / reconcile, so a plain base write to a derived view doesn't seem to be pinned by a test yet.
Impact
In @dschz/solid-ag-grid the rowStore adapter binds one createEffect(() => deep(row), …) per row on the user's store (plain or optimistic view) and projects the returned plain payload into AG Grid transactions. On rc.1+ every optimistic-view user goes silent: optimistic writes still project, but the server-confirmed truth (and any later authoritative update) never reaches the grid. Plain stores are unaffected. This is currently the one thing holding our rc.0 → rc.7 bump.
Possibly related (happy to open separately)
While an optimistic overlay is active, view.map(r => r) on rc.7 returns fresh proxies for every row, not just the touched ones — identities are restored at revert/settle. rc.0 kept untouched rows identity-stable through the overlay. If that's intentional it's fine, just noting it since anything doing pointer-diffs over the view (we do, mapArray-style) now pays O(n) per optimistic action instead of O(delta).
Platform
- OS: macOS 15 (Darwin 24.6.0)
- Node 24.13.0,
[email protected]/@solidjs/[email protected](also reproduces in vitest/jsdom)
Activity
thedanchez commentedon Sep 9, 2026
One more data point on the "by design or gap" question, since #3147 might look like it already covers this.
#3147 was about which value
deep()composes while a transaction holds a store landing. The fix (aa03f76, rc.6) madedeep()/snapshot()and per-key reads share one visibility decision so they agree on the value.This one is about the subscription. The value is already correct here: after the base write,
view[0].qtyreads back as2. What doesn't happen is the wake-up. The effect trackingdeep(view)never re-runs, while the effect trackingview[0].qtydoes, in the same flush, with no transaction or hold anywhere.So by the "reader families must agree" rule pinned in
deep-held-visibility.test.ts, this looks like a gap rather than a design choice. #3283 (rc.7) fixingdeep()dropping subscriptions after a parent-field edit reads like the same category.fix(store): deep() hears base and optimistic writes through chained v…
ryansolid commentedon Sep 9, 2026
chore: bump Solid stack to 2.0.0-rc.7 (peer floor ^2.0.0-rc.7)
fix(rowStore): per-key tracking fallback for optimistic views (solid#…
chore: bump Solid stack to 2.0.0-rc.7 (peer floor ^2.0.0-rc.7)
fix(rowStore): per-key tracking fallback for optimistic views (solid#…
chore: Solid 2.0.0-rc.11; remove the #3323 optimistic-view fallback