Skip to content

fix: a directive inside an asyncAppend chunk escapes the error boundary - #1282

Open
vivek7405 wants to merge 2 commits into
fix/map-array-commit-throwfrom
fix/async-stream-error-boundary
Open

fix: a directive inside an asyncAppend chunk escapes the error boundary#1282
vivek7405 wants to merge 2 commits into
fix/map-array-commit-throwfrom
fix/async-stream-error-boundary

Conversation

@vivek7405

Copy link
Copy Markdown
Collaborator

Closes #1251

Stacked on #1277 (which closes #1250, itself stacked on #1274 / #1268). Base is fix/map-array-commit-throw, so review the top commit; GitHub retargets this as the stack merges. The stacking is for the doc paragraphs all three edit, not a correctness dependency: this is a different function and a different failure mode.

A watch() or until() nested inside a chunk committed by asyncAppend / asyncReplace never got an error-boundary owner, so a throw from ITS commit escaped to the window as an uncaught error instead of reaching the owning component's renderError(). #1172 routed out-of-band commit throws by stamping the owner at directive-install time and wrapping watch's notify microtask and until's promise handler. consumeAsyncStream is the third such site and was not wrapped: it commits through renderToNodes with no render on the stack, so any directive installed by that commit saw currentRenderRoot === null, was never stamped, and its later throw fell through to a bare rethrow inside a microtask.

This was NOT the documented carve-out. That carve-out is about the stream swallowing its own ITERATION errors. A nested directive's commit throw was neither swallowed nor routed, it simply escaped.

What changed

Both directives stamp the owner at install time, above the same-iterable short-circuit so a re-render that returns early still refreshes it, and guarded so a re-install outside a render keeps a previously good owner.

The chunk commit runs inside commitOutOfBand, with renderToNodes INSIDE the wrap, since that is where a nested directive is installed and where it reads currentRenderRoot. commitInto is a different concern (the renderer-write window for a light slot host), so the two nest rather than replace each other.

The one catch splits into two spans, which the filing left open and this settles. A chunk COMMIT throw is a render failure of the component whose template holds the binding, so it routes to renderError() and STOPS the stream (the boundary is about to render an error state, and appending into a region it may have replaced is not a recovery). A throw from the author's own iterable keeps its console.error, because that is the author's generator failing rather than a render.

lit is no authority either way: forAwaitOf has no try at all and AsyncReplaceDirective.update neither awaits nor catches it, so in lit BOTH failures become unhandled rejections at the window, and lit has no per-component boundary to route to. Webjs does, and per-component error isolation says a commit throw belongs to the owning component. Routing a nested watch throw while leaving the identical throw from the chunk's own template at the console would have been an indefensible seam.

The split is structural, not a flag, and that is load-bearing: reportOutOfBandCommitError RETHROWS for a part with no owner, so calling it from inside the old outer try would hand that rethrow straight back to the console.error swallow, which is the escape being fixed.

Audit of the remaining out-of-band commit sites

Recorded so this closes the SET rather than one instance:

  • renderToNodes is called only from consumeAsyncStream (now wrapped) and recursively from itself.
  • applyChildInner's unwrapped callers are all reached from a render, so currentRenderRoot is already set: applyChild, applyChildInnerRaw (the keyed and guard paths), applyCache, applyUntil's two synchronous calls, and applyWatch's two synchronous calls.
  • The two genuinely out-of-band ones were already wrapped: until's resolution and watch's notify.

Correction shipped alongside

The docs said this path "logs its own iteration throw and continues on purpose". It never continued: the catch sat OUTSIDE the while, so an iteration throw has always ended the stream. Both doc surfaces now say so.

Test plan

  • Unit (packages/core/test/rendering/directive-commit-throw.test.js): six new cases. A nested watch for asyncAppend and for asyncReplace; a nested until (the two directives stamp independently); one through the ownershipTest helper, whose owner-el holding a child-el is what distinguishes the correct owner from a lucky one; the stream's OWN chunk commit throw reaching the boundary, not being logged, and stopping the stream (asserted by the generator not being pulled again); and an ITERATION throw still logged and never routed. Every one asserts nothing escaped, via uncaughtException and unhandledRejection listeners held only for the assertion window, since "the boundary was called" cannot distinguish routed from routed-and-also-escaped. 25/25 in the file, 402/402 across packages/core/test/rendering.
  • Counterfactual, the two pieces reverted separately. Reverting only the stamps reds the nesting cases. Reverting only the commit-span split reds ONLY the stream's-own-commit case while the iteration case stays green. Proven at c2ac4005.
  • Browser (packages/core/test/rendering/browser/directive-commit-throw.test.js): a nested watch against a REAL component in both light and shadow mode. Shadow is the case the unit tests cannot reach, since only there does the render root differ from the boundary-carrying element and boundaryOwnerOf has to resolve a ShadowRoot through its .host. A window error listener asserts nothing escaped. 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 sentence saying this path is not covered "in two distinct ways" now states what is true, including that an iteration throw ends the stream.
  • Updated website/app/docs/error-handling/page.ts: the same correction in that page's prose.
  • 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.

