Repository navigation
fix(store): snapshot() and deep() preserve array length when trailing indices were deleted (closes #2846) - #2848
yumemi-thomas wants to merge 2 commits into
Conversation
🦋 Changeset detectedLatest commit: fdc9969 The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 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 |
|
Correct on all counts — Your branch predates the #2850/#2756 |
…es (#2846, from #2848) snapshotImpl's array branch skipped $DELETED slots without restoring the result's length afterwards, so a deleted trailing index truncated the copy while the proxy (correctly, per plain-JS delete semantics) kept length intact — the store disagreed with its own materialized copies, at any nesting depth, for both snapshot() and deep(). Restore length after the loop, mirroring unwrapStoreValue's identical handling; middle holes and length-write truncation were already correct and stay pinned as controls. Also folds in a prettier reformat of the previous commit's getNode guard. Co-authored-by: yumemi-thomas <yumemi-thomas@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Closes #2846
Summary
delete arr[i]on an array store leaves a hole without changinglength— plain JavaScript semantics, and every proxy-side path agrees (store.length,JSON.stringify(store),[...store],store.map,Array.fromall yield["a","b",null]). But both copy APIs return an array whose length has silently shrunk:snapshot(store)anddeep(store)drop trailing holes entirely, at any nesting depth, and multi-hole runs shrink by the full run. The store disagrees only with its own materialized copies — any consumer that serializes, diffs, orreconcile()s the copy sees silently lost slots.https://stackblitz.com/edit/solidjs-templates-dvf3msqv?file=src%2FApp.tsx
Root cause
The array branch of
snapshotImpl(packages/solid-signals/src/store/utils.ts) skips$DELETEDslots but never restores the result's length afterwards, so a copy ends at the last written index:The sibling path
unwrapStoreValue(used by the set trap) handles the identical situation correctly by restoring the length after its loop (store.ts:result.length = override.length ?? source.length). This is the one divergent site.Fix
One line, mirroring
unwrapStoreValue: after the array loop,if (result) result.length = len;. Deleted slots stay true holes (i in copy === false, serialized asnull), matching plain-JSdeleteand the proxy's own view. By construction it can't overreach: the loop only writes indices< lenso the restoration never truncates written data, theif (result)guard leaves the identity/no-copy path untouched, andlenalready honorsoverride.lengthso length-truncation writes keep working. One change covers every affected variant, sincesnapshot()anddeep()share the implementation and nesting recurses through it.Tests
Four tests added to the existing
snapshotdescribe intests/store/utilities.test.ts, three confirmed failing red-first on the unfixed source:length 3, the hole stays a hole (2 in copy === false), serializes as["a","b",null]deep()with the same shape{ list: ["a","b",<hole>,<hole>] }keepslength 4)length-truncation writes still truncateEdge shapes probed additionally: deleting the only element (
[null], length 1, index still a hole),length-extension with a delete in the extended region, mixed middle+trailing holes. Full solid-signals suite: the diff versus pristine is exactly the intended flips; solid and @solidjs/web rebuilt and re-run at their baselines; test files pass the tests-inclusive typecheck.Solid 1.x note
Regression from 1.x, verified on 1.9.14 with the 1.x equivalents (
setState(2, undefined)to delete;unwrapas the copy): bothstate.lengthandunwrap(state).lengthremain3after deleting the trailing index — a hole, matching plain-JSdeletesemantics. 2.0's copies shrink instead.Full disclosure: I wrote this with the help of an AI assistant (Claude Fable 5) and reviewed every line before pushing. The tests were written first and confirmed failing on the unfixed code, and the affected/unaffected variant boundary was established by probing each shape individually.