fix(signals): repeat() handles disjoint window jumps (closes #2853) - #2854
fix(signals): repeat() handles disjoint window jumps (closes #2853)#2854yumemi-thomas wants to merge 2 commits into
Conversation
🦋 Changeset detectedLatest commit: ad738b4 The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 packages
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 |
|
Excellent analysis — the "shift + fill is only valid when old ∩ new ≠ ∅" framing pins all three symptoms to one modeling assumption, and the guard being the exact boolean complement of the overlap condition is what makes this safe: partial-overlap slides can't accidentally take the replacement path, and disjoint moves have no shared rows to preserve anyway, so wholesale replacement is the minimum possible work. Also appreciated the red-first tests — we re-verified all four fail on the unfixed source before landing. Checked the interaction with the fallback-dispose line above the guard (fallback is removed before the wholesale path runs, so no double-dispose) and the adjacent-window boundary ( |
updateRepeat modeled every window move as clear-end + shift-front + fill, valid only when the old and new windows overlap. Disjoint moves walked _nodes at negative local indices: forward jumps larger than the window created and leaked every gap row (owners/effects alive, invisible to dispose bookkeeping), first render with nonzero `from` mapped the whole prefix, and backward disjoint jumps threw on _nodes[negative].dispose() and froze the list. Detect disjointness up front (from >= prevTo || to <= offset — the exact complement of overlap) and replace the window wholesale; overlapping slides (the #2784 fix) are untouched. Co-authored-by: yumemi-thomas <yumemi-thomas@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Closes #2853
Summary
repeat()/<Repeat>with a reactivefromonly handled window moves that overlap the previous window. Any disjoint jump — the everyday shape of a virtualized list, where dragging the scrollbar or jumping to an index movesfromby more thancount— broke three ways:frommaps the whole prefix —from: 10, count: 3creates rows0..12(13 rows for a 3-row window).0 → 10maps3..12and stores the gap rows (3..9) at negative_nodesindices, so nothing ever disposes them; their owners, effects andonCleanups stay live until the whole list is torn down. Each disjoint jump adds more (0 → 10 → 20⇒ 17 live scopes for a 3-row window).20 → 0throwsCannot read properties of undefined (reading 'dispose'), the update aborts, and the list freezes on the old rows.The rendered list always looks right (leaked rows never enter the DOM), so the leak is invisible without watching live scope counts. Repro: https://stackblitz.com/edit/solidjs-templates-de3jtq5s?file=src%2FApp.tsx
Distinct from #2767 (fixed by #2784 /
d8921ac1): that covered the empty-window reset and overlapping backward slides, which still work. The disjoint geometries were never handled.Root cause
updateRepeat(packages/solid-signals/src/map.ts) models every window move as "clear the end, shift the front, fill the difference" — valid only whenold ∩ new ≠ ∅. The fill loopfor (i = prevTo; i < to; i++) this._nodes[i - from] = …writes negative local indices for everyi < from(symptoms 1–2), and the end-clear loopthis._nodes[i - this._offset].dispose()readsthis._nodes[negative](undefined) on a backward disjoint jump (symptom 3).Fix
Detect the disjoint case up front and replace the window wholesale:
That condition is the exact boolean complement of "the windows overlap" (
from < prevTo && to > offset), so:[5,8) → [3,6)creates only rows3,4and preserves row5;prevTois 0 on first render).The existing overlapping-slide branches (the #2784 fix) are untouched, and no loop-clamping is added — the guard makes the negative-index accesses unreachable rather than merely clamped.
Tests
tests/repeat.test.tsgains a "disjoint window jumps" block, all confirmed red-first on the unfixed source:live === 3, not 10)live === 3, not 17)frommaps only the window (3 created, not 13)Solid 1.x note
Not applicable —
repeat()/<Repeat>is a new 2.0 API with no 1.x counterpart (the closest 1.x pattern,<Index>over a sliced range, has no windowing bookkeeping and no equivalent failure mode).Full disclosure: I wrote this with the help of an AI assistant (Claude Fable 5) and reviewed every line before pushing. All new tests were written first and confirmed failing on the unfixed code