Skip to content

[SPIKE] {{#try}}/{{else catch}} template error boundaries - #21548

Draft
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:mainfrom
NullVoxPopuli-ai-agent:prototype-try-catch
Draft

[SPIKE] {{#try}}/{{else catch}} template error boundaries#21548
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:mainfrom
NullVoxPopuli-ai-agent:prototype-try-catch

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

Summary

Prototype of a {{#try}} block keyword that acts as a template error boundary: JavaScript errors thrown while rendering the block — during initial render or during updates — render a catch branch instead of wedging the renderer.

{{#try}}
  {{this.mightThrow}}
{{else catch as |error|}}
  caught: {{error.message}}
{{/try}}

No parser changes needed: handlebars' chained-else grammar already parses {{else catch as |error|}} as an inverse invoking catch with block params, closed by {{/try}}. The try keyword unwraps it during normalization. A plain {{else}} works as a catch branch that ignores the error, and a standalone {{#catch}} is a syntax error.

How it works

The region compiles like the other Replayable constructs — a single resettable block whose TryOpcode-style closure can re-render it from scratch — plus three new opcodes:

  • EnterTry creates a TryErrorOpcode, a TryOpcode marked as an error boundary.
  • PushTryFrame snapshots the machine/syscall registers, the six VM stacks, the tree builder's open cursors/blocks and the region's DOM position, and the open tracking-frame depth, and records the CATCH address. It sits after EnterTry, so closure re-renders (which resume at the pc captured by EnterTry) re-arm the handler.
  • PopTryFrame disarms the handler when the try branch completes.

Append-time errors: the execute loop catches, rolls every piece of snapshotted state back, destroys the partial content's destroyables, removes its DOM, pushes a reference to the caught error, and resumes at CATCH. DOM removal uses real node positions captured in the frame rather than block bounds — partially-appended content contains inner blocks that are still empty, whose bounds can't be queried.

Update-time errors: UpdatingVM catches a throwing updating opcode, unwinds its frame stack to the nearest TryErrorOpcode, and re-renders that region from scratch (the same recovery path structural invalidation already uses). If the error persists, the append-time handler renders the catch branch with the fresh error.

Automatic retry: the unwind captures everything the failed branch consumed and stores it on the boundary, which revalidates those dependencies on each update pass and re-attempts the try branch when one changes. Two supporting changes make that possible: track() now forwards a failed computation's combined tag to its parent tracker (a throw otherwise discards it in the finally), and the try frame holds a wrapper tracking frame open across the try branch so those tags have somewhere to land (PopTryFrame closes it transparently on the happy path).

Testing

  • 12 new integration tests (test/keywords/try-test.ts): initial-render catch, update-time catch, recovery on dependency change, DOM rollback mid-element, nested boundaries, plain-else catch, no-catch swallow, and errors outside any boundary still propagating.
  • Full browser suite passes: 9483 tests, 0 failures.

Known limitations / open questions

  • Modifiers already scheduled on elements the unwind discards still install (on detached elements).
  • Errors thrown inside the catch branch propagate; no re-entrancy guard.
  • An update-time error outside any enclosing cache group may not self-schedule the retry render.
  • Should caught errors also report to onerror? Retry policy, {{catch}} ergonomics, and interaction with SSR/rehydration are RFC territory — this spike is meant to show the VM machinery is tractable.

🤖 Generated with Claude Code

Adds a `try` block keyword that catches JavaScript errors thrown while
rendering its block, both during initial render and during updates:

    {{#try}}
      {{this.mightThrow}}
    {{else catch as |error|}}
      caught: {{error.message}}
    {{/try}}

The syntax comes free from handlebars' chained-else grammar: `{{else
catch as |error|}}` parses as an inverse block invoking `catch` with
block params, and the `try` keyword unwraps it during normalization. A
plain `{{else}}` also works as a catch branch that ignores the error.

The region compiles like other Replayable blocks (one resettable block
+ TryOpcode-style closure), with three new opcodes:

- `EnterTry` creates a `TryErrorOpcode`, a `TryOpcode` marked as an
  error boundary.
- `PushTryFrame` snapshots the machine and syscall registers, the six
  VM stacks, the tree builder's cursors/blocks and DOM position, and
  the open tracking-frame depth, and records the CATCH label.
- `PopTryFrame` disarms the handler when the try branch completes.

If appending the try branch throws, the append loop rolls all of that
state back, destroys the partial content's destroyables, removes its
DOM (by real node positions, since partially-appended inner blocks can
be empty and their bounds unqueryable), pushes a reference to the
caught error, and resumes at CATCH. Updating opcodes that throw unwind
the UpdatingVM's frame stack to the nearest boundary, which re-renders
the region from scratch; the append-time handler renders the catch
branch if the error persists.

Caught regions also retry automatically: `track()` now forwards a
failed computation's combined tag to its parent tracker (a throw
otherwise discards it in the `finally`), and the try frame holds a
wrapper tracking frame open so those dependencies have somewhere to
land. The unwind stores them on the boundary, which revalidates them
on each update pass and re-attempts the try branch when one changes.

Known limitations: modifiers scheduled on discarded elements still
install; errors thrown inside the catch branch propagate; and an
update-time error outside any cache group may not self-schedule the
retry render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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