Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/webjs/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`<child-el>${watch(sig)}</child-el>`` 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`<child-el>${watch(sig)}</child-el>`` 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.

Expand Down
128 changes: 101 additions & 27 deletions packages/core/src/render-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2679,6 +2679,15 @@ function teardownWatch(partAny) {
* @param {{ iterable: AsyncIterable<unknown>, 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.
Expand Down Expand Up @@ -2717,6 +2726,9 @@ function applyAsyncAppend(part, dir) {
* @param {{ iterable: AsyncIterable<unknown>, 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'
Expand Down Expand Up @@ -2763,46 +2775,108 @@ 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<BoundPart, {kind:'child'}>} part
* @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} 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<unknown>} */
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++;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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`<p>ok</p>`);
const seen = [];
const escaped = [];
const onError = (e) => { escaped.push(e); };

class StreamHost extends WebComponent({}) {
static shadow = shadow;
renderError(err) { seen.push(err); return html`<p>err</p>`; }
render() {
async function* gen() { yield html`<span>${watch(inner)}</span>`; }
return html`<div>${asyncReplace(gen())}</div>`;
}
}
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`<section title=${poison}>bad</section>`);
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(() => {
Expand Down
Loading