@vivek7405 vivek7405 self-assigned this Aug 5, 2026
@vivek7405
vivek7405 force-pushed the fix/async-stream-error-boundary branch from c2ac400 to a324bc8 Compare August 5, 2026 10:59
@vivek7405
vivek7405 force-pushed the fix/map-array-commit-throw branch from f9b7b7c to 4c47403 Compare August 5, 2026 11:03
@vivek7405
vivek7405 force-pushed the fix/async-stream-error-boundary branch from a324bc8 to 5ff87a5 Compare August 5, 2026 11:03

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Routing is right and the two spans are the correct shape. Everything I found is a claim around it that does not match what runs.

Both doc surfaces bind the stream stopping to a nested watch / until throw as well as to the chunk commit. Only the commit sets aborted; a nested directive throws from its own handler, outside the loop entirely, so the loop keeps pulling. Easy to state and easy to test, so both now happen.

The mapper sits in the iteration span, so a mapper throw stays at the console, but the docs said the iterable was the only thing that did. The mapper is the code producing the very TemplateResult being committed, so that omission is the sort a reader would act on.

The rethrow comment was wrong twice over: the part DOES have an owner after this change (render() stamps the container itself), so the rethrow is the owner-has-no-boundary branch; and watch throws inside a queueMicrotask, which is an uncaught error, not a rejection. Also one lowercase brand in prose, which the hook regex misses because of the trailing possessive.

Where each one is, since the anchors moved under a rebase:

  • .agents/skills/webjs/references/components.md and website/app/docs/error-handling/page.ts, the async-stream sentence in the mid-commit paragraph: bound the stop to the nested case as well as the commit, and named the iterable as the only thing left at the console.
  • packages/core/src/render-client.js, the rethrow comment inside consumeAsyncStream's commit catch: claimed the part has no owner, and called the surfacing an unhandled rejection like watch.
  • packages/core/src/render-client.js, consumeAsyncStream's doc comment: lowercase brand in prose.

@vivek7405

Copy link
Copy Markdown
Collaborator Author

All fixed in 5ff87a56.

Both doc surfaces now scope the stop to the chunk commit and say a nested directive reaches the boundary without stopping the loop, and both name the mapper alongside the iterable. There are tests for each: one lets the generator yield again after a nested throw and asserts it was pulled, another throws from a mapper and asserts it stays at the console.

The rethrow comment now says the rethrow comes from an owner carrying no _handleRenderError rather than from a missing owner, and that the surfacing shape differs by site (this rejects the loop promise, until rejects from its .then, watch throws in a queueMicrotask). The ownerless path is stated in both doc surfaces; I left it out of the unit tests deliberately, because the surfacing is an unhandled rejection and the node runner claims those itself, so the assertion would fail the test making it. Brand casing fixed too.

@vivek7405
vivek7405 marked this pull request as ready for review August 5, 2026 11:54
@vivek7405
vivek7405 force-pushed the fix/map-array-commit-throw branch from 4c47403 to 04ef620 Compare August 5, 2026 12:07
A watch() or until() nested inside a chunk committed by asyncAppend /
asyncReplace never got an error-boundary owner, so a throw from its commit
escaped to the window instead of reaching the owning component's
renderError(). consumeAsyncStream is the third out-of-band commit site and was
the one #1172 did not wrap: it commits with no render on the stack, so any
directive installed by that commit saw no currentRenderRoot, was never
stamped, and its later throw fell through to a bare rethrow in a microtask.

Both directives now stamp the owner at install time, above the same-iterable
short-circuit so a re-render that returns early still refreshes it, and the
chunk commit runs inside commitOutOfBand with renderToNodes INSIDE the wrap,
since that is where a nested directive reads the owner.

The one catch also splits in two. A chunk COMMIT throw is a render failure of
the component whose template holds the binding, so it routes to renderError()
and stops the stream, because the boundary is about to render an error state
and appending into a region it may have replaced is not a recovery. A throw
from the author's own iterable keeps its console.error, which is a separate
standing decision about the author's generator rather than about a render.

The split has to be structural rather than a flag: reportOutOfBandCommitError
rethrows for an ownerless part, so calling it inside the old outer try would
hand that rethrow straight to the swallow this fixes.
…he author's

Corrections to the claims around the async-stream routing, not to the routing.

Both doc surfaces bound the stream stopping to a nested watch or until throw
as well as to the chunk commit. Only the commit stops it: a nested directive
throws from its own handler, outside the loop entirely, so the loop keeps
pulling. There is now a test that lets the generator yield again after a
nested throw and asserts exactly that.

The mapper is the author's code, not a render, and it sits in the iteration
span with the iterable, so a mapper throw stays at the console. The docs said
the iterable was the only thing that did. Both now name the mapper, and a test
pins it.

The rethrow comment claimed the part has no owner and that the surfacing is an
unhandled rejection like watch and until. The part does have an owner after
this change (render() stamps the container itself), so the rethrow comes from
that owner carrying no _handleRenderError; and watch throws inside a
queueMicrotask, which is an uncaught error rather than a rejection. Both
corrected, and the ownerless path is now stated in the docs.

Also fixes the one prose brand casing in the file.
@vivek7405
vivek7405 force-pushed the fix/async-stream-error-boundary branch from 5ff87a5 to e49fc69 Compare August 5, 2026 12:07
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.

1 participant