Describe the bug
When reconcile() merges a shorter array into an array store, the per-index nodes for the removed indices are never signaled:
- 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.
- Because the stale node still exists, even untracked reads of
state[i] keep returning the removed item — the store proxy contradicts its own length.
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).
- 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
- 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].
- Click
refetch → server now returns only Ada.
- 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:
- 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"].
- 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.
Describe the bug
When
reconcile()merges a shorter array into an array store, the per-index nodes for the removed indices are never signaled:state[i]withi >= next.length) never re-runs — it stays frozen on the removed row forever, whilestate.lengthalready reports the shorter length.state[i]keep returning the removed item — the store proxy contradicts its ownlength.hasnodes are equally stale, in both directions: a tracked2 in liststaystrueafter the shrink removes index 2 — and a tracked5 in liststaysfalseforever afterreconcile()grows the array past index 5 (the merge loops never touchSTORE_HASat all).reconcile([])alike.This is a 2.0 regression for the shrink case: Solid 1.x
reconcilenotifies removed indices (verified on 1.9.14 — the tracking effect re-runs withundefined, and untracked reads agree withlength, 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
hasstaleness are 2.0 regressions — 1.x notifies both. The shrink-directionhasstaleness is the one part 1.x shares: a tracked2 in listis not notified there either, because itssetPropertylength-truncation branch predatesintracking and notifies value nodes only (deleteand index adds notify$HAScorrectly). 1.x's version is much milder, though — with no stale node to serve reads, a fresh2 in liststill returnsfalsethere, whereas 2.0 serves the staletrueto every read. Runnable 1.x demonstration (trackedlist[2]heals, tracked2 in listfreezes, fresh read contradicts it): https://playground.solidjs.com/anonymous/03e39514-2569-4143-86d5-b471ada54a09Related to but distinct from two fixed issues:
reconciledoes not notify key subscribers on pure trailing removal #2773 (fixed in 54b2175) coveredreconcile's$TRACK/ownKeyssubscribers on trailing removal — so<For>and key enumeration update — but not per-index nodes.store.length = n/ plain setter writes), which now marks each dropped index$DELETEDand notifies it.reconcile()never goes through the set trap — its array merge writes index nodes directly — so that fix does not reach this path.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:Steps to Reproduce the Bug or Issue
Ada, Grace, Alan(3 rows), detail showsAlan (#3), probe line shows2 in list: true · Object.keys: [0,1,2].refetch → server now returns only Ada.The list correctly collapses to one row, but the
<Show when={list[2]}>detail panel keeps renderingAlan (#3)— a row that no longer exists in the store — and never updates again. The probe line pairs the two sharpest contradictions: the tracked2 in listbinding still saystruewhileObject.keys(whose$TRACKnotification #2773 fixed) correctly says the only key is0.Expected behavior
Removed indices should be signaled — value nodes with
undefined,hasnodes withfalse— exactly like the set-trap truncation path (#2768) and Solid 1.xsetProperty(for (let i = state.length; i < len; i++) nodes[i].$()) already do:Screenshots or Videos
No response
Platform
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]and2 in listare tracked bindings, so both go stale (Alan (#3),true). Two consequences worth spelling out:console.log(list[2])anywhere in the app prints{"id":3,"name":"Alan"}and2 in listevaluatestrue— because the leftover nodes serve those reads — whilelist.lengthis1andObject.keys(list)is["0"].2 in listis never read until after the click, it evaluatesfalse(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 inapplyStateFast/applyStateSlowiterates only up tonext.length. Nodes for indices in[next.length, prevLength)are never touched; the shrink only sets thelengthnode and callsnotifySelf:The keyed fast-exit branch (reconcile.ts:117-133) returns early with the same gap. Since
target[STORE_VALUE]was already swapped to the shorternext, 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 withlength.The same root cause also breaks the growth direction for
hasnodes: the merge loops never touchSTORE_HASat all, so a tracked5 in liststaysfalseforever afterreconcile()grows the array past index 5 (whilelist[5]reads correctly). Both directions are the same defect — reconcile's array path doesn't maintain per-key nodes against the new value.