You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Several client-renderer commit paths record their bookkeeping BEFORE the DOM work that can throw, so a throw part-way through a commit leaves the directive's state desynchronised from the DOM. The renderer recovers (the component error boundary catches, logs once, and the page survives), but the corrupted state persists and later VALID renders are silently wrong. Two of the three never self-heal.
This is pre-existing, not introduced by #1167. Every case below was reproduced with a throw that has nothing to do with the form-action guard: a value whose toString throws, which is what any attribute commit does to its value. #1167 matters only in that it adds a second, much more reachable way to throw during a commit, so it raises the odds of hitting this from "an exotic value" to "a Next-shaped authoring mistake".
1. repeat() permanently duplicates a row (worst of the three)
reconcileRepeat moves DOM and deletes from state.map incrementally while accumulating a local newMap, and only commits state.map = newMap at the end (packages/core/src/render-client.js:1303). A throw mid-walk discards newMap with the already-processed keys in it, so those keys are lost from state.map while their nodes remain in the DOM, tracked by nothing.
Measured, three-row keyed list, throw while reconciling row 2:
1. initial <ul><li><span>one</span></li><li><span>two</span></li><li><span>three</span></li></ul>
2. throws unrelated commit failure
after throw <ul><li></li><li><span>three</span></li><li><span>one</span></li></ul>
3. valid render <ul><li><span>one</span></li><li><span>one</span></li><li><span>two</span></li><li><span>three</span></li></ul>
4. again <ul><li><span>one</span></li><li><span>one</span></li><li><span>two</span></li><li><span>three</span></li></ul>
Step 3 is a fully valid render. Row "one" is duplicated because key 1 is no longer in the map, so a fresh instance is built while the orphaned original is never removed. Step 4 is byte-identical, so it never recovers, and nothing is logged after the first throw. In production this is a duplicated list row with no error to point at it.
2. guard() leaves a permanently blank region
applyChildInnerRaw assigns part.__guardDeps (render-client.js:984) BEFORE committing the new template (:987), and the nested-template commit tears the previous child down first. A throw therefore leaves the region empty with the NEW deps already recorded, so every later render with the same deps hits the if (equal) return short-circuit and skips it forever.
1. initial <div><p>view</p><b>0</b></div>
2. throws unrelated commit failure
after throw <div><b>0</b></div>
3. re-render <div><b>1</b></div> <- the guarded region is gone for good
3. watch() and until() commit outside the component error boundary
applyWatch's Watcher notify schedules applyChildInner in a bare queueMicrotask (render-client.js:1843) with no try/catch, and that path runs outside component.js's update cycle, so nothing routes a throw to _handleRenderError. It surfaces as a window-level error / uncaughtException instead of a per-component renderError, and the watched region is left empty because the prior content was torn down before the throw. It does self-heal on the next signal value, so this is the mildest of the three. until()'s promise handler has the same gap and additionally advances state.highestResolved before committing.
Design / approach
One root cause, three symptoms: state is committed before the operation that can fail. The fix is to make each commit atomic with respect to a throw, which generally means one of:
Commit bookkeeping after the DOM work succeeds. Smallest change for guard() (move the __guardDeps assignment after applyChildInner returns) and structurally right for until().
Roll back on throw. For reconcileRepeat, wrap the walk and on a throw either restore the pre-walk state.map or mark the state as poisoned so the next render rebuilds the list from scratch rather than reusing a map that no longer describes the DOM. Rebuilding is likely simpler and safer than trying to unwind partial DOM moves.
Route the throw to the component boundary for watch() / until(), so their commits are contained like every other render path.
Note the interaction with the async-commit containment fix that shipped in #1167 (_commitAsync now catches a commit throw and routes it to _handleRenderError). That made the error VISIBLE and contained at the component level; it does not make the directive state consistent, which is what this issue is about. A component that renders a corrupted repeat() list now reports the error properly and still shows a duplicated row.
Decide whether a poisoned directive state should force a full re-render of its region. That is probably the cheapest correct answer across all three, and it fits the framework's existing preference for degrading to a clean rebuild rather than a guessed recovery (the client router does exactly this when a boundary scan is poisoned).
Implementation notes (for the implementing agent)
Where to edit (all in packages/core/src/render-client.js):
reconcileRepeat, the incremental moveRange + state.map.delete(key) walk, with the deferred commit at L1303 (state.map = newMap).
applyChildInnerRaw, guard branch: prevDeps read at L972, __guardDeps written at L984, commit at L987. Related cleanup lives in clearStaleDirectiveState (L1551-1552) and the comment at L901.
applyWatch, the notify path at L1843 (queueMicrotask).
until() state handling around L1706-1731 (highestResolved, carriedHighest).
Landmines:
reconcileArray (the plain, non-keyed path) is NOT affected: it leaves the DOM untouched on a throw. Do not "fix" it to match, and do use it as the reference for what a safe path looks like.
The three symptoms have different severities and different self-healing behaviour. Do not assume one fix covers all three; verify each independently.
_commitAsync in packages/core/src/component.js now catches commit throws (fix: refuse a function in form action=, it leaked server action source #1167). That changes what an author OBSERVES but not the corrupted state, so a test asserting "the error was reported" is not evidence the state is consistent. Assert the DOM after a subsequent VALID render, which is where the corruption shows.
The corruption is silent after the first throw: steps 3 and 4 above log nothing. A test that only checks for a logged error will pass on a corrupted list.
Invariants to respect:
Per-component error isolation: one broken component must not take down the page. Any rollback must not turn a contained error into an uncontained one.
The framework degrades to a clean rebuild rather than a guessed recovery. A poisoned state should rebuild, never partially patch.
Tests + docs surfaces:
Unit (linkedom), packages/core/test/rendering/ and the directive tests: for each of the three paths, throw mid-commit, then assert the DOM after a subsequent fully valid render. The counterfactual is the second render, not the throwing one.
Browser: repeat() keyed reconciliation is DOM-identity-sensitive, so the duplication case needs a real-browser assertion, not only linkedom.
Bun parity: only if the fix touches a runtime-sensitive surface; the client renderer normally is not one.
Docs: if directive behaviour on error becomes documented, .agents/skills/webjs/references/components.md (the error-isolation paragraph) and the troubleshooting page.
Acceptance criteria
After a mid-commit throw inside repeat(), a subsequent valid render produces the correct list with no duplicated or orphaned rows
After a mid-commit throw inside guard(), a later render with the same deps re-renders the region instead of skipping it forever
A throw from a watch() or until() commit reaches the component's renderError() boundary rather than the window
Problem
Several client-renderer commit paths record their bookkeeping BEFORE the DOM work that can throw, so a throw part-way through a commit leaves the directive's state desynchronised from the DOM. The renderer recovers (the component error boundary catches, logs once, and the page survives), but the corrupted state persists and later VALID renders are silently wrong. Two of the three never self-heal.
This is pre-existing, not introduced by #1167. Every case below was reproduced with a throw that has nothing to do with the form-action guard: a value whose
toStringthrows, which is what any attribute commit does to its value. #1167 matters only in that it adds a second, much more reachable way to throw during a commit, so it raises the odds of hitting this from "an exotic value" to "a Next-shaped authoring mistake".1.
repeat()permanently duplicates a row (worst of the three)reconcileRepeatmoves DOM and deletes fromstate.mapincrementally while accumulating a localnewMap, and only commitsstate.map = newMapat the end (packages/core/src/render-client.js:1303). A throw mid-walk discardsnewMapwith the already-processed keys in it, so those keys are lost fromstate.mapwhile their nodes remain in the DOM, tracked by nothing.Measured, three-row keyed list, throw while reconciling row 2:
Step 3 is a fully valid render. Row "one" is duplicated because key 1 is no longer in the map, so a fresh instance is built while the orphaned original is never removed. Step 4 is byte-identical, so it never recovers, and nothing is logged after the first throw. In production this is a duplicated list row with no error to point at it.
2.
guard()leaves a permanently blank regionapplyChildInnerRawassignspart.__guardDeps(render-client.js:984) BEFORE committing the new template (:987), and the nested-template commit tears the previous child down first. A throw therefore leaves the region empty with the NEW deps already recorded, so every later render with the same deps hits theif (equal) returnshort-circuit and skips it forever.3.
watch()anduntil()commit outside the component error boundaryapplyWatch's Watcher notify schedulesapplyChildInnerin a barequeueMicrotask(render-client.js:1843) with no try/catch, and that path runs outsidecomponent.js's update cycle, so nothing routes a throw to_handleRenderError. It surfaces as a window-levelerror/uncaughtExceptioninstead of a per-componentrenderError, and the watched region is left empty because the prior content was torn down before the throw. It does self-heal on the next signal value, so this is the mildest of the three.until()'s promise handler has the same gap and additionally advancesstate.highestResolvedbefore committing.Design / approach
One root cause, three symptoms: state is committed before the operation that can fail. The fix is to make each commit atomic with respect to a throw, which generally means one of:
guard()(move the__guardDepsassignment afterapplyChildInnerreturns) and structurally right foruntil().reconcileRepeat, wrap the walk and on a throw either restore the pre-walkstate.mapor mark the state as poisoned so the next render rebuilds the list from scratch rather than reusing a map that no longer describes the DOM. Rebuilding is likely simpler and safer than trying to unwind partial DOM moves.watch()/until(), so their commits are contained like every other render path.Note the interaction with the async-commit containment fix that shipped in #1167 (
_commitAsyncnow catches a commit throw and routes it to_handleRenderError). That made the error VISIBLE and contained at the component level; it does not make the directive state consistent, which is what this issue is about. A component that renders a corruptedrepeat()list now reports the error properly and still shows a duplicated row.Decide whether a poisoned directive state should force a full re-render of its region. That is probably the cheapest correct answer across all three, and it fits the framework's existing preference for degrading to a clean rebuild rather than a guessed recovery (the client router does exactly this when a boundary scan is poisoned).
Implementation notes (for the implementing agent)
Where to edit (all in
packages/core/src/render-client.js):reconcileRepeat, the incrementalmoveRange+state.map.delete(key)walk, with the deferred commit at L1303 (state.map = newMap).applyChildInnerRaw, guard branch:prevDepsread at L972,__guardDepswritten at L984, commit at L987. Related cleanup lives inclearStaleDirectiveState(L1551-1552) and the comment at L901.applyWatch, the notify path at L1843 (queueMicrotask).until()state handling around L1706-1731 (highestResolved,carriedHighest).Landmines:
reconcileArray(the plain, non-keyed path) is NOT affected: it leaves the DOM untouched on a throw. Do not "fix" it to match, and do use it as the reference for what a safe path looks like.toStringthrows). Reproduce with that, NOT with the form-action guard, so the tests do not depend on the guard's existence._commitAsyncinpackages/core/src/component.jsnow catches commit throws (fix: refuse a function in form action=, it leaked server action source #1167). That changes what an author OBSERVES but not the corrupted state, so a test asserting "the error was reported" is not evidence the state is consistent. Assert the DOM after a subsequent VALID render, which is where the corruption shows.Invariants to respect:
Tests + docs surfaces:
packages/core/test/rendering/and the directive tests: for each of the three paths, throw mid-commit, then assert the DOM after a subsequent fully valid render. The counterfactual is the second render, not the throwing one.repeat()keyed reconciliation is DOM-identity-sensitive, so the duplication case needs a real-browser assertion, not only linkedom..agents/skills/webjs/references/components.md(the error-isolation paragraph) and the troubleshooting page.Acceptance criteria
repeat(), a subsequent valid render produces the correct list with no duplicated or orphaned rowsguard(), a later render with the same deps re-renders the region instead of skipping it foreverwatch()oruntil()commit reaches the component'srenderError()boundary rather than the windowrepeat()case has a real-browser assertion, not only linkedom