Skip to content

2.0.0-beta.15: snapshot() and deep() shrink the array length when a trailing index was deleted #2846

Description

@yumemi-thomas

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 depthsnapshot(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

  1. 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)
  1. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions