Skip to content

fix: a mid-commit throw strands a row in a plain .map() array - #1277

Open
vivek7405 wants to merge 2 commits into
fix/repeat-leftover-removal-throwfrom
fix/map-array-commit-throw
Open

fix: a mid-commit throw strands a row in a plain .map() array#1277
vivek7405 wants to merge 2 commits into
fix/repeat-leftover-removal-throwfrom
fix/map-array-commit-throw

Conversation

@vivek7405

Copy link
Copy Markdown
Collaborator

Closes #1250

Stacked on #1274 (which closes #1268). Base is fix/repeat-leftover-removal-throw, so review the top commit; GitHub retargets this to main once #1274 merges. The dependency is real: this repair assumes removeArrayItem does not throw, which is what #1274's disposeInstance guard delivers.

A plain .map() array permanently stranded a row when an item's template SHAPE changed in the same render that threw. reconcileArray accumulated its replacement slot list locally and assigned state.items only after the whole walk, so a throw part-way discarded that list entirely: the tracked slots kept describing positions whose nodes were already removed, while the freshly built ones sat in the document tracked by nothing. The orphan then outlived every later render including an EMPTY one, because the only code that could remove it walks state.items. Nothing was logged after the first throw.

This is the same silent-corruption class #1172 fixed for repeat(), in the one child-position reconciler it left alone. That premise (reconcileArray "leaves the DOM untouched on a throw") holds for the COMMON same-shape path, which touches only values. It does not hold for the shape-changed branch, which inserts the replacement and removes the old slot before the loop can finish.

What changed

The walk, the shrink loop and the deferred commit are wrapped in a catch that splices the untouched tail of old onto what next accumulated, then rethrows. The array analogue of reconcileRepeat's catch: bookkeeping only, no attempt to unwind the partial DOM work, no teardown-and-rebuild. The invariant it guarantees, stated in the comment rather than an absolute: at any throw point every live node is described by exactly one slot, those below next.length being the rebuilt or reused ones and the rest the part of old the pass never reached. Index alignment survives because this reconciler is POSITIONAL.

The splice boundary is a consumed cursor, not next.length. The shrink loop advances through old while next stops growing, so the two part company there. Splicing from next.length would re-describe slots the shrink loop already removed, and the damage surfaces only later: a render that GREW the array would match a live value against a DETACHED slot with the same strings, update it in place, and that row would silently never appear. There is a test for exactly that, and it is the one the cursor exists for.

Both destructive branches push before they remove. A pure reordering on the success path, and it means a slot that has been built and inserted is never untracked at any throw point. This is not a substitute for the catch, which the filing left open: the reorder makes the failed POSITION atomic, while the corruption is that the whole list is committed late, so a throw at index 5 still discards indices 0 to 4. Both are needed.

Deliberately excluded

A teardown-and-rebuild of the region (measured on #1172 as keeping 0 of 3 node identities; a rebuild cancels an in-progress native drag and drops focus and scroll). Committing state.items incrementally, which would leave later reads of old[i] reading a partially rewritten array while nextArrayAnchor scans old forward. The COMMIT_FAILED sentinel is complementary, not a substitute: it repairs the failed HOLE inside one instance, this repairs state.items.

Test plan

  • Unit (packages/core/test/rendering/directive-commit-throw.test.js): three new cases. A mid-walk throw followed by a valid render produces the exact list with no stranded row, and a second valid render is identical (so the recovery is not merely delayed); an EMPTY render after the throw leaves nothing behind, which is the sharpest probe since a slot tracked by nothing survives it; a throw in the SHRINK loop leaves no slot describing a detached row, proven by a later LONGER render rendering every row. 19/19 in the file, 396/396 across packages/core/test/rendering.
  • Counterfactual, the two pieces reverted separately. Reverting only the catch reds all three new cases. Replacing the consumed cursor with next.length (leaving the catch) reds ONLY the shrink-loop case, which is what proves that test earns its place. Proven at 65287383.
  • Browser (packages/core/test/rendering/browser/directive-commit-throw.test.js): one new case for the identity fact linkedom cannot prove, that the row which did NOT change shape survives the recovery as the same element. Full run green on Chromium, Firefox and WebKit.
  • Bun parity: N/A. render-client.js is the client renderer, not a runtime-sensitive surface, and the parity hook does not match this path.
  • Dogfood: N/A. Client-renderer only, no change to SSR output, the importmap, or what the browser fetches.

Doc surfaces

  • Updated .agents/skills/webjs/references/components.md: the parenthetical carve-out naming the plain array as still exempt is gone, and the paragraph now covers both list reconcilers.
  • Updated website/app/docs/error-handling/page.ts: that paragraph carried no carve-out at all, so it was overstating in exactly the way fix: make repeat/guard/watch/until commits atomic against a throw #1232 was corrected for. It now names the plain array rather than implying it by omission.
  • N/A for the MCP, editor plugins, scaffold templates, marketing copy and READMEs: no public API, CLI flag, config key, template, grammar or positioning claim changed.

@vivek7405 vivek7405 self-assigned this Aug 5, 2026
@vivek7405
vivek7405 force-pushed the fix/map-array-commit-throw branch 2 times, most recently from 8ab5fdf to e6096d5 Compare August 5, 2026 10:43

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fuzzed the repair itself before reading the prose (random old and new lists across all four slot kinds, random poison index): no recovery mismatches, and nothing survives a post-throw empty render. The splice and the consumed cursor are right. Three problems around it.

The one that matters is the push-before-remove reorder in the EMPTY-slot branch. The reorder earns its place in the branch below, where a slot has already been built and inserted and must not be left untracked. This branch builds and inserts nothing, so the reason does not apply, and it is not neutral: pushing first leaves a phantom empty slot at that index AND the old slot spliced in at the next one, which shifts every later slot in a POSITIONAL reconciler. That is a regression against base, taken for symmetry.

Second, the residual note says a removal throw orphans nothing. It does. removeBetween takes the start marker first and early-returns for good once that marker is gone, so that row can never be removed and an empty render will not clear it. reconcileRepeat's catch admits exactly this about its own equivalent; this one denies it.

Third, four places narrow the trigger to a template SHAPE change. The same branch takes an array that GREW past its old length and a slot whose KIND changed. Growth reproduces the identical bug on base, so that was a coverage hole, not only a wording one.

// Shape changed, or the array grew past the old length. Build fresh,
// insert at this position (before the current / next still-attached
// old node, else the marker), then drop the old slot it replaced.
// The push sits BEFORE the removal, which is a pure reordering on the

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing before the removal is the right call one branch down, where something was already inserted. Nothing is inserted here, so on a removal throw this leaves a phantom empty slot at i and old[i] at i+1, shifting the whole tail. Removing first is correct for this branch.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f9b7b7cf. Removes first again, with a comment saying why this branch is deliberately not symmetric with the one below. Confirmed the ordering on the repro: [null,'2','3'] over ['1','2','3'] with a refusing removal now recovers to X, 2, 3 rather than 2, X, 3.

Comment thread packages/core/src/render-client.js Outdated
// Two residuals, both from the removal step itself rather than the
// bookkeeping. A throw out of `removeArrayItem` leaves the slot it was
// removing described one position later than it sits, costing that row
// its identity on the next render but orphaning nothing (and it takes a

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"orphaning nothing" is not true. removeBetween takes the start marker first and early-returns once it is gone, so a row whose removal refused can never be removed and survives an empty render.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f9b7b7cf. The note now states the residual instead of denying it, including that an empty render will not clear those nodes, and points at reconcileRepeat's catch as the same admission.

return container.querySelector('div').innerHTML.replace(/<!--[^>]*-->/g, '');
}

