diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index d91b4b04e..aae9eab63 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -179,7 +179,7 @@ Three decoupled concerns, do not conflate them. Errors are isolated per component by default (no user code): a thrown `await` renders a component-scoped error state while siblings render, never bubbling to the route `error.ts`. Override `renderError(error)` only to customize it (dev shows the message, prod stays silent). The boundary covers the COMMIT as well as the fetch, so a template that throws while being applied (a refused binding, a value whose `toString` throws) reaches `renderError()` too, and `updateComplete` still settles. Those two halves used to disagree: a fetch rejection was contained and a commit throw escaped as an unhandled rejection that also left `updateComplete` pending forever. -The boundary also covers `watch(signal)` (its notify microtask) and `until()` (its promise resolution), which commit outside the update cycle. A throw from either used to surface at the window instead of the owning component. It routes to the component whose TEMPLATE holds the binding, which is not always the element the binding sits inside: `html`${watch(sig)}`` belongs to the parent that wrote it, not to `child-el`. The `asyncAppend` / `asyncReplace` path is NOT covered, in two distinct ways: its own iteration throw is swallowed to `console.error` on purpose (an author's iterable should handle it), and a `watch` / `until` nested inside a chunk it commits is installed with no owner in scope, so that one still reaches the window. +The boundary also covers `watch(signal)` (its notify microtask) and `until()` (its promise resolution), which commit outside the update cycle. A throw from either used to surface at the window instead of the owning component. It routes to the component whose TEMPLATE holds the binding, which is not always the element the binding sits inside: `html`${watch(sig)}`` belongs to the parent that wrote it, not to `child-el`. `asyncAppend` / `asyncReplace` is the third such site and is covered the same way: a chunk's own commit throw, and a `watch` / `until` nested inside a chunk, both reach the owning component's `renderError()`. A chunk's own commit throw also STOPS the stream, since the boundary is about to render an error state and appending into a region it may have replaced is not a recovery; a nested directive throws from its own handler outside that loop, so it reaches the boundary but does not stop the stream, the same as a directive nested anywhere else. What stays at `console.error` is the author's own code, the iterable AND any `mapper` passed alongside it, on the standing reasoning that an author's iterable should handle its own errors. That ends the stream too, and always has. With a bare `render()` into a plain container there is no component to receive a commit throw, so it surfaces rather than being swallowed, which is what `watch` and `until` already do. **A commit that throws leaves the directive's own state consistent, so the NEXT valid render is correct.** This matters because the corruption is otherwise silent: the renders that expose it are fully valid and log nothing after the first throw. The hole whose commit threw is marked so the next render re-applies it rather than skipping it as unchanged (its recorded value is never advanced past a throw, and would otherwise match exactly what the recovering render supplies, leaving a child region blank for good). Both list reconcilers additionally repair their own bookkeeping so it describes the DOM again, and the next render is an ordinary reconcile rather than a rebuild of the region, which would discard the node identity the reconcilers exist to preserve. `repeat()` re-unites its key map and repositions every row (the failure was a permanently duplicated row). A plain `.map()` array splices the part of its slot list the failed pass never reached back on, which matters whenever a slot is REPLACED rather than updated in place (its template shape changed, its kind changed between text, template and empty, or the array grew past its old length), since that is the branch that inserts the replacement before removing what it replaced (the failure was a stranded row that outlived even a render of an empty array). `guard()` records its new deps only once the commit succeeds, so a later render with those same deps re-renders the region instead of short-circuiting past a region the throw had blanked; `until()` advances its resolved priority only after the commit succeeds, so a failed high-priority resolution does not refuse the lower-priority one behind it. diff --git a/packages/core/src/render-client.js b/packages/core/src/render-client.js index b70f284e0..e4012c8c1 100644 --- a/packages/core/src/render-client.js +++ b/packages/core/src/render-client.js @@ -2679,6 +2679,15 @@ function teardownWatch(partAny) { * @param {{ iterable: AsyncIterable, mapper?: (v: unknown, i: number) => unknown }} dir */ function applyAsyncAppend(part, dir) { + const partAny = /** @type any */ (part); + // Record the owning component while we are still inside its render(), the + // only moment it is knowable, exactly as `applyWatch` / `applyUntil` do. + // Chunks commit from an async loop with no render on the stack, so without + // this both the chunk's own commit throw and any directive nested inside a + // chunk have no owner to route to. Stamped ABOVE the short-circuit so a + // re-render that returns early still refreshes the owner, and guarded so a + // re-install outside a render keeps a previously good one. + if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot); // Same-iterable short-circuit: if the prior render's iterable identity // matches, the existing iterator is still consuming it. Re-subscribing // would start a fresh iterator that misses already-yielded values. @@ -2717,6 +2726,9 @@ function applyAsyncAppend(part, dir) { * @param {{ iterable: AsyncIterable, mapper?: (v: unknown, i: number) => unknown }} dir */ function applyAsyncReplace(part, dir) { + const partAny = /** @type any */ (part); + // Owner stamp: see comment in applyAsyncAppend. Above the short-circuit. + if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot); // Same-iterable short-circuit: see comment in applyAsyncAppend. const currentChild = /** @type any */ (part.child); if (currentChild && currentChild.kind === 'async-stream' @@ -2763,6 +2775,23 @@ function applyAsyncReplace(part, dir) { * after every `next()` resolve to short-circuit if abortion happened * while the iterator was suspended. * + * Each pass carries TWO try spans, and which failure lands in which is the + * load-bearing part. SPAN A is the author's own code, the iterable AND the + * `mapper` it was given, and a throw from either is logged to the console and + * ends the stream, on the long-standing reasoning that an author's iterable + * should handle its own errors. SPAN B is the chunk COMMIT, which is a render + * failure of the component whose template holds the binding, so it routes to + * that component's `renderError()` and stops the stream. + * + * Scope note: only a throw from the COMMIT can stop the stream from here. A + * directive nested INSIDE a committed chunk (a `watch` whose signal changes + * later) throws from its own handler, outside this loop entirely, so it + * reaches the boundary but this loop knows nothing about it and keeps + * pulling. That is the same for any directive nested anywhere else. lit is no authority + * either way here (it has no per-component boundary, and both failures become + * unhandled rejections at the window), so this follows the + * per-component error isolation WebJs has instead. + * * @param {AsyncStreamState} state * @param {Extract} part * @param {{ iterable: AsyncIterable, mapper?: (v: unknown, i: number) => unknown }} dir @@ -2770,39 +2799,84 @@ function applyAsyncReplace(part, dir) { async function consumeAsyncStream(state, part, dir) { const marker = part.marker; let i = 0; - try { - while (!state.aborted) { - const result = await state.iterator.next(); + while (!state.aborted) { + /** @type {IteratorResult} */ + let result; + /** @type {unknown} */ + let mapped; + // SPAN A, the author's iterable. A throw here is the author's generator + // failing, not a render, so it keeps the long-standing console.error and + // ends the stream. It is a separate span from the commit below on purpose + // rather than a flag the one catch inspects, because the distinction is + // the whole point: `reportOutOfBandCommitError` RETHROWS for a part with + // no owner, and a single enclosing try would hand that rethrow straight + // back to this swallow, which is the escape this split exists to stop. + try { + result = await state.iterator.next(); if (state.aborted) break; if (result.done) break; - const mapped = dir.mapper ? dir.mapper(result.value, i) : result.value; - const newNodes = renderToNodes(mapped); - - // This chunk commit runs in an async loop OUTSIDE any render() window, - // so open the renderer-write window explicitly: without it, committing a - // stream chunk into a light slot host would hit the patched insertBefore / - // removeChild and fold the renderer's own output into `authored`. - commitInto(marker.parentNode, () => { - if (state.mode === 'replace') { - for (const n of state.nodes) { - if (n.parentNode) n.parentNode.removeChild(n); + mapped = dir.mapper ? dir.mapper(result.value, i) : result.value; + } catch (err) { + // Note this ENDS the stream, and always has: the catch used to sit + // outside the loop, so there has never been a resume path here. + if (typeof console !== 'undefined') console.error('[webjs] asyncStream error:', err); + return; + } + + // SPAN B, the chunk commit. This is a render of the component whose + // TEMPLATE holds the binding, so a throw is that component's render + // failure and routes to its `renderError()`, the same as `watch` and + // `until` already do from their own out-of-band commits. `renderToNodes` + // is INSIDE the wrap because that is where a nested directive is + // installed and reads `currentRenderRoot`; without it, a `watch()` inside + // a chunk is stamped with no owner and its later throw escapes. + // `commitInto` is a different concern (the renderer-write window for a + // light slot host), so the two nest rather than replace each other. + try { + commitOutOfBand(part, () => { + const newNodes = renderToNodes(mapped); + + // This chunk commit runs in an async loop OUTSIDE any render() window, + // so open the renderer-write window explicitly: without it, committing a + // stream chunk into a light slot host would hit the patched insertBefore / + // removeChild and fold the renderer's own output into `authored`. + commitInto(marker.parentNode, () => { + if (state.mode === 'replace') { + for (const n of state.nodes) { + if (n.parentNode) n.parentNode.removeChild(n); + } + state.nodes = []; } - state.nodes = []; - } - const frag = document.createDocumentFragment(); - for (const n of newNodes) frag.appendChild(n); - marker.parentNode?.insertBefore(frag, marker); - state.nodes.push(...newNodes); + const frag = document.createDocumentFragment(); + for (const n of newNodes) frag.appendChild(n); + marker.parentNode?.insertBefore(frag, marker); + state.nodes.push(...newNodes); + }); }); - - i++; + } catch (err) { + // Stop the stream. The boundary is about to render an error state, and + // appending later chunks into a region it may have replaced is not a + // recovery. The rendered nodes are left alone: blanking the region is a + // separate decision, and `teardownAsyncStream` is for the part being + // reset, not for this. + state.aborted = true; + try { state.iterator.return?.()?.catch?.(() => {}); } catch { /* best effort */ } + // Rethrows when nothing can receive the error, which for a bare + // `render()` into a plain container is an owner that carries no + // `_handleRenderError` (the stamp records the container itself, so the + // owner is present, just not a component). Surfacing beats swallowing + // there, and it matches `watch` and `until`, which rethrow from their + // own out-of-band handlers for the same reason. The exact shape differs + // by site rather than being one thing: this rejects the loop's + // promise, `until` rejects from its `.then`, and `watch` throws inside + // a `queueMicrotask`, which is an uncaught error rather than a + // rejection. + reportOutOfBandCommitError(part, err); + return; } - } catch (err) { - // Swallow iteration errors. A leaked iterator throwing should not - // crash the host's render cycle. Authors who care about errors - // should handle them in their iterable / generator. - if (typeof console !== 'undefined') console.error('[webjs] asyncStream error:', err); + + i++; } } diff --git a/packages/core/test/rendering/browser/directive-commit-throw.test.js b/packages/core/test/rendering/browser/directive-commit-throw.test.js index ea27437f3..b40361a3a 100644 --- a/packages/core/test/rendering/browser/directive-commit-throw.test.js +++ b/packages/core/test/rendering/browser/directive-commit-throw.test.js @@ -16,7 +16,7 @@ import { html } from '../../../src/html.js'; import { render } from '../../../src/render-client.js'; import { repeat } from '../../../src/repeat.js'; -import { watch, ref } from '../../../src/directives.js'; +import { watch, ref, asyncReplace } from '../../../src/directives.js'; import { signal } from '../../../src/signal.js'; import { WebComponent } from '../../../src/component.js'; @@ -317,6 +317,64 @@ suite('directive commit throws (browser)', () => { assert.equal(container.querySelector('div').children.length, 0); }); + // A chunk commits from an async loop with no render on the stack, so a + // directive installed BY that commit has no owner unless the stream part + // was stamped when it was installed. SHADOW is the case the unit tests + // cannot reach: only there does the render root differ from the + // boundary-carrying element, so only there does `boundaryOwnerOf` have to + // resolve a ShadowRoot through its `.host`. + const streamBoundaryTest = (label, shadow, tag) => { + test(label, async () => { + const inner = signal(html`

ok

`); + const seen = []; + const escaped = []; + const onError = (e) => { escaped.push(e); }; + + class StreamHost extends WebComponent({}) { + static shadow = shadow; + renderError(err) { seen.push(err); return html`

err

`; } + render() { + async function* gen() { yield html`${watch(inner)}`; } + return html`
${asyncReplace(gen())}
`; + } + } + StreamHost.register(tag); + + const el = document.createElement(tag); + document.body.appendChild(el); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 20)); + const root = shadow ? el.shadowRoot : el; + assert.equal(root.querySelector('p').textContent, 'ok'); + + // Asserting the boundary was called cannot distinguish routed from + // routed AND also escaped, and escaping is the failure being fixed. + window.addEventListener('error', onError); + try { + inner.set(html`
bad
`); + await new Promise((r) => setTimeout(r, 30)); + } finally { + window.removeEventListener('error', onError); + } + + assert.equal(seen.length, 1, 'the nested directive must reach THIS component'); + assert.equal(seen[0].message, 'boom'); + assert.equal(escaped.length, 0, 'nothing may reach the window'); + el.remove(); + }); + }; + + streamBoundaryTest( + 'a watch nested in an async chunk reaches a LIGHT-DOM component renderError', + false, + 'stream-throw-light-host', + ); + streamBoundaryTest( + 'a watch nested in an async chunk reaches a SHADOW-DOM component renderError', + true, + 'stream-throw-shadow-host', + ); + test('removing rows after recovery leaves nothing behind', () => { render(rows(good), container); throwsMatching(() => { diff --git a/packages/core/test/rendering/directive-commit-throw.test.js b/packages/core/test/rendering/directive-commit-throw.test.js index 138bc815b..6bba4ef92 100644 --- a/packages/core/test/rendering/directive-commit-throw.test.js +++ b/packages/core/test/rendering/directive-commit-throw.test.js @@ -27,11 +27,11 @@ before(() => { globalThis.HTMLElement = window.HTMLElement; }); -let html, render, guard, until, watch, ref, repeat, signal; +let html, render, guard, until, watch, ref, asyncAppend, asyncReplace, repeat, signal; before(async () => { ({ html } = await import('../../src/html.js')); ({ render } = await import('../../src/render-client.js')); - ({ guard, until, watch, ref } = await import('../../src/directives.js')); + ({ guard, until, watch, ref, asyncAppend, asyncReplace } = await import('../../src/directives.js')); ({ repeat } = await import('../../src/repeat.js')); ({ signal } = await import('../../src/signal.js')); }); @@ -506,7 +506,9 @@ function ownershipTest(label, installDirective) { // The child upgrades and gains the boundary from its prototype. owner.querySelector('child-el')._handleRenderError = (err) => { childSeen.push(err); }; - fire(); + // `fire` may be async: an async-stream case has to let its first chunk + // commit before the directive nested inside that chunk even exists. + await fire(); await tick(); assert.equal(ownerSeen.length, 1, 'the OWNING template must get the error'); @@ -528,6 +530,219 @@ ownershipTest('until: routes to the template that owns the part, not the element return () => resolveIt(html`
bad
`); }); +// --- asyncAppend / asyncReplace --- + +/** + * Run `fn` with nothing allowed to escape. Asserting only that the boundary + * was called cannot tell "routed" from "routed AND also escaped", and an + * escape is the whole failure being fixed here. The commit runs in a + * microtask, so a `watch` rethrow surfaces as an uncaughtException and a + * promise rejection as an unhandledRejection; watch for both. + */ +async function assertNothingEscapes(fn) { + const escaped = []; + const onUncaught = (err) => { escaped.push(err); }; + process.on('uncaughtException', onUncaught); + process.on('unhandledRejection', onUncaught); + try { + await fn(); + await tick(); + } finally { + process.off('uncaughtException', onUncaught); + process.off('unhandledRejection', onUncaught); + } + assert.deepEqual(escaped.map((e) => e?.message ?? String(e)), [], 'nothing may reach the window'); +} + +// `consumeAsyncStream` is the third out-of-band commit site. It commits with +// no render on the stack, so a directive installed BY that commit used to see +// no owner, was never stamped, and its own later throw fell through to a bare +// rethrow inside a microtask. + +for (const [label, makeDirective] of [ + ['asyncAppend', (gen) => asyncAppend(gen)], + ['asyncReplace', (gen) => asyncReplace(gen)], +]) { + test(`${label}: a watch nested in a chunk routes its throw to the boundary`, async () => { + const seen = []; + const owner = document.createElement('owner-el'); + owner._handleRenderError = (err) => { seen.push(err); }; + + const inner = signal(html`

b

`); + async function* gen() { yield html`${watch(inner)}`; } + render(html`
${makeDirective(gen())}
`, owner); + + await assertNothingEscapes(async () => { + await tick(); + inner.set(html`
bad
`); + await tick(); + }); + + assert.equal(seen.length, 1, 'the nested directive must inherit the owner'); + assert.match(seen[0].message, /boom/); + }); +} + +test('asyncReplace: an until nested in a chunk routes its throw to the boundary', async () => { + // The two directives stamp independently, so covering one says nothing + // about the other. + const seen = []; + const owner = document.createElement('owner-el'); + owner._handleRenderError = (err) => { seen.push(err); }; + + let resolveIt; + const pending = new Promise((r) => { resolveIt = r; }); + async function* gen() { yield html`${until(pending, html`

fallback

`)}
`; } + render(html`
${asyncReplace(gen())}
`, owner); + + await assertNothingEscapes(async () => { + await tick(); + resolveIt(html`
bad
`); + await tick(); + }); + + assert.equal(seen.length, 1); + assert.match(seen[0].message, /boom/); +}); + +ownershipTest('asyncAppend: a nested watch routes to the template that owns the part', (owner) => { + const inner = signal(html`

b

`); + async function* gen() { yield html`${watch(inner)}`; } + render(html`${asyncAppend(gen())}`, owner); + return async () => { + await tick(); + inner.set(html`
bad
`); + }; +}); + +test('asyncReplace: the stream\'s OWN chunk commit throw reaches the boundary and stops it', async () => { + const seen = []; + const logged = []; + const owner = document.createElement('owner-el'); + owner._handleRenderError = (err) => { seen.push(err); }; + + let yielded = 0; + async function* gen() { + yielded++; yield html`

${poison}

`; + yielded++; yield html`

after

`; + } + + const origError = console.error; + console.error = (...args) => { logged.push(args.join(' ')); }; + try { + render(html`
${asyncReplace(gen())}
`, owner); + await assertNothingEscapes(async () => { await tick(); }); + } finally { + console.error = origError; + } + + // A chunk commit is a render of the component whose template holds the + // binding, so it belongs to that component's boundary, not to the console. + // Routing the nested case but not this one would be an indefensible seam. + assert.equal(seen.length, 1, 'the chunk commit throw must reach the boundary'); + assert.match(seen[0].message, /boom/); + assert.deepEqual(logged, [], 'and must NOT also be logged as an iteration error'); + + // The stream stops: the boundary is about to render an error state, and + // appending into a region it may have replaced is not a recovery. + await tick(); + assert.equal(yielded, 1, 'no further chunk may be pulled'); + assert.equal(owner.querySelector('p'), null); +}); + +test('asyncReplace: a mapper throw is the author\'s code too, so it stays at the console', async () => { + // The mapper sits in the same span as the iterable on purpose: it is the + // author's function, not a render. Asserted so the docs claim about what + // reaches the boundary is backed rather than assumed. + const seen = []; + const logged = []; + const owner = document.createElement('owner-el'); + owner._handleRenderError = (err) => { seen.push(err); }; + + async function* gen() { yield 'chunk'; } + const origError = console.error; + console.error = (...args) => { logged.push(args.join(' ')); }; + try { + render(html`
${asyncReplace(gen(), () => { throw new Error('mapper-boom'); })}
`, owner); + await assertNothingEscapes(async () => { await tick(); }); + } finally { + console.error = origError; + } + + assert.equal(seen.length, 0, 'a mapper throw must not reach the boundary'); + assert.equal(logged.length, 1); + assert.match(logged[0], /mapper-boom/); +}); + +test('asyncReplace: a nested watch throw does NOT stop the stream', async () => { + // Only a throw from the CHUNK COMMIT stops the loop. A directive nested + // inside a committed chunk throws from its own handler, outside the loop + // entirely, so the stream keeps pulling. The docs used to bind the stop to + // both cases; this is what makes that claim checkable. + const seen = []; + const owner = document.createElement('owner-el'); + owner._handleRenderError = (err) => { seen.push(err); }; + + const inner = signal(html`

b

`); + let release; + const gate = new Promise((r) => { release = r; }); + let yielded = 0; + async function* gen() { + yielded++; yield html`${watch(inner)}`; + await gate; + yielded++; yield html`second`; + } + render(html`
${asyncReplace(gen())}
`, owner); + + await assertNothingEscapes(async () => { + await tick(); + inner.set(html`
bad
`); + await tick(); + }); + assert.equal(seen.length, 1, 'the nested throw still reaches the boundary'); + + release(); + await tick(); + assert.equal(yielded, 2, 'the stream keeps pulling after a NESTED throw'); +}); + +// Not covered here: a chunk commit throw with NO component owner (a bare +// `render()` into a plain container). It rethrows rather than being +// swallowed, which is what `watch` and `until` already do there, but the +// surfacing is an unhandled rejection and this runner claims those itself, so +// asserting it would fail the test it is asserting in. It is stated in both +// doc surfaces instead. + +test('asyncReplace: an ITERATION throw is unchanged, logged and never routed', async () => { + const seen = []; + const logged = []; + const owner = document.createElement('owner-el'); + owner._handleRenderError = (err) => { seen.push(err); }; + + async function* gen() { + yield html`

ok

`; + throw new Error('generator-boom'); + } + + const origError = console.error; + console.error = (...args) => { logged.push(args.join(' ')); }; + try { + render(html`
${asyncReplace(gen())}
`, owner); + await assertNothingEscapes(async () => { await tick(); }); + } finally { + console.error = origError; + } + + // The author's generator failing is not a render, and an author's iterable + // is expected to handle its own errors. Deliberately left as it was. + assert.equal(seen.length, 0, 'an iteration throw must NOT reach the boundary'); + assert.equal(logged.length, 1); + assert.match(logged[0], /generator-boom/); + // It also ENDS the stream, and always has: the catch used to sit outside + // the loop, so there has never been a resume path. + assert.equal(owner.querySelector('p')?.textContent, 'ok'); +}); + test('a directive installed BY an out-of-band commit still reaches the boundary', async () => { const seen = []; const owner = document.createElement('owner-el'); diff --git a/website/app/docs/error-handling/page.ts b/website/app/docs/error-handling/page.ts index a6f2eab3b..8bbbcc9c9 100644 --- a/website/app/docs/error-handling/page.ts +++ b/website/app/docs/error-handling/page.ts @@ -126,7 +126,7 @@ export default function GlobalError({ error }: { error: Error }) {

For a component with an async render(), error isolation is a default that needs no user code. A thrown await getData() (or any render throw) is caught for THAT component: its siblings render normally and the failure never bubbles to the route error.ts. On the server the default renders a component-scoped error box in dev and a silent empty element in prod (no internal detail leaks); on the client the same boundary runs. Add renderError() only to customize the error UI. This delivers a per-route-error-boundary experience at the component level, without per-component routes.

A directive that throws mid-commit stays consistent

-

The component boundary above also covers watch(signal) and until(), which commit outside the update cycle, so a throw from either reaches renderError() rather than the window. It reaches the component whose template holds the binding, which is not always the element the binding sits inside: a watch() written between a child component's tags belongs to the parent that wrote it. asyncAppend / asyncReplace are not covered, in two ways: that path logs its own iteration throw and continues on purpose, and a watch() or until() nested inside a chunk it commits still reaches the window.

+

The component boundary above also covers watch(signal) and until(), which commit outside the update cycle, so a throw from either reaches renderError() rather than the window. It reaches the component whose template holds the binding, which is not always the element the binding sits inside: a watch() written between a child component's tags belongs to the parent that wrote it. asyncAppend / asyncReplace are covered the same way: a chunk's own commit throw, and a watch() or until() nested inside a chunk, both reach the owning component's renderError(). A chunk's own commit throw also stops the stream, since the boundary is about to render an error state and appending into a region it may have replaced is not a recovery; a nested directive throws from its own handler outside that loop, so it reaches the boundary without stopping the stream. Your own code is the exception: a throw from the iterable or from a mapper you passed alongside it is a generator failing rather than a render, so it is still logged to the console and you are expected to handle it. That ends the stream too.

Beyond reporting the error, the directive's own state is left describing the DOM that actually exists, which is what makes the NEXT render correct. That matters because the failure is otherwise silent: the renders that expose it are fully valid and log nothing. The hole whose commit threw is marked so the next render re-applies it instead of skipping it as unchanged, which is what used to leave a region blank for good. Both list reconcilers additionally repair their own bookkeeping so it describes the DOM again, and the next render is an ordinary reconcile rather than a rebuild of the region, which would throw away the node identity they exist to preserve. repeat() re-unites its key map and repositions every row (the symptom was a permanently duplicated row). A plain .map() array splices back the part of its slot list the failed pass never reached, which is what a slot REPLACED rather than updated in place needs (its template shape changed, its kind changed between text, template and empty, or the array grew past its old length), since that is the branch that inserts the replacement before removing what it replaced (the symptom was a stranded row that outlived even a render of an empty array). guard() records its new deps only once the commit succeeds, so a later render with those deps re-renders the region instead of skipping past one the throw had blanked; until() advances its resolved priority only after its commit succeeds, so a failed high-priority resolution does not refuse the lower-priority one behind it.

Tearing content back out is covered too, and it has to be, because a teardown has no next render to repair it. Unbinding a ref while a row is removed can never abort the removal of the rest of the list, and repeat() drops each leftover key from its map before touching that row, so the map never describes a row that has already been removed. Without that, a throw part-way through left the row you had DELETED on screen, reordered the survivors, and let a later render that re-added the key reinsert the disposed instance. The cost is that a ref whose object value setter throws is swallowed on teardown, matching the ref callback, which was already swallowed everywhere (lit guards neither and propagates from both, so this is a deliberate divergence). It applies to teardown only: on the COMMIT path a throwing object-ref setter still reaches renderError().