fix(signals): guard reconcile's array paths against array/object shape changes - #2946
Conversation
…e changes
The object diff refuses to recurse into a pair whose container kinds
disagree — `Array.isArray(previous) !== Array.isArray(next)` replaces the
slot instead of merging. The array paths reach the same recursion through
`keyedMatch` and positional pairing and never applied that rule: two
keyless wrappables "match" because `keyFn` returns undefined for both,
whatever kind they are.
The slot's proxy then stays the wrong kind forever, since `Array.isArray`
inspects the proxy target fixed at wrap time. An object slot receiving an
array enumerates as `{"0":..,"1":..}`; an array slot receiving an object
reads `length` off that object and presents an empty array, dropping the
data outright.
Route all five sites — the keyed prefix loop and the positional loop in
both applyStateFast and applyStateSlow, plus applyArrayItem — through a
shared `recursablePair()` that folds the existing markRaw check together
with the kind check, matching the object diff and `descendInto`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: b19d23a The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Merging this PR will not alter performance
Comparing Footnotes
|
|
Merged — verified the diagnosis independently: |
The scenario measured 12.59 KB and had already drifted past the 12.5 KB cap before this batch of fixes. The overage is accumulated correctness work on paths createStore always retains — reconcile's container-kind guard (#2946), the projection derive-swap path (#2941), and the queue traversal pass stamp (#2947) — not a shakeable feature. Restores the ~2% headroom the sibling scenarios carry. Co-authored-by: Cursor <cursoragent@cursor.com>
The bug
reconcile()'s object diff refuses to recurse into a pair whose container kinds disagree —Array.isArray(previous) !== Array.isArray(next)replaces the slot instead of merging into it. The array paths reach the same recursion throughkeyedMatchand positional pairing, and never applied that rule: two keyless wrappables "match" becausekeyFnreturnsundefinedfor both, regardless of whether they're arrays or objects.So an incoming array gets merged into a proxy whose target is an object.
Array.isArrayon a Proxy inspects the target, which is fixed at wrap time, so the slot is wrong forever after:Spread,
.map,<For each>andJSON.stringifyall see an object. The reverse direction is worse — an array-shaped target receiving an object readslengthoff that object and the store presents[], silently dropping the data.The fix
Five call sites shared the gap: the keyed prefix loop and the positional loop in both
applyStateFastandapplyStateSlow, plusapplyArrayItem(which covers the keyed diff's trailing and moved slots). They now share arecursablePair()helper that folds the existingmarkRawcheck together with the container-kind check, so a kind change replaces the slot by reference — exactly what the object diff anddescendIntoalready do.Net effect on the hot paths is neutral:
recursablePairsubsumes checks those sites already performed inline.Tests
Four cases added to
tests/store/reconcile.test.ts, covering both directions, the keyed-array trailing slot (applyArrayItem), and positionalkey: nullmerging. All four fail onnextand pass with the fix.Found by differential fuzzing (random trees reconciled against a plain-object oracle, checking both direct reads and effect-observed values). Full suite green: 19/19 turbo tasks, 1150
@solidjs/signalstests.🤖 Generated with Claude Code