Describe the bug
An effect observing deep(store) stops receiving descendant updates after an unrelated property on the parent is edited. With { title: "a", child: { count: 0 } }, changing title to "b" reruns the observer, but subsequently changing child.count to 2 does not. Direct reads see 2; the observer's last snapshot still contains 0.
Priority assessment: P1. Ordinary parent edits can silently disconnect deep observers, leaving UI, autosave, or synchronization effects with stale data.
Platform and version
- Source checkout:
next at 472232bf; package version 2.0.0-rc.6.
- macOS, Node.js v25.1.0, Vitest 4.1.6. No browser or DOM required.
- Verified against the tracked
@solidjs/signals source, rather than a downloaded npm release.
Reproduction
In a checkout of the commit above with dependencies installed, save the following as packages/signals/tests/store/deep-probes.test.ts and run from the repository root:
pnpm --dir packages/signals exec vitest run tests/store/deep-probes.test.ts
import {
createRenderEffect,
createRoot,
createStore,
deep,
flush,
untrack,
} from "../../src/index.ts";
for (const editParent of [false, true]) {
it(`deep remains subscribed to descendants after ${editParent ? "parent" : "child"} edit`, () => {
let dispose!: () => void;
const seen: { title: string; child: { count: number } }[] = [];
const [store, setStore] = createRoot((d) => {
dispose = d;
const pair = createStore({ title: "a", child: { count: 0 } });
createRenderEffect(
() => deep(pair[0]),
(value) => {
seen.push(value);
},
);
return pair;
});
try {
flush();
setStore((draft) => {
if (editParent) draft.title = "b";
else draft.child.count = 1;
});
flush();
expect(seen.at(-1)).toEqual(
editParent
? { title: "b", child: { count: 0 } }
: { title: "a", child: { count: 1 } },
);
setStore((draft) => {
draft.child.count = 2;
});
flush();
expect(untrack(() => store.child.count)).toBe(2);
expect(seen.at(-1)?.child.count).toBe(2);
} finally {
dispose();
}
});
}
Expected and actual behavior
After the two edits, the observer should receive { title: "b", child: { count: 2 } }. Instead, its last value remains { title: "b", child: { count: 0 } } even though store.child.count is 2.
The matrix produces 1 failure and 1 passing control: editing the child first preserves the subscription, while editing the parent first loses it. The same results occur with __DEV__ and __TEST__ both enabled and both disabled when compiling local source.
Source analysis
The tracking walk in deepNext() enumerates Reflect.ownKeys(readSource(t)). During the rerun following a parent edit, that source can be a pending prototype overlay whose own keys contain only changed fields. Unchanged children live on its prototype, so the walk does not resubscribe to them. Snapshot materialization happens after tracking and does not restore the dropped subscriptions.
The reproduction uses a plain synchronous store with a flush between writes; it needs no optimistic state or transaction.
Describe the bug
An effect observing
deep(store)stops receiving descendant updates after an unrelated property on the parent is edited. With{ title: "a", child: { count: 0 } }, changingtitleto"b"reruns the observer, but subsequently changingchild.countto2does not. Direct reads see2; the observer's last snapshot still contains0.Priority assessment: P1. Ordinary parent edits can silently disconnect deep observers, leaving UI, autosave, or synchronization effects with stale data.
Platform and version
nextat472232bf; package version2.0.0-rc.6.@solidjs/signalssource, rather than a downloaded npm release.Reproduction
In a checkout of the commit above with dependencies installed, save the following as
packages/signals/tests/store/deep-probes.test.tsand run from the repository root:pnpm --dir packages/signals exec vitest run tests/store/deep-probes.test.tsExpected and actual behavior
After the two edits, the observer should receive
{ title: "b", child: { count: 2 } }. Instead, its last value remains{ title: "b", child: { count: 0 } }even thoughstore.child.countis2.The matrix produces 1 failure and 1 passing control: editing the child first preserves the subscription, while editing the parent first loses it. The same results occur with
__DEV__and__TEST__both enabled and both disabled when compiling local source.Source analysis
The tracking walk in
deepNext()enumeratesReflect.ownKeys(readSource(t)). During the rerun following a parent edit, that source can be a pending prototype overlay whose own keys contain only changed fields. Unchanged children live on its prototype, so the walk does not resubscribe to them. Snapshot materialization happens after tracking and does not restore the dropped subscriptions.The reproduction uses a plain synchronous store with a flush between writes; it needs no optimistic state or transaction.