} */
+ 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``);
+ 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``);
});
+// --- 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``);
+ 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``);
+ 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``);
+ };
+});
+
+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``);
+ 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().