Skip to content

2.0.0-beta.15: reconcile() leaves stale index and has nodes on array resize #2823

Description

@yumemi-thomas

Describe the bug

When reconcile() merges a shorter array into an array store, the per-index nodes for the removed indices are never signaled:

  1. An effect (or rendered binding) tracking a removed index (state[i] with i >= next.length) never re-runs — it stays frozen on the removed row forever, while state.length already reports the shorter length.
  2. Because the stale node still exists, even untracked reads of state[i] keep returning the removed item — the store proxy contradicts its own length.
  3. has nodes are equally stale, in both directions: a tracked 2 in list stays true after the shrink removes index 2 — and a tracked 5 in list stays false forever after reconcile() grows the array past index 5 (the merge loops never touch STORE_HAS at all).
  4. This covers keyed trailing removal, non-keyed arrays, and reconcile([]) alike.

This is a 2.0 regression for the shrink case: Solid 1.x reconcile notifies removed indices (verified on 1.9.14 — the tracking effect re-runs with undefined, and untracked reads agree with length, in all three shapes above).

Per-symptom 1.x classification (all verified on 1.9.14): the stale value reads (symptoms 1–2) and the growth-direction has staleness are 2.0 regressions — 1.x notifies both. The shrink-direction has staleness is the one part 1.x shares: a tracked 2 in list is not notified there either, because its setProperty length-truncation branch predates in tracking and notifies value nodes only (delete and index adds notify $HAS correctly). 1.x's version is much milder, though — with no stale node to serve reads, a fresh 2 in list still returns false there, whereas 2.0 serves the stale true to every read. Runnable 1.x demonstration (tracked list[2] heals, tracked 2 in list freezes, fresh read contradicts it): https://playground.solidjs.com/anonymous/03e39514-2569-4143-86d5-b471ada54a09

Related to but distinct from two fixed issues:

Your Example Website or App

https://stackblitz.com/edit/solidjs-templates-3dnfxm8y?file=src%2FApp.tsx

A master list + detail panel over the same store. The list view heals itself after the shrink (it tracks length, which #2773's fix notifies) — the index-pinned detail panel freezes on the deleted row, and one probe line shows the proxy contradicting itself:

import { createStore, reconcile, Show } from 'solid-js';

export default function App() {
  const [list, setList] = createStore<{ id: number; name: string }[]>([
    { id: 1, name: 'Ada' },
    { id: 2, name: 'Grace' },
    { id: 3, name: 'Alan' },
  ]);

  return (
    <div>
      <h4>The list ({list.length} rows)</h4>
      <ul>{list.map(row => <li>{row.name}</li>)}</ul>

      <h4>Detail of row #3</h4>
      <p>
        <Show when={list[2]} fallback={'— no row —'}>
          {`${list[2].name} (#${list[2].id})`}
        </Show>
      </p>

      {/* the proxy contradicting itself in one line */}
      <p>
        2 in list: {String(2 in list)} · Object.keys: [
        {Object.keys(list).join(',')}]
      </p>

      <button onClick={() => setList(reconcile([{ id: 1, name: 'Ada' }], 'id'))}>
        refetch → server now returns only Ada
      </button>
    </div>
  );
}

Steps to Reproduce the Bug or Issue

  1. Open the repro. Initial render: list shows Ada, Grace, Alan (3 rows), detail shows Alan (#3), probe line shows 2 in list: true · Object.keys: [0,1,2].
  2. Click refetch → server now returns only Ada.
  3. Observe:
The list (1 rows)
  • Ada

Detail of row #3
  Alan (#3)                              ← removed row, rendered forever

2 in list: true · Object.keys: [0]      ← index 2 both exists and doesn't exist

The list correctly collapses to one row, but the <Show when={list[2]}> detail panel keeps rendering Alan (#3) — a row that no longer exists in the store — and never updates again. The probe line pairs the two sharpest contradictions: the tracked 2 in list binding still says true while Object.keys (whose $TRACK notification #2773 fixed) correctly says the only key is 0.

Expected behavior

Removed indices should be signaled — value nodes with undefined, has nodes with false — exactly like the set-trap truncation path (#2768) and Solid 1.x setProperty (for (let i = state.length; i < len; i++) nodes[i].$()) already do:

The list (1 rows)
  • Ada

Detail of row #3
  — no row —

2 in list: false · Object.keys: [0]    ← now agrees with Object.keys: plain JS `2 in [x]` is false

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: Chrome
  • Version: 2.0.0-beta.15 (verified at next @ bad66625)

Additional context

The governing rule: whichever per-key node exists keeps serving its stale cache to all reads — tracked and untracked alike — while keys with no node consult the already-swapped value. In the repro both list[2] and 2 in list are tracked bindings, so both go stale (Alan (#3), true). Two consequences worth spelling out:

  1. The staleness poisons untracked reads too. After the click, a plain console.log(list[2]) anywhere in the app prints {"id":3,"name":"Alan"} and 2 in list evaluates true — because the leftover nodes serve those reads — while list.length is 1 and Object.keys(list) is ["0"].
  2. Whether any given read is correct depends on whether that exact key was ever tracked before the shrink. In a variant of the repro where 2 in list is never read until after the click, it evaluates false (no has-node exists, so the trap consults the new value). Same expression, opposite result, decided by tracking history — the least debuggable kind of inconsistency.

Root cause: packages/solid-signals/src/store/reconcile.ts — every array merge loop in applyStateFast / applyStateSlow iterates only up to next.length. Nodes for indices in [next.length, prevLength) are never touched; the shrink only sets the length node and calls notifySelf:

// applyStateFast, reconcile.ts:163-179 (applyStateSlow mirrors it at 312-330)
} else if (next.length) {
  for (let i = 0, len = next.length; i < len; i++) {
    ...
    arrayNodes?.[i] && setSignal(arrayNodes[i], wrapValue(next[i], target));
  }
}

if (prevLength !== next.length) {
  changed = true;
  arrayNodes?.length && setSignal(arrayNodes.length, next.length);
}
changed && notifySelf(target);

The keyed fast-exit branch (reconcile.ts:117-133) returns early with the same gap. Since target[STORE_VALUE] was already swapped to the shorter next, the stale node's cached value is all that keeps the removed item alive — which is also why untracked reads (served from the node when one exists) disagree with length.

The same root cause also breaks the growth direction for has nodes: the merge loops never touch STORE_HAS at all, so a tracked 5 in list stays false forever after reconcile() grows the array past index 5 (while list[5] reads correctly). Both directions are the same defect — reconcile's array path doesn't maintain per-key nodes against the new value.

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