Describe the bug
In a derived store created with createStore(seed, seed()), editing a nested child can change an ancestor's proxy identity and disconnect existing property observers. An effect watching store.row.selected receives the initial true, but after a child write and then row.selected = false, it never receives false. Direct reads return the updated value.
Priority assessment: P1. Existing reactive bindings silently remain stale after ordinary nested edits, despite the store accepting the later write.
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/derived-ancestor-observers.test.ts and run from the repository root:
pnpm --dir packages/signals exec vitest run tests/store/derived-ancestor-observers.test.ts
import {
createRenderEffect,
createRoot,
createStore,
flush,
untrack,
} from "../../src/index.ts";
describe("subscriptions after descendant writes", () => {
for (const derived of [false, true]) {
it(`${derived ? "derived" : "plain"} store keeps an ancestor observer connected`, () => {
const seed = () => ({ row: { selected: true, child: { count: 0 } } });
let dispose!: () => void;
const seen: boolean[] = [];
const [store, setStore] = createRoot((d) => {
dispose = d;
const pair = derived ? createStore(seed, seed()) : createStore(seed());
createRenderEffect(
() => pair[0].row.selected,
(value) => {
seen.push(value);
},
);
return pair;
});
try {
flush();
const originalRow = untrack(() => store.row);
setStore((draft) => {
draft.row.child.count = 1;
});
flush();
setStore((draft) => {
draft.row.selected = false;
});
flush();
expect(untrack(() => store.row.selected)).toBe(false);
expect.soft(seen).toEqual([true, false]);
expect(untrack(() => store.row === originalRow)).toBe(true);
} finally {
dispose();
}
});
}
});
Expected and actual behavior
After writing row.child.count = 1, flushing, then writing row.selected = false and flushing:
| Check |
Expected |
Actual for derived store |
Direct store.row.selected read |
false |
false |
| Values received by the observer |
[true, false] |
[true] |
store.row === originalRow |
true |
false |
The matrix produces 1 failing derived-store case and 1 passing plain-store control. Both the notification and identity assertions fail in the derived case.
Source analysis
privatizeCommitted() registers the cloned ancestor in storeNextLookup, while reads for a derived store resolve targets through its family map. The next read can therefore wrap the clone as a new target, leaving existing subscribers attached to the previous target.
Related context: #3271 concerned a descendant-first write discarding an ancestor's value in the same flush. Here the writes are separated by flush(), the final value is correct, and the defect is lost observer connectivity and proxy identity. This still fails on the commit above, which includes the #3271 fix.
Describe the bug
In a derived store created with
createStore(seed, seed()), editing a nested child can change an ancestor's proxy identity and disconnect existing property observers. An effect watchingstore.row.selectedreceives the initialtrue, but after a child write and thenrow.selected = false, it never receivesfalse. Direct reads return the updated value.Priority assessment: P1. Existing reactive bindings silently remain stale after ordinary nested edits, despite the store accepting the later write.
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/derived-ancestor-observers.test.tsand run from the repository root:pnpm --dir packages/signals exec vitest run tests/store/derived-ancestor-observers.test.tsExpected and actual behavior
After writing
row.child.count = 1, flushing, then writingrow.selected = falseand flushing:store.row.selectedreadfalsefalse[true, false][true]store.row === originalRowtruefalseThe matrix produces 1 failing derived-store case and 1 passing plain-store control. Both the notification and identity assertions fail in the derived case.
Source analysis
privatizeCommitted()registers the cloned ancestor instoreNextLookup, while reads for a derived store resolve targets through its family map. The next read can therefore wrap the clone as a new target, leaving existing subscribers attached to the previous target.Related context: #3271 concerned a descendant-first write discarding an ancestor's value in the same flush. Here the writes are separated by
flush(), the final value is correct, and the defect is lost observer connectivity and proxy identity. This still fails on the commit above, which includes the #3271 fix.