Skip to content

fix(store): snapshot() and deep() preserve array length when trailing indices were deleted (closes #2846) - #2848

Closed
yumemi-thomas wants to merge 2 commits into
solidjs:nextfrom
yumemi-thomas:fix/snapshot-trailing-holes
Closed

yumemi-thomas wants to merge 2 commits into
solidjs:nextfrom
yumemi-thomas:fix/snapshot-trailing-holes

Conversation

@yumemi-thomas

Copy link
Copy Markdown

Closes #2846

Summary

delete arr[i] on an array store leaves a hole without changing length — plain JavaScript semantics, and every proxy-side path agrees (store.length, JSON.stringify(store), [...store], store.map, Array.from all yield ["a","b",null]). But both copy APIs return an array whose length has silently shrunk: snapshot(store) and deep(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, or reconcile()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 $DELETED slots but never restores the result's length afterwards, so a copy ends at the last written index:

for (let i = 0; i < len; i++) {
  v = override && i in override ? override[i] : item[i];
  if (v === $DELETED) continue;   // ← skipped; result never reaches index i
  ...
  result[i] = unwrapped;
}

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 as null), matching plain-JS delete and the proxy's own view. By construction it can't overreach: the loop only writes indices < len so the restoration never truncates written data, the if (result) guard leaves the identity/no-copy path untouched, and len already honors override.length so length-truncation writes keep working. One change covers every affected variant, since snapshot() and deep() share the implementation and nesting recurses through it.

Tests

Four tests added to the existing snapshot describe in tests/store/utilities.test.ts, three confirmed failing red-first on the unfixed source:

  • trailing hole: copy keeps length 3, the hole stays a hole (2 in copy === false), serializes as ["a","b",null]
  • deep() with the same shape
  • nested array + multi-hole run ({ list: ["a","b",<hole>,<hole>] } keeps length 4)
  • controls (passing before and after): middle holes preserved, length-truncation writes still truncate

Edge 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; unwrap as the copy): both state.length and unwrap(state).length remain 3 after deleting the trailing index — a hole, matching plain-JS delete semantics. 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.

@changeset-bot

changeset-bot Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fdc9969

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@solidjs/signals Patch
test-integration Patch
solid-js Patch
babel-preset-solid Patch
@solidjs/web Patch
@solidjs/html Patch
@solidjs/h Patch
@solidjs/universal Patch
@solidjs/element Patch

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

@codspeed

codspeed Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 120 untouched benchmarks


Comparing yumemi-thomas:fix/snapshot-trailing-holes (fdc9969) with next (cdbe95d)

Open in CodSpeed

@ryansolid

Copy link
Copy Markdown
Member

Correct on all counts — unwrapStoreValue already restored the length in the identical situation, so snapshotImpl skipping $DELETED without the restoration was a plain omission, and the one-line fix covers every variant (both APIs share the impl, nesting recurses through it). The 1.x regression check was appreciated context.

Your branch predates the #2850/#2756 snapshotImpl rework (merged overlay + the specialized no-overlay walk), so the diff didn't apply — the same one-liner is now placed in the reworked array branch, with your four tests taken verbatim (verified red-first: 3 fail unfixed, the controls pass). Landed as faf78ebd with co-author credit. Ships in the next beta — thanks!

@ryansolid ryansolid closed this Jul 8, 2026
ryansolid added a commit that referenced this pull request Jul 8, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants