Skip to content

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

Merged
vivek7405 merged 2 commits into
mainfrom
fix/async-stream-error-boundary
Aug 5, 2026
Merged

fix: a directive inside an asyncAppend chunk escapes the error boundary#1285
vivek7405 merged 2 commits into
mainfrom
fix/async-stream-error-boundary

Conversation

@vivek7405

Copy link
Copy Markdown
Collaborator

Closes #1251

Targets main. #1274 (issue #1268) and #1284 (issue #1250) are both merged; this was stacked behind them only because all three edit the same two doc paragraphs, not for any correctness dependency (different function, different failure mode).

Replaces #1282, which GitHub closed when its base branch was deleted on the merge below it. Same branch, same work, no content difference; the review round and its resolutions live on #1282.

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 041cb9a1 and 7c099717.
  • 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.

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 self-assigned this Aug 5, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Review trail, carried over from #1282

This branch was reviewed on #1282 before GitHub closed that PR when its base branch was deleted. The content is identical, only the commit ids moved: 4c9f7b1a there is 041cb9a1 here, and 5ff87a56 is 7c099717. Reproducing it here so it is not stranded on a closed PR.

The routing and the two try spans were confirmed correct. Everything found was a claim around them that did not match what runs.

1. Both doc surfaces said a nested watch / until throw stops the stream. It does not. Only the chunk commit sets aborted; a nested directive throws from its own handler, outside consumeAsyncStream entirely, so the loop keeps pulling. Verified against a real component boundary with the default renderError(): after the nested throw routed, the generator was pulled again and the next chunk appended.

Fixed in 7c099717. Both surfaces now scope the stop to the chunk commit, and a test lets the generator yield again after a nested throw and asserts it was pulled.

2. A mapper throw also stays at console.error, but the docs said the iterable was the only thing that did. dir.mapper(...) sits in the iteration span, and the mapper is the code producing the very TemplateResult being committed, so the omission is one a reader would act on.

Fixed in 7c099717. Both surfaces name the mapper alongside the iterable, and a test throws from a mapper and asserts it stays at the console.

3. The rethrow comment was wrong on both of its claims. It said the part has no owner, but after this change it does (render() stamps the container itself), so the rethrow comes from that owner carrying no _handleRenderError. And it called the surfacing an unhandled rejection "exactly as watch and until do", but watch throws inside a queueMicrotask, which is an uncaught error rather than a rejection.

Fixed in 7c099717. Both corrected, and the ownerless path is now stated in the docs. It is deliberately not unit-tested: the surfacing is an unhandled rejection and the node runner claims those itself, so the assertion would fail the test making it.

4. One lowercase brand in prose (invariant 11), which the hook regex misses because of the trailing possessive. Fixed in the same commit.

What is not covered: the reviewer read the diff BEFORE those fixes. 7c099717 itself has not been read by a fresh reviewer. I verified it directly (the full rendering suite at 405 pass, and counterfactuals reverting the two stamps and the commit-span split separately, each reding exactly the expected cases), but that is my own verification rather than an independent one.

@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.

Read the whole diff against the branch state, including the fix commit the earlier round never saw. Nothing to change.

The parts I wanted to be sure of, since this PR is mostly about claims matching behaviour: the owner stamp sits above the same-iterable short-circuit and survives the later teardownChild(part), because neither that nor clearStaleDirectiveState touches __commitOwner. renderToNodes inside commitOutOfBand but outside commitInto keeps the pre-existing ordering, and commitInto(null, fn) degrades to fn(), so a detached marker cannot crash the new path. Widening the currentRenderRoot window over renderToNodes is inert beyond its purpose, since boundaryOwnerOf is its only reader and is idempotent on a resolved host.

The audit in the body checks out: renderToNodes has exactly two callers, and every unwrapped applyChildInner caller is render-reachable. The doc corrections are accurate, including the one that matters most, that a nested throw does NOT stop the stream, which holds even when the component overrides renderError(), because clearInstance does not tear down child parts and so a boundary re-render leaves the stream state intact.

No third doc surface was left stale, and the new prose does not trip invariant 11.

@vivek7405
vivek7405 merged commit cea6574 into main Aug 5, 2026
10 checks passed
@vivek7405
vivek7405 deleted the fix/async-stream-error-boundary branch August 5, 2026 13:45
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.

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

1 participant