Describe the bug
delete arr[i] on an array store leaves a hole without changing length — plain JavaScript semantics, and the store proxy agrees (store.length stays 3, store[2] reads undefined). But snapshot() of that store returns an array whose length has silently shrunk: the trailing hole is dropped entirely. Any consumer that serializes, structured-clones, or diffs the snapshot sees an array that disagrees with the store it came from.
The affected surface is wider than one call (probed each variant individually):
deep() is equally affected — it shares snapshotImpl, so the tracked deep-copy loses the length the same way.
- Any nesting depth —
snapshot(store).list of a store holding the array as a property shrinks identically (the recursion reaches the same branch).
- Multiple trailing holes shrink by the full run (
["a","b",<hole>,<hole>] → length 2).
And the boundary of the bug, for contrast: a middle hole is preserved correctly (["a",null,"c"], length 3), writing any index past the hole restores the length, length extension (arr.length = 5) copies correctly — and every proxy-side path agrees with plain JS (store.length === 3, JSON.stringify(store), [...store], store.map, Array.from all yield ["a","b",null]). The store disagrees only with its own materialized copies.
This is a gap in snapshotImpl only: the sibling code path unwrapStoreValue (used by the set trap) handles the same case correctly by restoring the length after skipping deleted slots. Not covered by the truncation fixes for #2768 (5894f2a / bc92d00), which are about length writes through the set trap, not about snapshot's read path.
Your Example Website or App
https://stackblitz.com/edit/solidjs-templates-dvf3msqv?file=src%2FApp.tsx
import { createSignal, createStore, deep, flush, Show, snapshot } from "solid-js";
type Verdict = { ok: boolean; actual: string };
export default function App() {
const [store, setStore] = createStore(["a", "b", "c"]);
const [verdict, setVerdict] = createSignal<Verdict>();
function deleteLastAndCopy() {
setStore(draft => {
delete draft[2];
});
flush();
const snap = snapshot(store);
const deepCopy = deep(store);
setVerdict({
ok: snap.length === store.length && deepCopy.length === store.length,
actual: [
`store (proxy): length ${store.length} ${JSON.stringify([...store])}`,
`snapshot(): length ${snap.length} ${JSON.stringify(snap)}`,
`deep(): length ${deepCopy.length} ${JSON.stringify(deepCopy)}`
].join("\n")
});
}
return (
<main style={{ "font-family": "system-ui", padding: "16px" }}>
<h2>snapshot() and deep() lose trailing holes</h2>
<p>
items: {JSON.stringify([...store])} (length: {store.length})
</p>
<button onClick={deleteLastAndCopy}>delete last item and copy</button>
<Show when={verdict()}>
{v => (
<section
style={{
padding: "12px",
"margin-top": "12px",
color: "white",
background: v().ok ? "#137333" : "#c5221f"
}}
>
<b>{v().ok ? "PASS - bug is fixed" : "FAIL - bug reproduced"}</b>
<pre>{v().actual}</pre>
</section>
)}
</Show>
</main>
);
}
Steps to Reproduce the Bug or Issue
- Click delete last item and copy. The rendered store shows the deletion behaving like plain JS — the hole appears (rendered as
null) and the length stays 3:
items: ["a","b",null] (length: 3)
- Actual — the verdict banner shows both copy APIs disagreeing with the store they were taken from, while the proxy stays correct:
FAIL - bug reproduced
store (proxy): length 3 ["a","b",null]
snapshot(): length 2 ["a","b"]
deep(): length 2 ["a","b"]
Expected behavior
Both copies preserve the store's length, keeping the trailing hole like plain JS (["a", "b", <hole>].length === 3):
PASS - bug is fixed
store (proxy): length 3 ["a","b",null]
snapshot(): length 3 ["a","b",null]
deep(): length 3 ["a","b",null]
Screenshots or Videos
No response
Platform
- OS: macOS
- Browser: Chrome
- Version: 2.0.0-beta.15 (all variants re-verified at
next @ e2ebc11c)
Additional context
Root cause: snapshotImpl's array branch in packages/solid-signals/src/store/utils.ts:48-58 skips $DELETED slots but never restores the result's length afterwards:
if (isArray) {
const len = override?.length ?? item.length;
for (let i = 0; i < len; i++) {
v = override && i in override ? override[i] : item[i];
if (v === $DELETED) continue; // ← skipped, so result never reaches index i
...
result[i] = unwrapped;
}
}
When the deleted index is trailing, the fresh result array simply ends before it, so result.length comes out shorter than the store's. unwrapStoreValue in store.ts:164-184 handles the identical situation correctly:
if (isArray) result.length = override.length ?? source.length; // store.ts:183
Suggested fix direction: after the array loop in snapshotImpl, apply the same restoration (if (result) result.length = len;), so deleted slots stay holes instead of truncating the copy. One change covers every affected variant above, since snapshot() and deep() share the implementation and nesting recurses through it.
Does this exist in Solid 1.x?
Regression from 1.x. Verified against solid-js 1.9.14 using the 1.x equivalents (setState(2, undefined) deletes the property in 1.x; unwrap is 1.x's snapshot): after deleting the trailing index of [1, 2, 3], both state.length and unwrap(state).length remain 3 (2 in state is false — a hole, matching plain-JS delete semantics). 2.0's snapshot() shrinks the length instead.
Working solid 1.x example: https://playground.solidjs.com/anonymous/32bb125d-9292-4723-bd28-4e992662ee71
Describe the bug
delete arr[i]on an array store leaves a hole without changinglength— plain JavaScript semantics, and the store proxy agrees (store.lengthstays3,store[2]readsundefined). Butsnapshot()of that store returns an array whoselengthhas silently shrunk: the trailing hole is dropped entirely. Any consumer that serializes, structured-clones, or diffs the snapshot sees an array that disagrees with the store it came from.The affected surface is wider than one call (probed each variant individually):
deep()is equally affected — it sharessnapshotImpl, so the tracked deep-copy loses the length the same way.snapshot(store).listof a store holding the array as a property shrinks identically (the recursion reaches the same branch).["a","b",<hole>,<hole>]→ length 2).And the boundary of the bug, for contrast: a middle hole is preserved correctly (
["a",null,"c"], length 3), writing any index past the hole restores the length,lengthextension (arr.length = 5) copies correctly — and every proxy-side path agrees with plain JS (store.length === 3,JSON.stringify(store),[...store],store.map,Array.fromall yield["a","b",null]). The store disagrees only with its own materialized copies.This is a gap in
snapshotImplonly: the sibling code pathunwrapStoreValue(used by the set trap) handles the same case correctly by restoring the length after skipping deleted slots. Not covered by the truncation fixes for #2768 (5894f2a / bc92d00), which are aboutlengthwrites through the set trap, not aboutsnapshot's read path.Your Example Website or App
https://stackblitz.com/edit/solidjs-templates-dvf3msqv?file=src%2FApp.tsx
Steps to Reproduce the Bug or Issue
null) and the length stays3:Expected behavior
Both copies preserve the store's length, keeping the trailing hole like plain JS (
["a", "b", <hole>].length === 3):Screenshots or Videos
No response
Platform
next@e2ebc11c)Additional context
Root cause:
snapshotImpl's array branch inpackages/solid-signals/src/store/utils.ts:48-58skips$DELETEDslots but never restores the result's length afterwards:When the deleted index is trailing, the fresh
resultarray simply ends before it, soresult.lengthcomes out shorter than the store's.unwrapStoreValueinstore.ts:164-184handles the identical situation correctly:Suggested fix direction: after the array loop in
snapshotImpl, apply the same restoration (if (result) result.length = len;), so deleted slots stay holes instead of truncating the copy. One change covers every affected variant above, sincesnapshot()anddeep()share the implementation and nesting recurses through it.Does this exist in Solid 1.x?
Regression from 1.x. Verified against solid-js 1.9.14 using the 1.x equivalents (
setState(2, undefined)deletes the property in 1.x;unwrapis 1.x's snapshot): after deleting the trailing index of[1, 2, 3], bothstate.lengthandunwrap(state).lengthremain3(2 in stateisfalse— a hole, matching plain-JSdeletesemantics). 2.0'ssnapshot()shrinks the length instead.Working solid 1.x example: https://playground.solidjs.com/anonymous/32bb125d-9292-4723-bd28-4e992662ee71