2.0.0-beta.15: reconcile() mishandles store-proxy values — leaks internal Signal records, and keyed reorder of proxy array items crashes with a stack overflow
Describe the bug
reconcile()'s internal unwrap() helper reads the wrong slot off a store proxy: it grabs STORE_NODE — the internal per-property signal-node bookkeeping record — instead of the store's value. When a reconciled value holds store proxies as property values (the exact shape createProjection commits, and any store-in-store composition like { selected: row }), and those proxies have live tracked nodes (i.e. they are rendered anywhere), the diff compares and writes the internal records instead of the data:
- After
setState(reconcile({ selected: rowB }, "id")), state.selected no longer holds data: rendered bindings show [object Object] (or undefined, depending on which properties of the source row were ever tracked), direct reads return raw internal Signal records ({ _equals, _value, _subs, ... } — name-mangled in the published build), and JSON.stringify(state.selected) throws Converting circular structure to JSON — the store's own data can no longer be serialized.
- Variant: when the rows have no key property (
keyFn returns undefined on the record), the swap is silently dropped — state.selected keeps the old row with no error.
- When the proxies are array items instead of object property values, it's worse: any keyed reorder/replace (
setList(reconcile([rowB, rowA], "id")) on a createStore([rowA, rowB])) crashes with RangeError: Maximum call stack size exceeded — the array branch hands the proxy to applyState as next, whose swap sets the row store's STORE_VALUE to its own proxy, so every subsequent read recurses through itself. Reproduced against the published npm package.
All three symptoms are 1.x → 2.0 regressions: on 1.9.14 the same three scenarios behave perfectly — the keyed swap lands (selected === rowB, clean JSON), the keyless swap applies, and the proxy-array reorder preserves identity on every row (verified). 1.x deep-unwraps proxies before diffing; 2.0's unwrap appears to be the port of that step, pointed at the wrong slot.
The bug only manifests once the nested proxies have been read in a tracked scope (STORE_NODE is otherwise unset and unwrap falls through to the proxy), which is why simple non-rendering tests pass. Distinct from #2774 (reconcile corrupting the proxy shape on array→object replacement): that is a shape-mismatch in the same diff, while this is a wrong-symbol read that corrupts values even when old and new shapes agree. None of the recent reconcile fixes (54b2175, bc92d00) touch this helper.
Your Example Website or App
https://stackblitz.com/edit/solidjs-templates-qjajl3gp?file=src%2FApp.tsx
Three panels, one per symptom — a selection store holding a keyed row proxy, a selection of keyless tag proxies, and a list of the row proxies. Everything is on screen except the crash, which lands in the console:
import { createStore, reconcile } from 'solid-js';
type Row = { id: number; name: string };
type Tag = { name: string };
export default function App() {
const [rowA] = createStore<Row>({ id: 1, name: 'a' });
const [rowB] = createStore<Row>({ id: 2, name: 'b' });
const [tagA] = createStore<Tag>({ name: 'x' });
const [tagB] = createStore<Tag>({ name: 'y' });
const [state, setState] = createStore<{ selected: Row }>({ selected: rowA });
const [tags, setTags] = createStore<{ selected: Tag }>({ selected: tagA });
const [list, setList] = createStore<Row[]>([rowA, rowB]);
const serialized = () => {
try { return JSON.stringify(state.selected); }
catch (e) { return `THROWS: ${(e as Error).message.split('\n')[0]}`; }
};
return (
<div>
{/* rendering the rows/tags gives them live tracked nodes, as any list UI would */}
<p>
rowA: {rowA.name} (#{rowA.id}) · rowB: {rowB.name} (#{rowB.id}) · tagA:{' '}
{tagA.name} · tagB: {tagB.name}
</p>
<h4>Bug 1 — internals leak (keyed)</h4>
<p>selected: {String(state.selected?.name)} (#{String(state.selected?.id)})</p>
<p>JSON.stringify(selected): {serialized()}</p>
<button onClick={() => setState(reconcile({ selected: rowB }, 'id'))}>
select row B
</button>
<h4>Bug 2 — silent drop (keyless)</h4>
<p>selected tag: {tags.selected?.name}</p>
<button onClick={() => setTags(reconcile({ selected: tagB }, 'id'))}>
select tag B
</button>
<h4>Bug 3 — stack-overflow crash (array items)</h4>
<p>list: {list.map(r => r?.name).join(',')}</p>
<button onClick={() => setList(reconcile([rowB, rowA], 'id'))}>
reorder list (crashes)
</button>
</div>
);
}
Steps to Reproduce the Bug or Issue
- Open the repro. Initial render:
selected: a (#1), JSON.stringify(selected): {"id":1,"name":"a"}, selected tag: x, list: a,b.
- Click
select row B (symptom 1). Observe:
selected: [object Object] (#[object Object])
JSON.stringify(selected): THROWS: Converting circular structure to JSON
The selection neither kept rowA nor became rowB — state.selected now holds framework internals: both bindings render signal-node records, and the store's own data can no longer be serialized. (Which shape a given read returns depends on tracking history: a property that was ever rendered on the source row yields its signal node, one that never was yields undefined.)
-
Click select tag B (symptom 2). Nothing happens: selected tag: x — the swap is silently dropped because the keyless tag records produce undefined from keyFn.
-
Click reorder list (crashes) (symptom 3). The click handler throws synchronously:
Uncaught RangeError: Maximum call stack size exceeded
The list never reorders (list: a,b frozen), and subsequent scheduler flushes keep throwing — the app is broken from here on. (This is why the crash button is last.)
Expected behavior
after "select row B": selected: b (#2) · JSON.stringify(selected): {"id":2,"name":"b"}
after "select tag B": selected tag: y
after "reorder list": list: b,a — no crash, row identity preserved
Screenshots or Videos
No response
Platform
- OS: macOS
- Browser: Chrome
- Version: 2.0.0-beta.15 (all symptoms reproduced against the published npm package; also verified at
next @ bad66625)
Additional context
What the leaked reads actually contain — logging state.selected.name after the click prints an internal signal-node record instead of "b" (property names minified in the published build; in source they are _equals, _value, _subs, ...):
<ref *2> {
v: <ref *1> {
Fe: [Function: isEqual],
ue: 0,
Z: 'b', ← the actual value, buried in the node record
D: { Re: [Circular *1], ... },
...
},
Symbol(0): [Circular *2] ← the circularity that breaks JSON.stringify
}
Root cause: packages/solid-signals/src/store/reconcile.ts:19-21:
function unwrap(value: any) {
return value?.[$TARGET]?.[STORE_NODE] ?? value;
}
STORE_NODE ("n") is the DataNodes record holding the proxy's per-property signal nodes; the store's data lives in STORE_VALUE ("v", plus STORE_OVERRIDE for pending writes — cf. unwrapStoreValue in store.ts:164-184, which does this correctly). unwrap is called on both sides of the object diff (unwrap(previous[key]) / unwrap(next[key]), reconcile.ts:191-192 and 341-342), so once a nested proxy has nodes:
previousValue/nextValue become the node records; keyFn(record) returns a signal node (never equal across records) → the "replace" branch runs setSignal(node, wrap(nextValue, target)), wrapping rowB's node record as the new store value — every subsequent property read returns a signal node (symptom 1);
- with no key property,
keyFn(record) is undefined → the recursive branch diffs one node record against a wrap of the other, which have no data keys to notify — nothing happens (symptom 2).
Suggested fix direction, two parts:
- Make
unwrap read the store value — value?.[$TARGET]?.[STORE_VALUE] ?? value (fixes symptoms 1–2).
- Normalize once at the dispatcher entry —
next = unwrap(next) as applyState's first line (fixes symptom 3). Array items and root reconcile() calls can hand applyState a proxy as next; without normalization the swap sets STORE_VALUE to the store's own proxy (the stack overflow), and the next === previous early-exit never matches proxy against raw.
Note unwrap should stay a shallow raw read rather than reusing unwrapStoreValue: that helper clones when a STORE_OVERRIDE is pending, and clones break the diff's identity mechanics (previous === next skip checks, and wrap(raw) resolving back to the existing proxy via STORE_LOOKUP). Pending overrides are already honored at the right layer — wrap(raw) returns the same proxy with its overrides, and the recursive applyState dispatches to the override-aware slow path.
2.0.0-beta.15:
reconcile()mishandles store-proxy values — leaks internal Signal records, and keyed reorder of proxy array items crashes with a stack overflowDescribe the bug
reconcile()'s internalunwrap()helper reads the wrong slot off a store proxy: it grabsSTORE_NODE— the internal per-property signal-node bookkeeping record — instead of the store's value. When a reconciled value holds store proxies as property values (the exact shapecreateProjectioncommits, and any store-in-store composition like{ selected: row }), and those proxies have live tracked nodes (i.e. they are rendered anywhere), the diff compares and writes the internal records instead of the data:setState(reconcile({ selected: rowB }, "id")),state.selectedno longer holds data: rendered bindings show[object Object](orundefined, depending on which properties of the source row were ever tracked), direct reads return raw internal Signal records ({ _equals, _value, _subs, ... }— name-mangled in the published build), andJSON.stringify(state.selected)throwsConverting circular structure to JSON— the store's own data can no longer be serialized.keyFnreturnsundefinedon the record), the swap is silently dropped —state.selectedkeeps the old row with no error.setList(reconcile([rowB, rowA], "id"))on acreateStore([rowA, rowB])) crashes withRangeError: Maximum call stack size exceeded— the array branch hands the proxy toapplyStateasnext, whose swap sets the row store'sSTORE_VALUEto its own proxy, so every subsequent read recurses through itself. Reproduced against the published npm package.All three symptoms are 1.x → 2.0 regressions: on 1.9.14 the same three scenarios behave perfectly — the keyed swap lands (
selected === rowB, clean JSON), the keyless swap applies, and the proxy-array reorder preserves identity on every row (verified). 1.x deep-unwraps proxies before diffing; 2.0'sunwrapappears to be the port of that step, pointed at the wrong slot.The bug only manifests once the nested proxies have been read in a tracked scope (
STORE_NODEis otherwise unset andunwrapfalls through to the proxy), which is why simple non-rendering tests pass. Distinct from #2774 (reconcile corrupting the proxy shape on array→object replacement): that is a shape-mismatch in the same diff, while this is a wrong-symbol read that corrupts values even when old and new shapes agree. None of the recent reconcile fixes (54b2175, bc92d00) touch this helper.Your Example Website or App
https://stackblitz.com/edit/solidjs-templates-qjajl3gp?file=src%2FApp.tsx
Three panels, one per symptom — a selection store holding a keyed row proxy, a selection of keyless tag proxies, and a list of the row proxies. Everything is on screen except the crash, which lands in the console:
Steps to Reproduce the Bug or Issue
selected: a (#1),JSON.stringify(selected): {"id":1,"name":"a"},selected tag: x,list: a,b.select row B(symptom 1). Observe:The selection neither kept rowA nor became rowB —
state.selectednow holds framework internals: both bindings render signal-node records, and the store's own data can no longer be serialized. (Which shape a given read returns depends on tracking history: a property that was ever rendered on the source row yields its signal node, one that never was yieldsundefined.)Click
select tag B(symptom 2). Nothing happens:selected tag: x— the swap is silently dropped because the keyless tag records produceundefinedfromkeyFn.Click
reorder list (crashes)(symptom 3). The click handler throws synchronously:The list never reorders (
list: a,bfrozen), and subsequent scheduler flushes keep throwing — the app is broken from here on. (This is why the crash button is last.)Expected behavior
Screenshots or Videos
No response
Platform
next@bad66625)Additional context
What the leaked reads actually contain — logging
state.selected.nameafter the click prints an internal signal-node record instead of"b"(property names minified in the published build; in source they are_equals,_value,_subs, ...):Root cause:
packages/solid-signals/src/store/reconcile.ts:19-21:STORE_NODE("n") is theDataNodesrecord holding the proxy's per-property signal nodes; the store's data lives inSTORE_VALUE("v", plusSTORE_OVERRIDEfor pending writes — cf.unwrapStoreValueinstore.ts:164-184, which does this correctly).unwrapis called on both sides of the object diff (unwrap(previous[key])/unwrap(next[key]), reconcile.ts:191-192 and 341-342), so once a nested proxy has nodes:previousValue/nextValuebecome the node records;keyFn(record)returns a signal node (never equal across records) → the "replace" branch runssetSignal(node, wrap(nextValue, target)), wrapping rowB's node record as the new store value — every subsequent property read returns a signal node (symptom 1);keyFn(record)isundefined→ the recursive branch diffs one node record against a wrap of the other, which have no data keys to notify — nothing happens (symptom 2).Suggested fix direction, two parts:
unwrapread the store value —value?.[$TARGET]?.[STORE_VALUE] ?? value(fixes symptoms 1–2).next = unwrap(next)asapplyState's first line (fixes symptom 3). Array items and rootreconcile()calls can handapplyStatea proxy asnext; without normalization the swap setsSTORE_VALUEto the store's own proxy (the stack overflow), and thenext === previousearly-exit never matches proxy against raw.Note
unwrapshould stay a shallow raw read rather than reusingunwrapStoreValue: that helper clones when aSTORE_OVERRIDEis pending, and clones break the diff's identity mechanics (previous === nextskip checks, andwrap(raw)resolving back to the existing proxy viaSTORE_LOOKUP). Pending overrides are already honored at the right layer —wrap(raw)returns the same proxy with its overrides, and the recursiveapplyStatedispatches to the override-aware slow path.