// The shape-changed branch is the destructive one: it inserts the replacement

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A same-shape update cannot reach the branch, but a GROWN array can (no old slot at that index at all), and so can a slot changing KIND. Both reproduce the same bug on base, so scoping every case to a shape change leaves the rest uncovered.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f9b7b7cf. The comment names all three routes into that branch, both doc surfaces are widened the same way, and there is a growth-path test with no shape change anywhere in it. It reds when the catch is reverted, so it covers the bug rather than just the wording.

@vivek7405
vivek7405 force-pushed the fix/map-array-commit-throw branch from f9b7b7c to 4c47403 Compare August 5, 2026 11:03
@vivek7405
vivek7405 marked this pull request as ready for review August 5, 2026 11:54
@vivek7405
vivek7405 force-pushed the fix/repeat-leftover-removal-throw branch from d1ed783 to 22a06d3 Compare August 5, 2026 12:07
reconcileArray accumulated its replacement slot list locally and committed it
only after the whole walk, so a throw part-way discarded the list entirely.
The tracked slots kept describing positions whose nodes were already removed,
while the freshly built ones sat in the document tracked by nothing. The
orphan then outlived every later render including an empty one, because the
only code that could remove it walks the tracked list.

Only the shape-changed branch is destructive (it inserts the replacement and
removes the old slot before the loop can finish), which is why #1172 read the
common same-shape path as leaving the DOM untouched.

The repair splices the untouched tail of the old list onto what the pass
accumulated, the array analogue of reconcileRepeat's catch, and rethrows. The
boundary comes from a processed-slot cursor rather than the new list's length
because the shrink loop advances through the old slots while the new list
stops growing; splicing from the length would re-describe an already-removed
slot, and a later render that grew the array would match a live value against
a detached slot and that row would silently never appear.

The two destructive branches also push before they remove, a pure reordering
on the success path that keeps a built and inserted slot tracked at every
throw point. It is not a substitute for the catch: it makes the failed
POSITION atomic, while the corruption is that the whole list was committed
late.
…p narrowing

Three corrections to the array repair, none of which change the repair itself.

The push-before-remove reorder was applied to the empty-slot branch for
symmetry, and it is wrong there. The reorder exists to keep a slot that was
already built and inserted tracked at a throw point, and the empty branch
builds and inserts nothing, so pushing first only means a removal throw leaves
a phantom empty slot at that index plus the old slot spliced in at the next
one, shifting every later slot in a positional reconciler. Measured: rendering
[null,'2','3'] over ['1','2','3'] with a refusing removal recovered to
2, X, 3 instead of X, 2, 3. It now removes first, like it used to.

The residual note claimed a removal throw orphans nothing. It does:
removeBetween takes the start marker first and early-returns for good once
that marker is gone, so that row can never be removed afterwards and an empty
render will not clear it. The comment now says so, matching what
reconcileRepeat's catch already admits about its own equivalent.

The tests and both doc surfaces described the trigger as a template SHAPE
change. The destructive branch also takes an array that GREW past its old
length (no old slot to compare against) and a slot whose KIND changed between
text, template and empty, neither of which is a shape change. Growth
reproduces the identical bug on the base branch, so that was a real coverage
hole rather than only a wording one, and it now has a test.
@vivek7405
vivek7405 force-pushed the fix/map-array-commit-throw branch from 4c47403 to 04ef620 Compare August 5, 2026 12:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant