Skip to content

2.0.0-rc.7: deep() over a createOptimisticStore view never re-runs on base-store writes (per-key reads do) — regression since rc.1 #3323

Closed

Description

@thedanchez

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.ts

Results (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-witness getDeepNode(t) (target.dk). The child keys come from Reflect.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 because serveDataKey links through the chain. The $TRACK get-trap path also handles it explicitly: "a chained backing's $TRACK reads 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's walkT has no equivalent read-through: it reads the view target's k and dk only. Nothing that happens on the base family ever touches those two nodes, so a base write is invisible to a deep() 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

  1. 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.
  2. 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 at deep-held-visibility.test.ts and optimistic-observer-invariance.test.ts and 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

Activity

  1. thedanchez commented on Sep 9, 2026

    @thedanchez
    Author

    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) made deep()/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].qty reads back as 2. What doesn't happen is the wake-up. The effect tracking deep(view) never re-runs, while the effect tracking view[0].qty does, 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) fixing deep() dropping subscriptions after a parent-field edit reads like the same category.

  2. ryansolid commented on Sep 9, 2026

    @ryansolid
    Member

    Fixed on next in ae0ec3f5 — thank you for a genuinely excellent report. The diagnosis was right on both counts: it's a gap, not a design choice (the reader-families-agree rule applies to subscription as much as to value), and walkT had no read-through where the $TRACK trap does.

    Two defects were hiding in that walk, not one. Besides never subscribing the inner record's key-set/deep-witness nodes, it resolved children via the inner proxy's descriptor trap — which hands back the base raw — and wrapNext(raw) then created a second, non-chained view target for each row that nothing ever wrote to or bumped. The walk now reads through the whole chain and resolves children to exactly the chained targets view[i] serves. Your repro's three effects now fire 1×/1×/1×.

    Pulling on that thread found two more holes in the same composition, all landed together:

    • deep() over any optimistic store was deaf to writes on a row added under a held action. The row lives in presence/value overrides, not the committed backing, and the walk enumerated the raw backing — so the row appeared in the snapshot but its record was never subscribed. The ownKeys/getOwnPropertyDescriptor trap bodies are now shared helpers the walk uses, so the walk sees what readers see by construction.
    • Your "possibly related" note is the same bug, not a separate one. notifyOptimisticWrites diffed the draft against t.v, which for a derived view is the inner proxy; its reads return inner child proxies, the draft's clone holds raws, so every untouched row compared unequal and got a value override — served as a fresh non-chained target for the life of the action, snapped back at settle. It now compares unwrapped values, chained targets serving from a pending backing resolve inner-owned raws to the inner proxy before wrapping, and snapshot() composes outer overrides below the root. view.map(r => r) under an overlay is back to identity-stable for untouched rows, so your pointer-diffs are O(delta) again.

    Regression tests for all three shapes are in tests/store/deep-chained-view.test.ts. Only the deep() walk, the chained-view read path and the optimistic diff changed; plain-store reads/writes are untouched and bundles that don't use stores are byte-identical.

    Ships in rc.8.

    — Claude via Cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions