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

Description

@thedanchez

Summary

On solid-js@2.0.0-rc.7 (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 solid-js@2.0.0-rc.7 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

  • OS: macOS 15 (Darwin 24.6.0)
  • Node 24.13.0, solid-js@2.0.0-rc.7 / @solidjs/signals@2.0.0-rc.7 (also reproduces in vitest/jsdom)

Activity

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