Skip to content

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

Merged
vivek7405 merged 3 commits into
mainfrom
fix/map-array-commit-throw
Aug 5, 2026
Merged

fix: a mid-commit throw strands a row in a plain .map() array#1284
vivek7405 merged 3 commits into
mainfrom
fix/map-array-commit-throw

Conversation

@vivek7405

Copy link
Copy Markdown
Collaborator

Closes #1250

Targets main. #1274 (closing #1268) is already merged, which is what this depends on: the repair assumes removeArrayItem does not throw, and that PR's disposeInstance guard is what delivers it.

Replaces #1277, which GitHub auto-closed when the branch was force-pushed to drop the now-squashed #1274 commits. Same branch, same work, no content difference; the review trail lives on #1277.

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 2842c96f and dd8b1473.
  • 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.

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

Copy link
Copy Markdown
Collaborator Author

Review trail, carried over from #1277

This branch was reviewed on #1277 before GitHub closed that PR on a force-push. The content is identical, only the commit ids moved: e6096d57 there is 2842c96f here, and f9b7b7cf is dd8b1473. Reproducing it here so it is not stranded on a closed PR.

The repair itself was fuzzed before the prose was read: 4000 trials over random old and new lists across all four slot kinds with a random poison index, no recovery mismatches, and nothing surviving a post-throw empty render. Three problems around it.

1. render-client.js, the empty-slot branch: the push-before-remove reorder does not belong here. The reorder earns its place one branch down, where a slot has already been built and inserted and must not be left untracked. This branch builds and inserts nothing, so the reason is vacuous, and it is not neutral either: pushing first leaves a phantom empty slot at that index AND the old slot spliced in at the next one, shifting every later slot in a POSITIONAL reconciler. A regression against base, taken for symmetry.

Fixed in dd8b1473. Removes first again, with a comment saying why this branch is deliberately not symmetric with the one below. Confirmed 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.

2. render-client.js, the residual note: "orphaning nothing" is false. removeBetween takes the start marker first and early-returns for good once it is gone, so a row whose removal refused can never be removed afterwards and survives an empty render, which is exactly what the array: an EMPTY render after a throw leaves nothing behind case exists to catch. reconcileRepeat's catch admits this about its own equivalent; this one denied it.

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

3. Four surfaces narrow the trigger to a template SHAPE change. The same destructive branch is reached by an array that GREW past its old length (no old slot at that index at all) and by a slot whose KIND changed between text, template and empty. Growth reproduces the identical bug on base, so this was a coverage hole rather than only a wording one.

Fixed in dd8b1473. The comment names all three routes into the branch, both doc surfaces are widened, 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 the wording.

What is not covered: the reviewer read the diff BEFORE those three fixes. dd8b1473 itself has not been read by a fresh reviewer. I verified it directly (the full rendering suite, a counterfactual reverting the catch that reds all four array cases, and a reproduction of the tail shift), but that is my own verification rather than an independent one.

@vivek7405
vivek7405 merged commit df2b698 into main Aug 5, 2026
10 checks passed
@vivek7405
vivek7405 deleted the fix/map-array-commit-throw branch August 5, 2026 13:18
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.

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

1 participant