diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index a11e878d169..5ad81d82e3b 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -114,6 +114,7 @@ export { default as template, templateCacheCounters } from '@glimmer/opcode-compiler/lib/template'; export { default as RootTemplate } from './lib/templates/root'; +export { default as ErrorBoundary } from './lib/components/error-boundary'; export { default as Input } from './lib/components/input'; export { default as LinkTo } from './lib/components/link-to'; export { default as Textarea } from './lib/components/textarea'; diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts index c7e1027ca35..df0199b968f 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts @@ -561,6 +561,7 @@ const CURLY_CAPABILITIES: InternalComponentCapabilities = { wrapped: true, willDestroy: true, hasSubOwner: false, + errorBoundary: false, }; export const CURLY_COMPONENT_MANAGER = /*@__PURE__*/ new CurlyComponentManager(); diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/mount.ts b/packages/@ember/-internals/glimmer/lib/component-managers/mount.ts index 538377dcea2..f74226fa02a 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/mount.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/mount.ts @@ -49,6 +49,7 @@ const CAPABILITIES = { wrapped: false, willDestroy: false, hasSubOwner: true, + errorBoundary: false, }; class MountManager diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/root.ts b/packages/@ember/-internals/glimmer/lib/component-managers/root.ts index abcb5383a05..6954345e93b 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/root.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/root.ts @@ -94,6 +94,7 @@ const ROOT_CAPABILITIES: InternalComponentCapabilities = { wrapped: true, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }; export class RootComponentDefinition implements ComponentDefinition { diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/route-template.ts b/packages/@ember/-internals/glimmer/lib/component-managers/route-template.ts index 881fa87c64d..a552280e1a6 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/route-template.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/route-template.ts @@ -33,6 +33,7 @@ const CAPABILITIES: InternalComponentCapabilities = { wrapped: false, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }; class RouteTemplateManager diff --git a/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts b/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts new file mode 100644 index 00000000000..7e0bb070d97 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts @@ -0,0 +1,103 @@ +import type { + Destroyable, + DynamicScope, + Environment, + InternalComponentCapabilities, + InternalComponentManager, + Nullable, + Owner, + VMArguments, + WithCreateInstance, +} from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference/lib/reference'; +import type { VMArgumentsImpl } from '@glimmer/runtime/lib/vm/arguments'; +import { DEBUG } from '@glimmer/env'; +import { assert } from '@ember/debug'; +import { setComponentTemplate } from '@glimmer/manager/lib/public/template'; +import { setInternalComponentManager } from '@glimmer/manager/lib/internal/api'; +import { createConstRef } from '@glimmer/reference/lib/reference'; +import { ErrorBoundaryStateImpl } from '@glimmer/runtime/lib/component/error-boundary'; + +import type { ErrorBoundaryState } from '@glimmer/runtime/lib/component/error-boundary'; + +import ErrorBoundaryTemplate from '../templates/error-boundary'; + +const CAPABILITIES: InternalComponentCapabilities = { + dynamicLayout: false, + dynamicTag: false, + prepareArgs: false, + // Only needed for the DEBUG check of which blocks were passed. + createArgs: true, + attributeHook: false, + elementHook: false, + createCaller: false, + dynamicScope: false, + updateHook: false, + createInstance: true, + wrapped: false, + willDestroy: false, + hasSubOwner: false, + errorBoundary: true, +}; + +class ErrorBoundaryManager + implements InternalComponentManager, WithCreateInstance +{ + getCapabilities(): InternalComponentCapabilities { + return CAPABILITIES; + } + + create( + _owner: Owner, + _definition: object, + args: Nullable, + _env: Environment, + _dynamicScope: Nullable, + _caller: Nullable, + hasDefaultBlock: boolean + ): ErrorBoundaryState { + if (DEBUG) { + // Checked here rather than in the layout: the layout renders inside the + // boundary, which would catch the assertion and show the <:catch> block. + // Implicit content can't be mixed with named blocks, so a default block + // next to <:try> or <:catch> is always an explicit <:default>. + let blocks = (args as VMArgumentsImpl | null)?.blocks; + let hasNamedBlock = blocks !== undefined && (blocks.has('try') || blocks.has('catch')); + assert( + ' accepts a <:default> block only on its own. Use <:try> with <:catch>.', + !(hasDefaultBlock && hasNamedBlock) + ); + } + + return new ErrorBoundaryStateImpl(); + } + + didCreate(): void {} + didUpdate(): void {} + didRenderLayout(): void {} + didUpdateLayout(): void {} + + getDebugName(): string { + return 'ErrorBoundary'; + } + + getSelf(instance: ErrorBoundaryState): Reference { + return createConstRef(instance, 'this'); + } + + getDestroyable(_instance: ErrorBoundaryState): Nullable { + return null; + } +} + +const MANAGER = new ErrorBoundaryManager(); + +const ErrorBoundary = { + create() { + throw new Error('ErrorBoundary does not support .create(). It is managed internally.'); + }, +}; +setInternalComponentManager(MANAGER, ErrorBoundary); +setComponentTemplate(ErrorBoundaryTemplate, ErrorBoundary); + +export default ErrorBoundary; diff --git a/packages/@ember/-internals/glimmer/lib/components/internal.ts b/packages/@ember/-internals/glimmer/lib/components/internal.ts index cd5571b35dc..6d9a465013a 100644 --- a/packages/@ember/-internals/glimmer/lib/components/internal.ts +++ b/packages/@ember/-internals/glimmer/lib/components/internal.ts @@ -170,6 +170,7 @@ const CAPABILITIES: InternalComponentCapabilities = { wrapped: false, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }; class InternalManager diff --git a/packages/@ember/-internals/glimmer/lib/setup-registry.ts b/packages/@ember/-internals/glimmer/lib/setup-registry.ts index 19e6ac676ef..4a16a36dd98 100644 --- a/packages/@ember/-internals/glimmer/lib/setup-registry.ts +++ b/packages/@ember/-internals/glimmer/lib/setup-registry.ts @@ -2,6 +2,7 @@ import type Registry from '@ember/-internals/container/lib/registry'; import { privatize as P } from '@ember/-internals/container/lib/registry'; import { getOwner } from '@ember/-internals/owner'; import { assert } from '@ember/debug'; +import ErrorBoundary from './components/error-boundary'; import Input from './components/input'; import LinkTo from './components/link-to'; import Textarea from './components/textarea'; @@ -42,6 +43,7 @@ export function setupEngineRegistry(registry: Registry): void { registry.optionsForType('helper', { instantiate: false }); + registry.register('component:error-boundary', ErrorBoundary); registry.register('component:input', Input); registry.register('component:link-to', LinkTo); diff --git a/packages/@ember/-internals/glimmer/lib/templates/error-boundary.ts b/packages/@ember/-internals/glimmer/lib/templates/error-boundary.ts new file mode 100644 index 00000000000..bbd23aa2d85 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/templates/error-boundary.ts @@ -0,0 +1,11 @@ +import { precompileTemplate } from '@ember/template-compilation'; +export default precompileTemplate( + `{{#if this.hasError}}{{yield this.error this.retry to="catch"}}{{else if (has-block "try")}}{{yield to="try"}}{{else}}{{yield}}{{/if}}`, + { + moduleName: 'packages/@ember/-internals/glimmer/lib/templates/error-boundary.hbs', + strictMode: true, + scope() { + return {}; + }, + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-hardening-test.ts b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-hardening-test.ts new file mode 100644 index 00000000000..baf74040ea5 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-hardening-test.ts @@ -0,0 +1,1030 @@ +import { + AbstractStrictTestCase, + assertHTML, + buildOwner, + clickElement, + defineSimpleHelper, + moduleFor, + runDestroy, + runTask, +} from 'internal-test-helpers'; +import { precompileTemplate } from '@ember/template-compilation'; +import templateOnly from '@ember/component/template-only'; +import { ErrorBoundary, setComponentManager } from '@ember/component'; +import { on, renderComponent as renderGlimmerComponent, renderSync } from '@glimmer/runtime'; +import { tracked } from '@glimmer/tracking'; +import { isInErrorBoundary } from '@glimmer/validator/lib/tracking'; +import { associateDestroyableChild, registerDestructor } from '@glimmer/destroyable'; +import { + componentCapabilities, + modifierCapabilities, + setModifierManager, + setComponentTemplate, +} from '@glimmer/manager'; +import { run } from '@ember/runloop'; +import type Owner from '@ember/owner'; +import type { Arguments, SimpleElement } from '@glimmer/interfaces'; +import { + BaseRenderer, + renderComponent, + setRenderer, + type RenderResult, +} from '../../../lib/renderer'; +import GlimmerishComponent from '../../utils/glimmerish-component'; + +interface LifecycleCounts { + created: number; + installed: number; + updated: number; + destroyed: number; +} + +/** Observe component creation, deferred lifecycle work, and destruction separately. */ +function lifecycleComponent(counts: LifecycleCounts) { + class Component { + constructor(public args: Record) { + counts.created++; + } + } + + setComponentManager( + () => ({ + capabilities: componentCapabilities('3.13', { + destructor: true, + asyncLifecycleCallbacks: true, + updateHook: true, + }), + createComponent(_factory: unknown, args: Arguments) { + return new Component(args.named); + }, + getContext(component: Component) { + return component; + }, + updateComponent() {}, + didCreateComponent() { + counts.installed++; + }, + didUpdateComponent() { + counts.updated++; + }, + destroyComponent() { + counts.destroyed++; + }, + }), + Component + ); + + return setComponentTemplate(precompileTemplate('{{@label}}'), Component); +} + +/** Count modifier instances even when an attempt aborts before installation. */ +function lifecycleModifier(counts: LifecycleCounts) { + return setModifierManager( + () => ({ + capabilities: modifierCapabilities('3.22'), + createModifier() { + counts.created++; + return {}; + }, + installModifier() { + counts.installed++; + }, + updateModifier() { + counts.updated++; + }, + destroyModifier() { + counts.destroyed++; + }, + }), + {} + ); +} + +const Throwing = setComponentTemplate( + precompileTemplate('{{this.boom}}'), + class extends GlimmerishComponent { + get boom(): never { + throw new Error('render error'); + } + } +); + +const MaybeThrow = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + if (this.args.shouldThrow) { + throw new Error('conditional error'); + } + return 'ok'; + } + } +); + +class ErrorBoundaryHardeningTestCase extends AbstractStrictTestCase { + declare component: (RenderResult & { rerender: () => void }) | undefined; + owner: Owner; + + constructor(assert: QUnit['assert']) { + super(assert); + this.owner = buildOwner({}); + associateDestroyableChild(this, this.owner); + } + + get element() { + return document.querySelector('#qunit-fixture')!; + } + + assertChange({ change, expect }: { change: () => void; expect: string }) { + runTask(() => change()); + assertHTML(expect); + this.assertStableRerender(); + } + + renderComponent(component: object, options: { args?: Record; expect: string }) { + let { owner } = this; + + run(() => { + const result = renderComponent(component, { + owner, + args: options.args ?? {}, + env: { document: document, isInteractive: true, hasDOM: true }, + into: this.element, + }); + this.component = { + ...result, + rerender() { + // unused, but asserted against + }, + }; + registerDestructor(this, () => result.destroy()); + }); + + assertHTML(options.expect); + this.assertStableRerender(); + } +} + +moduleFor( + 'ErrorBoundary [error-boundary-hardening] — abort cleanup and lifecycle', + class extends ErrorBoundaryHardeningTestCase { + '@test baseline-green guard: prepend then throw clears the entire attempted range and preserves siblings'() { + this.assertListAbort(['x', 'bomb', 'a', 'b', 'c']); + } + + '@test baseline-green guard: middle row insert then throw clears temporary list markers and preserves siblings'() { + this.assertListAbort(['a', 'x', 'bomb', 'b', 'c']); + } + + '@test baseline-green guard: reorder then throw clears moved and inserted content and preserves siblings'() { + this.assertListAbort(['c', 'x', 'b', 'bomb', 'a']); + } + + /** Exercise mounted list cleanup with successful insertions before a failing item. */ + assertListAbort(failedItems: string[]) { + class State { + @tracked items = ['a', 'b', 'c']; + @tracked label = 'before'; + } + let state = new State(); + let item = defineSimpleHelper((value: unknown) => { + if (value === 'bomb') throw new Error('bomb'); + return value; + }); + let Root = setComponentTemplate( + precompileTemplate( + '{{state.label}}<:try>prefix{{#each state.items key="@identity" as |value|}}{{item value}}{{/each}}suffix<:catch as |error|>caught {{error.message}}{{state.label}}', + { strictMode: true, scope: () => ({ ErrorBoundary, state, item }) } + ), + templateOnly() + ); + let body = (items: string[]) => + `prefix${items.map((value) => `${value}`).join('')}suffix`; + this.renderComponent(Root, { expect: `before${body(state.items)}before` }); + for (let cycle = 1; cycle <= 3; cycle++) { + this.assertChange({ + change: () => (state.items = [...failedItems]), + expect: `${state.label}caught bomb${state.label}`, + }); + this.assertChange({ + change: () => (state.label = `cycle ${cycle}`), + expect: `cycle ${cycle}caught bombcycle ${cycle}`, + }); + let recovered = [`x${cycle}`, `y${cycle}`, `z${cycle}`]; + this.assertChange({ + change: () => (state.items = recovered), + expect: `${state.label}${body(recovered)}${state.label}`, + }); + } + } + + '@test aborted initial attempt cancels modifier install and component didCreate work'( + assert: Assert + ) { + let components = { created: 0, installed: 0, updated: 0, destroyed: 0 }; + let modifiers = { created: 0, installed: 0, updated: 0, destroyed: 0 }; + let Child = lifecycleComponent(components); + let modifier = lifecycleModifier(modifiers); + let Root = setComponentTemplate( + precompileTemplate( + '<:try>
partial
<:catch>caught
', + { + strictMode: true, + scope: () => ({ ErrorBoundary, Child, modifier, Throwing }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'caught' }); + assert.deepEqual( + components, + { created: 1, installed: 0, updated: 0, destroyed: 1 }, + `components lifecycle: ${JSON.stringify(components)}` + ); + assert.deepEqual( + modifiers, + { created: 1, installed: 0, updated: 0, destroyed: 1 }, + `modifiers lifecycle: ${JSON.stringify(modifiers)}` + ); + runTask(() => this.component!.destroy()); + assert.strictEqual(components.destroyed, 1, 'no duplicate destruction at root teardown'); + assert.strictEqual(modifiers.destroyed, 1, 'no duplicate destruction at root teardown'); + } + + '@test modifier on an open element is destroyed exactly once when its child throws'( + assert: Assert + ) { + let counts = { created: 0, installed: 0, updated: 0, destroyed: 0 }; + let modifier = lifecycleModifier(counts); + let Root = setComponentTemplate( + precompileTemplate( + '<:try>
prefix
<:catch>caught
', + { + strictMode: true, + scope: () => ({ ErrorBoundary, modifier, Throwing }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'caught' }); + assert.deepEqual( + counts, + { created: 1, installed: 0, updated: 0, destroyed: 1 }, + `counts lifecycle: ${JSON.stringify(counts)}` + ); + runTask(() => this.component!.destroy()); + assert.strictEqual(counts.destroyed, 1); + } + + '@test modifier created before another modifier throws on create is destroyed exactly once'( + assert: Assert + ) { + let counts = { created: 0, installed: 0, updated: 0, destroyed: 0 }; + let modifier = lifecycleModifier(counts); + let creates = 0; + let throwing = setModifierManager( + () => ({ + capabilities: modifierCapabilities('3.22'), + createModifier(): never { + creates++; + throw new Error('modifier create'); + }, + installModifier() {}, + updateModifier() {}, + destroyModifier() {}, + }), + {} + ); + let Root = setComponentTemplate( + precompileTemplate( + '<:try>
partial
<:catch as |error|>{{error.message}}
', + { + strictMode: true, + scope: () => ({ ErrorBoundary, modifier, throwing }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'modifier create' }); + assert.strictEqual(creates, 1); + assert.deepEqual( + counts, + { created: 1, installed: 0, updated: 0, destroyed: 1 }, + `counts lifecycle: ${JSON.stringify(counts)}` + ); + runTask(() => this.component!.destroy()); + assert.strictEqual(counts.destroyed, 1); + } + + '@test aborted retry attempts destroy each new component and modifier once'(assert: Assert) { + let components = { created: 0, installed: 0, updated: 0, destroyed: 0 }; + let modifiers = { created: 0, installed: 0, updated: 0, destroyed: 0 }; + let Child = lifecycleComponent(components); + let modifier = lifecycleModifier(modifiers); + let failing = true; + let boom = defineSimpleHelper(() => { + if (failing) throw new Error('retry error'); + return 'ok'; + }); + let Root = setComponentTemplate( + precompileTemplate( + '<:try>
child
{{boom}}<:catch as |error retry|>{{error.message}}
', + { + strictMode: true, + scope: () => ({ ErrorBoundary, Child, modifier, boom, on }), + } + ), + templateOnly() + ); + let fallback = 'retry error'; + this.renderComponent(Root, { expect: fallback }); + for (let attempt = 1; attempt <= 3; attempt++) { + let expected = { created: attempt, installed: 0, updated: 0, destroyed: attempt }; + assert.deepEqual( + components, + expected, + `components lifecycle: ${JSON.stringify(components)}` + ); + assert.deepEqual(modifiers, expected, `modifiers lifecycle: ${JSON.stringify(modifiers)}`); + if (attempt < 3) + this.assertChange({ change: () => clickElement('button'), expect: fallback }); + } + failing = false; + this.assertChange({ + change: () => clickElement('button'), + expect: 'child
child
ok', + }); + runTask(() => this.component!.destroy()); + assert.deepEqual( + components, + { created: 4, installed: 1, updated: 0, destroyed: 4 }, + `components lifecycle: ${JSON.stringify(components)}` + ); + assert.deepEqual( + modifiers, + { created: 4, installed: 1, updated: 0, destroyed: 4 }, + `modifiers lifecycle: ${JSON.stringify(modifiers)}` + ); + } + + '@test abort preserves lifecycle work of an independently rendered root'(assert: Assert) { + let independentComponents = { created: 0, installed: 0, updated: 0, destroyed: 0 }; + let independentModifiers = { created: 0, installed: 0, updated: 0, destroyed: 0 }; + let aborted = { created: 0, installed: 0, updated: 0, destroyed: 0 }; + let Child = lifecycleComponent(independentComponents); + let modifier = lifecycleModifier(independentModifiers); + let Aborted = lifecycleComponent(aborted); + let Independent = setComponentTemplate( + precompileTemplate('
independent
', { + strictMode: true, + scope: () => ({ Child, modifier }), + }), + templateOnly() + ); + let target = document.createElement('div'); + let renderer = BaseRenderer.strict(this.owner, document, { + isInteractive: true, + hasDOM: true, + }); + associateDestroyableChild(this, renderer); + setRenderer(this.owner, renderer); + let renderedDuringAttempt = ''; + let renderIndependent = defineSimpleHelper(() => { + // Bypass Ember's root queue so this independent VM actually executes + // inside the failing attempt's transaction, before the following throw. + let { state } = renderer; + let result = renderSync( + state.env, + renderGlimmerComponent( + state.context, + state.builder(state.env, { + element: target as unknown as SimpleElement, + nextSibling: null, + }), + this.owner, + Independent + ) + ); + associateDestroyableChild(this, result); + renderedDuringAttempt = target.innerHTML; + return ''; + }); + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{renderIndependent}}<:catch>caught', + { + strictMode: true, + scope: () => ({ ErrorBoundary, Aborted, renderIndependent, Throwing }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'caught' }); + assert.strictEqual(renderedDuringAttempt, 'independent
independent
'); + assert.strictEqual(target.innerHTML, 'independent
independent
'); + assert.deepEqual( + aborted, + { created: 1, installed: 0, updated: 0, destroyed: 1 }, + `aborted lifecycle: ${JSON.stringify(aborted)}` + ); + assert.deepEqual(independentComponents, { + created: 1, + installed: 1, + updated: 0, + destroyed: 0, + }); + assert.deepEqual(independentModifiers, { + created: 1, + installed: 1, + updated: 0, + destroyed: 0, + }); + runDestroy(this); + assert.strictEqual(independentComponents.destroyed, 1); + assert.strictEqual(independentModifiers.destroyed, 1); + } + + '@test remote prefix is removed synchronously before fallback uses the same destination'( + assert: Assert + ) { + class State { + @tracked failing = true; + } + let state = new State(); + let remote = document.createElement('div'); + remote.innerHTML = 'existing'; + let getRemote = defineSimpleHelper(() => remote); + let observed: string[] = []; + let inspect = defineSimpleHelper(() => { + observed.push(remote.innerHTML); + return ''; + }); + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{#in-element (getRemote) insertBefore=null}}body prefix{{/in-element}}<:catch>{{inspect}}{{#in-element (getRemote) insertBefore=null}}fallback{{/in-element}}', + { + strictMode: true, + scope: () => ({ ErrorBoundary, getRemote, state, MaybeThrow, inspect }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: '' }); + assert.deepEqual( + observed, + ['existing'], + `abort cleanup precedes fallback execution: ${JSON.stringify(observed)}` + ); + assert.strictEqual( + remote.innerHTML, + 'existingfallback', + 'deferred destruction preserves fallback' + ); + for (let cycle = 1; cycle <= 3; cycle++) { + this.assertChange({ change: () => (state.failing = false), expect: '' }); + assert.strictEqual(remote.innerHTML, 'existingbody prefixok'); + this.assertChange({ change: () => (state.failing = true), expect: '' }); + assert.strictEqual(remote.innerHTML, 'existingfallback'); + assert.strictEqual(observed.length, cycle + 1); + assert.strictEqual(observed[cycle], 'existing'); + } + runTask(() => this.component!.destroy()); + assert.strictEqual(remote.innerHTML, 'existing'); + } + } +); + +moduleFor( + 'ErrorBoundary [error-boundary-hardening] — inserted each row', + class extends ErrorBoundaryHardeningTestCase { + '@test baseline-green guard: boundary inside each rows isolates failures through insertion and reordering'() { + class Row { + @tracked failing = false; + constructor(public id: string) {} + } + class State { + @tracked rows = [new Row('a'), new Row('b'), new Row('c')]; + } + let state = new State(); + let [a, b, c] = state.rows as [Row, Row, Row]; + let Root = setComponentTemplate( + precompileTemplate( + '
    {{#each state.rows key="id" as |row|}}
  • {{row.id}}:<:try><:catch as |error|>{{error.message}}
  • {{/each}}
', + { strictMode: true, scope: () => ({ ErrorBoundary, state, MaybeThrow }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: '
  • a:ok
  • b:ok
  • c:ok
' }); + this.assertChange({ + change: () => (b.failing = true), + expect: '
  • a:ok
  • b:conditional error
  • c:ok
', + }); + let d = new Row('d'); + d.failing = true; + this.assertChange({ + change: () => (state.rows = [c, d, b, a]), + expect: + '
  • c:ok
  • d:conditional error
  • b:conditional error
  • a:ok
', + }); + this.assertChange({ + change: () => (b.failing = false), + expect: '
  • c:ok
  • d:conditional error
  • b:ok
  • a:ok
', + }); + this.assertChange({ + change: () => (state.rows = [b, a]), + expect: '
  • b:ok
  • a:ok
', + }); + } + } +); + +moduleFor( + 'ErrorBoundary [error-boundary-hardening] — remote content and independent roots', + class extends ErrorBoundaryHardeningTestCase { + '@test baseline-green guard: abort preserves a later independent remote block in the same destination'( + assert: Assert + ) { + class State { + @tracked failing = false; + } + let state = new State(); + let remote = document.createElement('div'); + let boom = defineSimpleHelper((failing: unknown) => { + if (failing) throw new Error('remote failure'); + return 'ok'; + }); + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{#in-element remote insertBefore=null}}owned{{/in-element}}{{boom state.failing}}<:catch>caught{{#in-element remote insertBefore=null}}independent{{/in-element}}', + { strictMode: true, scope: () => ({ ErrorBoundary, remote, boom, state }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'ok' }); + assert.strictEqual(remote.innerHTML, 'ownedindependent'); + let independent = remote.querySelector('i'); + this.assertChange({ change: () => (state.failing = true), expect: 'caught' }); + assert.strictEqual(remote.innerHTML, 'independent', 'only owned content is removed'); + assert.strictEqual(remote.querySelector('i'), independent, 'independent node survives'); + } + + '@test baseline-green guard: abort preserves remote content of a synchronously rendered independent root'( + assert: Assert + ) { + let remote = document.createElement('div'); + let target = document.createElement('div'); + let Independent = setComponentTemplate( + precompileTemplate( + '{{#in-element remote insertBefore=null}}independent{{/in-element}}', + { + strictMode: true, + scope: () => ({ remote }), + } + ), + templateOnly() + ); + let renderer = BaseRenderer.strict(this.owner, document, { + isInteractive: true, + hasDOM: true, + }); + associateDestroyableChild(this, renderer); + setRenderer(this.owner, renderer); + let independent: Element | null = null; + let renderIndependent = defineSimpleHelper(() => { + let { state } = renderer; + let result = renderSync( + state.env, + renderGlimmerComponent( + state.context, + state.builder(state.env, { + element: target as unknown as SimpleElement, + nextSibling: null, + }), + this.owner, + Independent + ) + ); + associateDestroyableChild(this, result); + independent = remote.querySelector('b'); + return ''; + }); + let boom = defineSimpleHelper(() => { + throw new Error('outer attempt'); + }); + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{renderIndependent}}{{boom}}<:catch>caught', + { + strictMode: true, + scope: () => ({ ErrorBoundary, renderIndependent, boom }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'caught' }); + assert.ok(independent, 'independent root rendered before the abort'); + assert.strictEqual(remote.innerHTML, 'independent', 'independent remote survives'); + assert.strictEqual(remote.querySelector('b'), independent, 'same independent node'); + } + + '@test abort clears an outer remote tail after a nested default remote detaches its prefix'( + assert: Assert + ) { + class State { + @tracked failing = false; + } + let state = new State(); + let remote = document.createElement('div'); + let boom = defineSimpleHelper((failing: unknown) => { + if (failing) throw new Error('detached remote prefix failure'); + return 'ok'; + }); + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{#in-element remote insertBefore=null}}prefix{{#in-element remote}}inner{{/in-element}}tail{{/in-element}}{{boom state.failing}}<:catch>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, remote, boom, state }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'ok' }); + assert.strictEqual( + remote.innerHTML, + 'innertail', + 'the nested default remote detached the outer prefix before the update' + ); + + this.assertChange({ change: () => (state.failing = true), expect: 'caught' }); + assert.strictEqual(remote.innerHTML, '', 'abort removes all try body remote content'); + + runTask(() => this.component!.destroy()); + assert.strictEqual( + remote.innerHTML, + '', + 'the destination remains empty after component destruction' + ); + } + + '@test baseline-green guard: abort of nested remote blocks preserves later independent content'( + assert: Assert + ) { + class State { + @tracked failing = false; + } + let state = new State(); + let remote = document.createElement('div'); + let boom = defineSimpleHelper((failing: unknown) => { + if (failing) throw new Error('nested remote failure'); + return 'ok'; + }); + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{#in-element remote insertBefore=null}}outer{{#in-element remote insertBefore=null}}inner{{/in-element}}{{/in-element}}{{boom state.failing}}<:catch>caught{{#in-element remote insertBefore=null}}independent{{/in-element}}', + { strictMode: true, scope: () => ({ ErrorBoundary, remote, boom, state }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'ok' }); + assert.strictEqual(remote.innerHTML, 'outerinnerindependent'); + let independent = remote.querySelector('i'); + this.assertChange({ change: () => (state.failing = true), expect: 'caught' }); + assert.strictEqual(remote.innerHTML, 'independent', 'independent content survives'); + assert.strictEqual( + remote.querySelector('i'), + independent, + 'the same independent node survives' + ); + } + } +); + +moduleFor( + 'ErrorBoundary [error-boundary-hardening] review — fallback failures', + class extends ErrorBoundaryHardeningTestCase { + '@test an inner fallback throwing during update reaches the outer boundary'(assert: Assert) { + class State { + @tracked failing = false; + } + let state = new State(); + let counts = { created: 0, installed: 0, updated: 0, destroyed: 0 }; + let Child = lifecycleComponent(counts); + let fallbackError = new Error('fallback failure'); + let boom = defineSimpleHelper(() => { + throw fallbackError; + }); + let Root = setComponentTemplate( + precompileTemplate( + 'before<:try><:try><:catch>{{boom}}<:catch as |error|>outer caught {{error.message}}after', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, Child, boom }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'beforeokafter' }); + let escaped: unknown; + try { + runTask(() => (state.failing = true)); + } catch (error) { + escaped = error; + } + assert.strictEqual(escaped, undefined, 'the outer boundary handles the fallback error'); + assertHTML('beforeouter caught fallback failureafter'); + assert.deepEqual(counts, { created: 1, installed: 0, updated: 0, destroyed: 1 }); + assert.false(isInErrorBoundary(), 'abandoned child frames balance the validator depth'); + } + + '@test an uncaught initial fallback aborts its queued lifecycle work'(assert: Assert) { + let components = { created: 0, installed: 0, updated: 0, destroyed: 0 }; + let modifiers = { created: 0, installed: 0, updated: 0, destroyed: 0 }; + let Child = lifecycleComponent(components); + let modifier = lifecycleModifier(modifiers); + let fallbackError = new Error('uncaught fallback'); + let boom = defineSimpleHelper(() => { + throw fallbackError; + }); + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch>
aborted
{{boom}}
', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, Child, modifier, boom }) } + ), + templateOnly() + ); + let renderer = BaseRenderer.strict(this.owner, document, { + isInteractive: true, + hasDOM: true, + }); + associateDestroyableChild(this, renderer); + let target = document.createElement('div'); + let escaped: unknown; + runTask(() => { + let { state } = renderer; + try { + renderSync( + state.env, + renderGlimmerComponent( + state.context, + state.builder(state.env, { + element: target as unknown as SimpleElement, + nextSibling: null, + }), + this.owner, + Root + ) + ); + } catch (error) { + escaped = error; + } + }); + assert.strictEqual(escaped, fallbackError, 'the original fallback error escapes'); + assert.deepEqual( + components, + { created: 1, installed: 0, updated: 0, destroyed: 1 }, + `an aborted fallback never delivers didCreate: ${JSON.stringify(components)}` + ); + assert.deepEqual( + modifiers, + { created: 1, installed: 0, updated: 0, destroyed: 1 }, + `an aborted fallback never installs its modifier: ${JSON.stringify(modifiers)}` + ); + assert.false(isInErrorBoundary(), 'the throwing guarded op balances the validator depth'); + } + + '@test a mounted fallback does not catch its own update error'(assert: Assert) { + class State { + @tracked failing = false; + } + let state = new State(); + let failures = 0; + let fallback = defineSimpleHelper((failing: unknown) => { + // A second invocation succeeds, exposing accidental self-recovery. + if (failing && failures++ === 0) throw new Error('fallback update'); + return 'inner fallback'; + }); + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:try><:catch>{{fallback state.failing}}<:catch as |error|>outer caught {{error.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, fallback, state }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'inner fallback' }); + this.assertChange({ + change: () => (state.failing = true), + expect: 'outer caught fallback update', + }); + assert.strictEqual( + failures, + 1, + 'the failing fallback is not invoked again by its own boundary' + ); + assert.false(isInErrorBoundary(), 'fallback propagation balances the validator depth'); + } + + '@test an uncaught initial fallback clears a partial remote before destruction'( + assert: Assert + ) { + let remote = document.createElement('div'); + remote.innerHTML = 'independent'; + let independent = remote.firstChild; + let fallbackError = new Error('remote fallback failure'); + let boom = defineSimpleHelper(() => { + throw fallbackError; + }); + let Failing = setComponentTemplate( + precompileTemplate('{{boom}}', { strictMode: true, scope: () => ({ boom }) }), + templateOnly() + ); + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch>{{#in-element remote insertBefore=null}}aborted prefix{{/in-element}}', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, remote, Failing }) } + ), + templateOnly() + ); + let renderer = BaseRenderer.strict(this.owner, document, { + isInteractive: true, + hasDOM: true, + }); + associateDestroyableChild(this, renderer); + let target = document.createElement('div'); + let escaped: unknown; + let destructionError: unknown; + let remoteAtThrow = ''; + try { + runTask(() => { + let { state } = renderer; + try { + renderSync( + state.env, + renderGlimmerComponent( + state.context, + state.builder(state.env, { + element: target as unknown as SimpleElement, + nextSibling: null, + }), + this.owner, + Root + ) + ); + } catch (error) { + escaped = error; + remoteAtThrow = remote.innerHTML; + } + }); + } catch (error) { + destructionError = error; + } + assert.strictEqual(escaped, fallbackError, 'the original fallback error escapes'); + assert.strictEqual( + remoteAtThrow, + 'independent', + 'the failed fallback clears its remote prefix synchronously' + ); + assert.strictEqual( + destructionError, + undefined, + `deferred remote destruction does not throw: ${String(destructionError)}` + ); + assert.strictEqual( + remote.innerHTML, + 'independent', + 'unrelated remote content survives' + ); + assert.strictEqual(remote.firstChild, independent, 'the unrelated node retains its identity'); + assert.false(isInErrorBoundary(), 'the failed fallback exits its boundary'); + } + } +); + +moduleFor( + 'ErrorBoundary [error-boundary-hardening] review 2 — propagated fallback dependencies', + class extends ErrorBoundaryHardeningTestCase { + '@test outer recovery retains dependencies of a fallback failing in handleCaughtError'() { + this.assertFallbackDependencyRecovery(false); + } + + '@test outer recovery retains dependencies of a fallback failing in handleException'() { + this.assertFallbackDependencyRecovery(true); + } + + /** Repair only a dependency read by the failed fallback, leaving the body broken. */ + assertFallbackDependencyRecovery(automaticRetry: boolean) { + class State { + @tracked phase = automaticRetry ? 1 : 0; + @tracked fallbackFails = false; + } + let state = new State(); + let body = defineSimpleHelper((phase: unknown) => { + if (phase) throw new Error('body failure'); + return 'body'; + }); + let boom = defineSimpleHelper(() => { + throw new Error('fallback failure'); + }); + let Fallback = setComponentTemplate( + precompileTemplate( + 'prefix{{#if state.fallbackFails}}{{boom}}{{else}}inner recovered{{/if}}', + { strictMode: true, scope: () => ({ state, boom }) } + ), + templateOnly() + ); + let Root = setComponentTemplate( + precompileTemplate( + 'before<:try><:try>{{body state.phase}}<:catch><:catch as |error|>outer caught {{error.message}}after', + { strictMode: true, scope: () => ({ ErrorBoundary, state, body, Fallback }) } + ), + templateOnly() + ); + this.renderComponent(Root, { + expect: automaticRetry + ? 'beforeprefixinner recoveredafter' + : 'beforebodyafter', + }); + this.assertChange({ + change: () => { + state.phase = automaticRetry ? 2 : 1; + state.fallbackFails = true; + }, + expect: 'beforeouter caught fallback failureafter', + }); + this.assert.false(isInErrorBoundary(), 'propagation balances boundary depth'); + this.assertChange({ + change: () => (state.fallbackFails = false), + expect: 'beforeprefixinner recoveredafter', + }); + this.assertChange({ + change: () => (state.phase = 3), + expect: 'beforeprefixinner recoveredafter', + }); + this.assert.false(isInErrorBoundary(), 'subsequent recovery balances boundary depth'); + } + } +); + +moduleFor( + 'ErrorBoundary [error-boundary-hardening] review 3 — initial failure in inserted each rows', + class extends ErrorBoundaryHardeningTestCase { + '@test a bad row inserted in the middle clears its initial partial DOM and preserves siblings'() { + this.assertInsertedRowFailure(['alpha', 'bad #1', 'beta', 'gamma']); + } + + '@test a bad row prepended clears its initial partial DOM and preserves siblings'() { + this.assertInsertedRowFailure(['bad #1', 'alpha', 'beta', 'gamma']); + } + + '@test a reorder inserting a bad row clears its initial partial DOM and preserves siblings'() { + this.assertInsertedRowFailure(['gamma', 'bad #1', 'beta', 'alpha']); + } + + /** Insert a failing boundary at the list cursor, sharing a parent with existing rows. */ + assertInsertedRowFailure(rows: string[]) { + class State { + @tracked rows = ['alpha', 'beta', 'gamma']; + } + let state = new State(); + let RowItem = setComponentTemplate( + precompileTemplate('{{this.label}}'), + class extends GlimmerishComponent { + get label() { + let row = this.args.row as string; + if (row.startsWith('bad')) { + throw new Error(`${row} failed to render`); + } + return row; + } + } + ); + let Root = setComponentTemplate( + precompileTemplate( + '{{#each state.rows key="@identity" as |row|}}<:try><:catch as |err|>{{err.message}}{{/each}}', + { strictMode: true, scope: () => ({ ErrorBoundary, state, RowItem }) } + ), + templateOnly() + ); + this.renderComponent(Root, { + expect: 'alphabetagamma', + }); + let siblings = Array.from(this.element.querySelectorAll('span')); + + runTask(() => (state.rows = rows)); + + let expected = rows + .map((row) => + row === 'bad #1' ? 'bad #1 failed to render' : `${row}` + ) + .join(''); + this.assert.strictEqual( + this.element.innerHTML, + expected, + 'the inserted row renders only its fallback, with no partial nodes and all siblings intact' + ); + let survivingSiblings = Array.from(this.element.querySelectorAll('span')); + for (let sibling of siblings) { + this.assert.strictEqual( + survivingSiblings.find((node) => node.textContent === sibling.textContent), + sibling, + `${sibling.textContent} retains its original DOM node` + ); + } + this.assertStableRerender(); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts new file mode 100644 index 00000000000..ed1650117eb --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -0,0 +1,1493 @@ +import { + AbstractStrictTestCase, + assertHTML, + buildOwner, + clickElement, + defineSimpleHelper, + defineSimpleModifier, + moduleFor, + runDestroy, +} from 'internal-test-helpers'; + +import { DEBUG } from '@glimmer/env'; +import { precompileTemplate } from '@ember/template-compilation'; +import templateOnly from '@ember/component/template-only'; +import { ErrorBoundary, setComponentManager } from '@ember/component'; +import { array, on } from '@glimmer/runtime'; +import { tracked } from '@glimmer/tracking'; +import GlimmerishComponent from '../../utils/glimmerish-component'; + +import { run } from '@ember/runloop'; +import { associateDestroyableChild, destroy, registerDestructor } from '@glimmer/destroyable'; +import { componentCapabilities, setComponentTemplate } from '@glimmer/manager'; +import { renderComponent, type RenderResult } from '../../../lib/renderer'; +import type Owner from '@ember/owner'; +import { setOwner } from '@ember/-internals/owner'; + +// --- Tracked state helpers --- +// Declared at module level to avoid TS1206 "Decorators are not valid here" +// which occurs with decorators inside anonymous class expressions. + +class ThrowOnlyState { + @tracked shouldThrow = false; +} +class SiblingState { + @tracked label = 'before'; +} +class ConditionalState { + @tracked show = true; +} +class InnerState { + @tracked value = 'hello'; +} + +// --- Test helper components --- + +const Throwing = setComponentTemplate( + precompileTemplate('{{this.boom}}'), + class extends GlimmerishComponent { + get boom(): never { + throw new Error('render error'); + } + } +); + +const MaybeThrow = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + if ((this as any).args.shouldThrow) { + throw new Error('conditional error'); + } + return 'ok'; + } + } +); + +// --- Test case base class --- + +class ErrorBoundaryTestCase extends AbstractStrictTestCase { + declare component: (RenderResult & { rerender: () => void }) | undefined; + owner: Owner; + + constructor(assert: QUnit['assert']) { + super(assert); + this.owner = buildOwner({}); + associateDestroyableChild(this, this.owner); + } + + get element() { + return document.querySelector('#qunit-fixture')!; + } + + assertChange({ change, expect }: { change: () => void; expect: string }) { + run(() => change()); + assertHTML(expect); + this.assertStableRerender(); + } + + renderComponent(component: object, options: { args?: Record; expect: string }) { + let { owner } = this; + + run(() => { + const result = renderComponent(component, { + owner, + args: options.args ?? {}, + env: { document: document, isInteractive: true, hasDOM: true }, + into: this.element, + }); + this.component = { + ...result, + rerender() { + // unused, but asserted against + }, + }; + registerDestructor(this, () => result.destroy()); + }); + + assertHTML(options.expect); + this.assertStableRerender(); + } + + /** Render `Root` and expect the DEBUG assertion about block combinations. */ + assertBlocksAssertion(Root: object) { + (window as any).expectAssertion(() => { + run(() => + renderComponent(Root, { + owner: this.owner, + env: { document: document, isInteractive: true, hasDOM: true }, + into: this.element, + }) + ); + }, / accepts a <:default> block only on its own\. Use <:try> with <:catch>\./); + } +} + +// --- Tests --- + +moduleFor( + 'ErrorBoundary', + class extends ErrorBoundaryTestCase { + afterEach() { + runDestroy(this); + } + + '@test renders default block when no error'() { + let Root = setComponentTemplate( + precompileTemplate( + '<:try>hello<:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hello' }); + } + + // --- block names --- + + '@test renders the <:try> block when no error'() { + let Root = setComponentTemplate( + precompileTemplate( + '<:try>hello<:catch>caught', + { strictMode: true, scope: () => ({ ErrorBoundary }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hello' }); + } + + '@test renders the <:catch> block with error and retry when <:try> throws'() { + class State { + @tracked shouldThrow = true; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err retry|>caught: {{err.message}} ', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: conditional error ' }); + + this.assertChange({ + change: () => clickElement('button'), + expect: 'caught: conditional error ', + }); + + this.assertChange({ + change: () => (state.shouldThrow = false), + expect: 'ok', + }); + } + + '@test <:catch> can come before <:try>'() { + let Root = setComponentTemplate( + precompileTemplate( + '<:catch as |err|>caught: {{err.message}}<:try>', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: render error' }); + } + + '@test asserts when given both <:try> and <:default>'() { + this.assertBlocksAssertion( + setComponentTemplate( + precompileTemplate( + '<:try>a<:default>b', + { + strictMode: true, + scope: () => ({ ErrorBoundary }), + } + ), + templateOnly() + ) + ); + } + + '@test asserts when <:catch> is paired with <:default> instead of <:try>'() { + this.assertBlocksAssertion( + setComponentTemplate( + precompileTemplate( + '<:default>a<:catch>b', + { + strictMode: true, + scope: () => ({ ErrorBoundary }), + } + ), + templateOnly() + ) + ); + } + + '@test catches error during initial render and shows error block'() { + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught' }); + } + + /* eslint-disable no-console */ + '@test logs caught error to console.error in DEBUG mode during initial render'(assert: Assert) { + if (!DEBUG) { + assert.expect(0); + return; + } + + let originalConsoleError = console.error; + let errors: unknown[][] = []; + console.error = (...args: unknown[]) => errors.push(args); + + try { + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught' }); + + assert.ok(errors.length > 0, 'console.error was called'); + assert.strictEqual( + errors[0]![0], + 'An error was caught by :', + 'logs the expected message' + ); + assert.ok(errors[0]![1] instanceof Error, 'logs the error object'); + assert.strictEqual( + (errors[0]![1] as Error).message, + 'render error', + 'logs the correct error' + ); + } finally { + console.error = originalConsoleError; + } + } + + '@test logs caught error to console.error in DEBUG mode during rerender'(assert: Assert) { + if (!DEBUG) { + assert.expect(0); + return; + } + + let originalConsoleError = console.error; + let errors: unknown[][] = []; + + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Start capturing after initial render + console.error = (...args: unknown[]) => errors.push(args); + + try { + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + + assert.ok(errors.length > 0, 'console.error was called during rerender'); + assert.strictEqual( + errors[0]![0], + 'An error was caught by :', + 'logs the expected message' + ); + assert.strictEqual( + (errors[0]![1] as Error).message, + 'conditional error', + 'logs the correct error' + ); + } finally { + console.error = originalConsoleError; + } + } + /* eslint-enable no-console */ + + '@test passes error object to error block'() { + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: render error' }); + } + + '@test catches error during rerender'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + } + + '@test retry re-renders default content after error is fixed'() { + class State { + @tracked shouldThrow = true; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err retry|>', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: '' }); + + state.shouldThrow = false; + + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + } + + '@test nested boundaries — inner catches, outer unaffected'() { + let Root = setComponentTemplate( + precompileTemplate( + '<:try>outer ok <:try><:catch as |err|>inner caught<:catch as |err|>outer caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'outer ok inner caught' }); + } + + '@test renders nothing when no error block provided'() { + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ ErrorBoundary, Throwing }), + }), + templateOnly() + ); + + this.renderComponent(Root, { expect: '' }); + } + + '@test tracked properties work after error recovery via retry'() { + class State { + @tracked shouldThrow = true; + } + let state = new State(); + + class CounterComponent extends GlimmerishComponent { + @tracked count = 0; + increment = () => this.count++; + } + + let Counter = setComponentTemplate( + precompileTemplate( + '{{this.count}}', + { strictMode: true, scope: () => ({ on }) } + ), + CounterComponent + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err retry|>', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, Counter, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: '' }); + + state.shouldThrow = false; + + this.assertChange({ + change: () => clickElement('.retry'), + expect: 'ok0', + }); + + this.assertChange({ + change: () => clickElement('button:not(.retry)'), + expect: 'ok1', + }); + } + + '@test sibling content survives error and recovery round-trip'() { + let Root = setComponentTemplate( + precompileTemplate( + 'before<:try><:catch as |err|>caughtafter', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'beforecaughtafter' }); + } + + '@test catches error from deeply nested grandchild component'() { + let Child = setComponentTemplate( + precompileTemplate('', { strictMode: true, scope: () => ({ Throwing }) }), + templateOnly() + ); + let Parent = setComponentTemplate( + precompileTemplate('', { strictMode: true, scope: () => ({ Child }) }), + templateOnly() + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, Parent }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: render error' }); + } + + '@test catches error from item in each loop'() { + let ItemComponent = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + if ((this as any).args.item === 'bad') { + throw new Error('bad item'); + } + return (this as any).args.item; + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{#each (array "good" "bad" "also-good") as |item|}}{{/each}}<:catch as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, ItemComponent, array }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: bad item' }); + } + + '@test helper throws during rerender when tracked state changes'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let maybeThrowHelper = defineSimpleHelper((shouldThrow: unknown) => { + if (shouldThrow) throw new Error('helper rerender error'); + return 'helper ok'; + }); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{maybeThrowHelper state.shouldThrow}}<:catch as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, maybeThrowHelper, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'helper ok' }); + + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught: helper rerender error', + }); + } + + '@test catches error thrown by a helper'() { + let throwingHelper = defineSimpleHelper(() => { + throw new Error('helper error'); + }); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{throwingHelper}}<:catch as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, throwingHelper }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: helper error' }); + } + + '@test modifiers install correctly inside error boundary'(assert: Assert) { + let trackingModifier = defineSimpleModifier((element: Element) => { + assert.step('modifier installed'); + element.setAttribute('data-modified', 'true'); + }); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>
content
<:catch as |err|>caught
', + { strictMode: true, scope: () => ({ ErrorBoundary, trackingModifier }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: '
content
' }); + assert.verifySteps(['modifier installed']); + } + + '@test computed properties and tracked dependencies work inside boundary'() { + class State { + @tracked firstName = 'Ada'; + @tracked lastName = 'Lovelace'; + } + let state = new State(); + + let FullName = setComponentTemplate( + precompileTemplate('{{this.fullName}}'), + class extends GlimmerishComponent { + get fullName() { + return `${(this as any).args.first} ${(this as any).args.last}`; + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, FullName, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'Ada Lovelace' }); + + this.assertChange({ + change: () => (state.firstName = 'Grace'), + expect: 'Grace Lovelace', + }); + + this.assertChange({ + change: () => (state.lastName = 'Hopper'), + expect: 'Grace Hopper', + }); + } + + '@test multiple sibling boundaries — one errors, other stays intact'() { + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>first caught<:try>second ok<:catch as |err|>second caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'first caughtsecond ok' }); + } + + '@test error boundary preserves error block across unrelated rerenders'() { + class State { + @tracked counter = 0; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{state.counter}}<:try><:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: '0caught' }); + + this.assertChange({ + change: () => state.counter++, + expect: '1caught', + }); + + this.assertChange({ + change: () => state.counter++, + expect: '2caught', + }); + } + + '@test outer tracked state continues to work across boundary error transitions'() { + class State { + @tracked label = 'hello'; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{state.label}}<:try><:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hellocaught' }); + + this.assertChange({ + change: () => (state.label = 'world'), + expect: 'worldcaught', + }); + } + + '@test catches error from conditional branch during initial render'() { + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{#if true}}{{else}}safe{{/if}}<:catch as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: render error' }); + } + + '@test catches error when tracked array mutation causes throw during rerender'() { + class State { + @tracked items = ['a', 'b']; + } + let state = new State(); + + let ItemComponent = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + if ((this as any).args.item === 'bomb') { + throw new Error('bomb item'); + } + return (this as any).args.item; + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{#each state.items as |item|}}{{/each}}<:catch as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, ItemComponent, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ab' }); + + this.assertChange({ + change: () => (state.items = ['a', 'b', 'bomb']), + expect: 'caught: bomb item', + }); + } + + // Modifier errors are not caught by ErrorBoundary because modifiers install + // during transaction.commit(), which runs after VM execution completes. + // ErrorBoundary only catches errors during the VM render phase. + '@skip catches error thrown by a modifier'() { + let throwingModifier = defineSimpleModifier(() => { + throw new Error('modifier error'); + }); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>
content
<:catch as |err|>caught: {{err.message}}
', + { strictMode: true, scope: () => ({ ErrorBoundary, throwingModifier }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: modifier error' }); + } + + '@test destructors are called when boundary transitions to error state'(assert: Assert) { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + // Use a component manager with destructor capability so the component + // instance enters the destroyable hierarchy. The default + // GlimmerishComponentManager has no destructor support, so + // registerDestructor on the component instance would never fire. + class DestroyableComponent { + args: any; + constructor(owner: any, args: any) { + setOwner(this, owner); + this.args = args; + registerDestructor(this, () => assert.step('destroyed'), true); + } + + get value() { + if (this.args.shouldThrow) { + throw new Error('conditional error'); + } + return 'alive'; + } + } + + setComponentManager( + () => ({ + capabilities: componentCapabilities('3.13', { destructor: true }), + createComponent(Factory: any, args: any) { + return new Factory(undefined, args.named); + }, + getContext(component: any) { + return component; + }, + destroyComponent(component: any) { + destroy(component); + }, + }), + DestroyableComponent + ); + + let Tracked = setComponentTemplate( + precompileTemplate('{{this.value}}'), + DestroyableComponent as any + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Tracked, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'alive' }); + + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + + assert.verifySteps(['destroyed']); + } + + '@test retry that still throws shows error block again'() { + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught ' }); + + this.assertChange({ + change: () => clickElement('button'), + expect: 'caught ', + }); + } + + '@test deeply nested components with conditionals — error in conditional branch'() { + class State { + @tracked showDanger = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{#if state.showDanger}}{{else}}safe{{/if}}<:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'safe' }); + + this.assertChange({ + change: () => (state.showDanger = true), + expect: 'caught', + }); + } + + // --- Regression tests for tracking state corruption bugs --- + + '@test rerender error then fix state and retry recovers'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Trigger error via rerender + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Fix state and retry — must not cause backtracking assertion + state.shouldThrow = false; + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + } + + '@test repeated rerender errors do not corrupt tracking state'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // First trigger + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Second trigger (same value — still dirties tag, causes revalidation) + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Fix and retry + state.shouldThrow = false; + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + } + + '@test error in error block fallback bubbles to parent boundary'() { + let ThrowingFallback = setComponentTemplate( + precompileTemplate('{{this.boom}}'), + class extends GlimmerishComponent { + get boom(): never { + throw new Error('fallback error'); + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:try><:catch as |err|><:catch as |err|>outer caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, ThrowingFallback }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'outer caught: fallback error' }); + } + + '@test each loop insert error then retry recovers'() { + class State { + @tracked items = ['a', 'b']; + } + let state = new State(); + + let ItemComponent = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + if ((this as any).args.item === 'bomb') { + throw new Error('bomb'); + } + return (this as any).args.item; + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{#each state.items as |item|}}{{/each}}<:catch as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, ItemComponent, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ab' }); + + // Add bad item — triggers insertItem path + this.assertChange({ + change: () => (state.items = ['a', 'b', 'bomb']), + expect: 'caught ', + }); + + // Fix and retry + state.items = ['a', 'b']; + this.assertChange({ + change: () => clickElement('button'), + expect: 'ab', + }); + } + + '@test each loop repeated insert errors then retry recovers'() { + class State { + @tracked items = ['a', 'b']; + } + let state = new State(); + + let ItemComponent = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + if ((this as any).args.item === 'bomb') { + throw new Error('bomb'); + } + return (this as any).args.item; + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{#each state.items as |item|}}{{/each}}<:catch as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, ItemComponent, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ab' }); + + // First bad mutation + this.assertChange({ + change: () => (state.items = ['a', 'b', 'bomb']), + expect: 'caught ', + }); + + // Second bad mutation while in error state + this.assertChange({ + change: () => (state.items = ['a', 'bomb', 'c']), + expect: 'caught ', + }); + + // Fix and retry + state.items = ['x', 'y']; + this.assertChange({ + change: () => clickElement('button'), + expect: 'xy', + }); + } + + // --- automatic retry from tracked state --- + + '@test recovers when state read by the failed initial render changes'() { + class State { + @tracked shouldThrow = true; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught' }); + + this.assertChange({ + change: () => (state.shouldThrow = false), + expect: 'ok', + }); + } + + '@test recovers when state read by the failed rerender changes'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + + this.assertChange({ + change: () => (state.shouldThrow = false), + expect: 'ok', + }); + + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + } + + '@test does not retry when state the failed render never read changes'(assert: Assert) { + class State { + @tracked label = 'a'; + } + let state = new State(); + let attempts = 0; + + let CountedThrow = setComponentTemplate( + precompileTemplate('{{this.boom}}'), + class extends GlimmerishComponent { + get boom(): never { + attempts++; + throw new Error('render error'); + } + } + ); + + // The default block throws before it reaches `state.label`, so only the + // error block depends on it. Changing it must update the fallback + // without re-attempting the default block. + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{state.label}}<:catch>caught {{state.label}}', + { strictMode: true, scope: () => ({ ErrorBoundary, CountedThrow, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught a' }); + assert.strictEqual(attempts, 1, 'default block attempted once on initial render'); + + this.assertChange({ + change: () => (state.label = 'b'), + expect: 'caught b', + }); + assert.strictEqual(attempts, 1, 'unrelated state change does not re-attempt'); + } + + '@test retries when state read before the throw changes, and re-catches if still failing'( + assert: Assert + ) { + class State { + @tracked count = 1; + } + let state = new State(); + let attempts = 0; + + let FailsWhilePositive = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + attempts++; + let count = (this as any).args.count; + if (count > 0) { + throw new Error(`count is ${count}`); + } + return 'ok'; + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, FailsWhilePositive, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: count is 1' }); + assert.strictEqual(attempts, 1); + + this.assertChange({ + change: () => (state.count = 2), + expect: 'caught: count is 2', + }); + assert.strictEqual(attempts, 2, 'retried once and caught the new error'); + + this.assertChange({ + change: () => (state.count = 0), + expect: 'ok', + }); + assert.strictEqual(attempts, 3, 'retried once and recovered'); + } + + // --- manual retry --- + + '@test rerender error then retry without fixing re-catches'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Trigger error via tracked state change + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Retry WITHOUT fixing state — error should be re-caught + this.assertChange({ + change: () => clickElement('button'), + expect: 'caught ', + }); + } + + '@test multiple error-recovery cycles do not require extra retry clicks'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // --- Cycle 1: error → retry without fixing → re-catch → fix → retry → recover --- + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Retry without fixing — should re-catch + this.assertChange({ + change: () => clickElement('button'), + expect: 'caught ', + }); + + // Fix and retry — should recover + state.shouldThrow = false; + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + + // --- Cycle 2: same sequence, should still recover in same number of clicks --- + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Retry without fixing — should re-catch + this.assertChange({ + change: () => clickElement('button'), + expect: 'caught ', + }); + + // Fix and retry — should recover (NOT require extra clicks) + state.shouldThrow = false; + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + + // --- Cycle 3: one more to be sure --- + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + state.shouldThrow = false; + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + } + + '@test sibling content after ErrorBoundary updates correctly'() { + let state = new SiblingState(); + + let Sibling = setComponentTemplate( + precompileTemplate('{{state.label}}', { strictMode: true, scope: () => ({ state }) }), + templateOnly() + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>content<:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Sibling }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'contentbefore' }); + + // Changing tracked state on sibling should not crash. + // Without the block stack fix, the ErrorBoundary's orphaned AppendingBlock + // corrupts the sibling's bounds, causing a crash in clear() during re-render. + this.assertChange({ + change: () => (state.label = 'after'), + expect: 'contentafter', + }); + } + + '@test component inside ErrorBoundary re-renders with sibling content after'() { + let state = new InnerState(); + let siblingState = new SiblingState(); + let Inner = setComponentTemplate( + precompileTemplate('{{state.value}}', { strictMode: true, scope: () => ({ state }) }), + templateOnly() + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught{{siblingState.label}}', + { strictMode: true, scope: () => ({ ErrorBoundary, Inner, siblingState }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hellobefore' }); + + // Re-render inside EB content — without the cursor fix, this inverts + // the parent block's bounds (firstNode after lastNode in DOM order). + this.assertChange({ + change: () => (state.value = 'world'), + expect: 'worldbefore', + }); + + // Re-render sibling — verifies bounds are correct after EB content changed. + this.assertChange({ + change: () => (siblingState.label = 'after'), + expect: 'worldafter', + }); + } + + '@test tracked state inside ErrorBoundary content updates'() { + let state = new InnerState(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{state.value}}<:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hello' }); + + this.assertChange({ + change: () => (state.value = 'world'), + expect: 'world', + }); + } + + '@test ErrorBoundary inside conditional that toggles'() { + let state = new ConditionalState(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#if state.show}}<:try>content<:catch as |err|>caught{{/if}}', + { strictMode: true, scope: () => ({ ErrorBoundary, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'content' }); + + this.assertChange({ + change: () => (state.show = false), + expect: '', + }); + + this.assertChange({ + change: () => (state.show = true), + expect: 'content', + }); + } + + '@test ErrorBoundary and sibling in conditional block re-render'() { + let state = new SiblingState(); + let cond = new ConditionalState(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#if cond.show}}<:try>eb<:catch as |err|>caught{{state.label}}{{/if}}', + { strictMode: true, scope: () => ({ ErrorBoundary, state, cond }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ebbefore' }); + + this.assertChange({ + change: () => (state.label = 'after'), + expect: 'ebafter', + }); + } + + '@test ErrorBoundary wrapping component with tracked state'() { + let state = new InnerState(); + let Inner = setComponentTemplate( + precompileTemplate('{{state.value}}', { strictMode: true, scope: () => ({ state }) }), + templateOnly() + ); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try><:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Inner }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hello' }); + + this.assertChange({ + change: () => (state.value = 'world'), + expect: 'world', + }); + } + + '@test catches error inside {{#in-element}} during initial render and cleans up remote DOM'() { + let fixture = document.querySelector('#qunit-fixture')!; + let remote = document.createElement('div'); + remote.id = 'eb-remote-target-1'; + fixture.appendChild(remote); + + let getRemote = defineSimpleHelper(() => remote); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{#in-element (getRemote) insertBefore=null}}{{/in-element}}<:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, getRemote }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught' }); + + // Remote element should be completely empty — no DOM nodes at all. + this.assert.strictEqual( + remote.innerHTML, + '', + 'remote element should be completely empty after error' + ); + } + + '@test catches rerender error and cleans up {{#in-element}} remote DOM'() { + let fixture = document.querySelector('#qunit-fixture')!; + let remote = document.createElement('div'); + remote.id = 'eb-remote-target-2'; + fixture.appendChild(remote); + + let getRemote = defineSimpleHelper(() => remote); + let state = new ThrowOnlyState(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{#in-element (getRemote) insertBefore=null}}{{/in-element}}<:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, getRemote, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: '' }); + + // Remote element should have content from successful render + this.assert.strictEqual(remote.textContent, 'ok', 'remote element has content before error'); + + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + + // Remote element should be completely empty — no DOM nodes at all. + this.assert.strictEqual( + remote.innerHTML, + '', + 'remote element should be completely empty after error' + ); + } + + '@test multiple re-renders of ErrorBoundary content'() { + let state = new InnerState(); + + let Root = setComponentTemplate( + precompileTemplate( + '<:try>{{state.value}}<:catch as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hello' }); + + this.assertChange({ + change: () => (state.value = 'one'), + expect: 'one', + }); + + this.assertChange({ + change: () => (state.value = 'two'), + expect: 'two', + }); + + this.assertChange({ + change: () => (state.value = 'three'), + expect: 'three', + }); + } + } +); diff --git a/packages/@ember/-internals/routing/route-managers/classic/outlet-component.ts b/packages/@ember/-internals/routing/route-managers/classic/outlet-component.ts index 83af5052615..f6f41d9fca3 100644 --- a/packages/@ember/-internals/routing/route-managers/classic/outlet-component.ts +++ b/packages/@ember/-internals/routing/route-managers/classic/outlet-component.ts @@ -87,6 +87,7 @@ const CAPABILITIES: InternalComponentCapabilities = { // Engines swap the owner at a mount point. Classic's business, not the // framework's: `render.owner` is the owner this level renders under. hasSubOwner: true, + errorBoundary: false, }; interface OutletInstanceState { diff --git a/packages/@ember/-internals/routing/route-managers/root-outlet.ts b/packages/@ember/-internals/routing/route-managers/root-outlet.ts index df69300fa52..1eb63afe67f 100644 --- a/packages/@ember/-internals/routing/route-managers/root-outlet.ts +++ b/packages/@ember/-internals/routing/route-managers/root-outlet.ts @@ -39,6 +39,7 @@ const CAPABILITIES: InternalComponentCapabilities = { wrapped: false, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }; class RootOutletManager diff --git a/packages/@ember/component/index.ts b/packages/@ember/component/index.ts index 9ce284937b3..a785817c0f7 100644 --- a/packages/@ember/component/index.ts +++ b/packages/@ember/component/index.ts @@ -6,6 +6,7 @@ export { setComponentTemplate, getComponentTemplate } from '@glimmer/manager/lib/public/template'; export { default } from '@ember/-internals/glimmer/lib/component'; +export { default as ErrorBoundary } from '@ember/-internals/glimmer/lib/components/error-boundary'; export { default as Input } from '@ember/-internals/glimmer/lib/components/input'; export { default as Textarea } from '@ember/-internals/glimmer/lib/components/textarea'; export { componentCapabilities as capabilities } from '@glimmer/manager/lib/public/component'; diff --git a/packages/@glimmer-workspace/integration-tests/lib/components/emberish-curly.ts b/packages/@glimmer-workspace/integration-tests/lib/components/emberish-curly.ts index 61560bb3931..f7983a761ad 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/components/emberish-curly.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/components/emberish-curly.ts @@ -131,6 +131,7 @@ const EMBERISH_CURLY_CAPABILITIES: InternalComponentCapabilities = { wrapped: true, willDestroy: true, hasSubOwner: false, + errorBoundary: false, }; export class EmberishCurlyComponentManager diff --git a/packages/@glimmer-workspace/integration-tests/lib/suites/components.ts b/packages/@glimmer-workspace/integration-tests/lib/suites/components.ts index 71ced2a1246..f0a79bccd84 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/suites/components.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/suites/components.ts @@ -879,7 +879,7 @@ export class GlimmerishComponents extends RenderTest { }; try { - assert.expect(7); + assert.expect(6); this.registerComponent( 'Glimmer', diff --git a/packages/@glimmer-workspace/integration-tests/test/owner-test.ts b/packages/@glimmer-workspace/integration-tests/test/owner-test.ts index b80180164f5..f640a0e70a0 100644 --- a/packages/@glimmer-workspace/integration-tests/test/owner-test.ts +++ b/packages/@glimmer-workspace/integration-tests/test/owner-test.ts @@ -52,6 +52,7 @@ const CAPABILITIES = { wrapped: false, willDestroy: false, hasSubOwner: true, + errorBoundary: false, }; // eslint-disable-next-line @typescript-eslint/no-extraneous-class diff --git a/packages/@glimmer/constants/lib/syscall-ops.ts b/packages/@glimmer/constants/lib/syscall-ops.ts index cc259215535..8ab8ac0648b 100644 --- a/packages/@glimmer/constants/lib/syscall-ops.ts +++ b/packages/@glimmer/constants/lib/syscall-ops.ts @@ -51,6 +51,7 @@ import type { VmJumpEq, VmJumpUnless, VmLoad, + VmInvokeComponentLayoutGuarded, VmLog, VmMain, VmModifier, @@ -183,7 +184,8 @@ export const VM_IF_INLINE_OP = 109 satisfies VmIfInline; export const VM_NOT_OP = 110 satisfies VmNot; export const VM_GET_DYNAMIC_VAR_OP = 111 satisfies VmGetDynamicVar; export const VM_LOG_OP = 112 satisfies VmLog; -export const VM_SYSCALL_SIZE = 113 satisfies VmSize; +export const VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP = 113 satisfies VmInvokeComponentLayoutGuarded; +export const VM_SYSCALL_SIZE = 114 satisfies VmSize; export function isOp(value: number): value is VmOp { return value >= 16; diff --git a/packages/@glimmer/debug/lib/opcode-metadata.ts b/packages/@glimmer/debug/lib/opcode-metadata.ts index e44d37c06bf..9c1a1b53280 100644 --- a/packages/@glimmer/debug/lib/opcode-metadata.ts +++ b/packages/@glimmer/debug/lib/opcode-metadata.ts @@ -57,6 +57,7 @@ import { VM_HAS_BLOCK_PARAMS_OP, VM_HELPER_OP, VM_IF_INLINE_OP, + VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, VM_INVOKE_COMPONENT_LAYOUT_OP, VM_INVOKE_YIELD_OP, VM_ITERATE_OP, @@ -692,6 +693,13 @@ if (LOCAL_DEBUG) { ops: ['state:register'], }; + METADATA[VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP] = { + name: 'InvokeComponentLayoutGuarded', + mnemonic: 'comp_invokelayout_guarded', + stackChange: 0, + ops: ['state:register'], + }; + METADATA[VM_BEGIN_COMPONENT_TRANSACTION_OP] = { name: 'BeginComponentTransaction', mnemonic: 'comp_begin', diff --git a/packages/@glimmer/interfaces/lib/dom/attributes.d.ts b/packages/@glimmer/interfaces/lib/dom/attributes.d.ts index 47919a1e7ac..b8ec4a2a915 100644 --- a/packages/@glimmer/interfaces/lib/dom/attributes.d.ts +++ b/packages/@glimmer/interfaces/lib/dom/attributes.d.ts @@ -45,6 +45,8 @@ export interface FixedBlock extends AppendingBlock {} */ export interface ResettableBlock extends FixedBlock { reset(env: Environment): Nullable; + /** Reset internal tracking state without DOM cleanup (for error boundary recovery). */ + resetPartial(): void; } export interface DOMStack { @@ -110,6 +112,7 @@ export interface TreeBuilder extends Cursor, DOMStack, TreeOperations { pushResettableBlock(): ResettableBlock; pushBlockList(list: Bounds[]): AppendingBlock; popBlock(): AppendingBlock; + dropBlocks(): void; didAppendBounds(bounds: Bounds): void; } diff --git a/packages/@glimmer/interfaces/lib/managers/internal/component.d.ts b/packages/@glimmer/interfaces/lib/managers/internal/component.d.ts index 0aaf6814e93..33f83774dad 100644 --- a/packages/@glimmer/interfaces/lib/managers/internal/component.d.ts +++ b/packages/@glimmer/interfaces/lib/managers/internal/component.d.ts @@ -106,6 +106,12 @@ export interface InternalComponentCapabilities { * used for engines. */ hasSubOwner: boolean; + + /** + * Whether this component acts as an error boundary, catching errors thrown + * during rendering of its children and displaying fallback UI. + */ + errorBoundary: boolean; } /** @@ -126,6 +132,7 @@ export type CreateInstanceCapability = 0b0001000000000; export type WrappedCapability = 0b0010000000000; export type WillDestroyCapability = 0b0100000000000; export type HasSubOwnerCapability = 0b1000000000000; +export type ErrorBoundaryCapability = 0b10000000000000; export type InternalComponentCapability = | EmptyCapability @@ -141,7 +148,8 @@ export type InternalComponentCapability = | CreateInstanceCapability | WrappedCapability | WillDestroyCapability - | HasSubOwnerCapability; + | HasSubOwnerCapability + | ErrorBoundaryCapability; //////////// diff --git a/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts b/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts index 7f5bcc2c9ec..e627ae965ef 100644 --- a/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts @@ -46,4 +46,10 @@ export interface DebugRenderTree { commit(): void; capture(): CapturedRenderNode[]; + + /** Return the current depth of the internal stack (for error boundary rollback). */ + getDepth(): number; + + /** Pop entries from the internal stack down to the given depth (for error boundary rollback). */ + rollbackTo(depth: number): void; } diff --git a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts index c47f1c98abb..798f917c5ad 100644 --- a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts @@ -30,6 +30,11 @@ export type ComponentInstanceWithCreate = ComponentInstance< WithCreateInstance >; +/** An opaque handle for one `{{#try}}` attempt's queued lifecycle work. */ +export interface RenderAttempt { + readonly entries: unknown[]; +} + export interface Environment { [TransactionSymbol]: Nullable; @@ -42,6 +47,18 @@ export interface Environment { begin(): void; commit(): void; + /** + * Scopes the lifecycle work queued by one `{{#try}}` attempt, so a failed + * attempt can drop it. Returns `null` outside a transaction. + */ + beginAttempt(): Nullable; + commitAttempt(attempt: Nullable): void; + abortAttempt(attempt: Nullable): void; + + /** Brackets an independent root render, whose work no enclosing attempt owns. */ + beginRootRender(): void; + endRootRender(): void; + getDOM(): GlimmerTreeChanges; getAppendOperations(): GlimmerTreeConstruction; diff --git a/packages/@glimmer/interfaces/lib/runtime/render.d.ts b/packages/@glimmer/interfaces/lib/runtime/render.d.ts index bd8ad0a2023..f813fd42002 100644 --- a/packages/@glimmer/interfaces/lib/runtime/render.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/render.d.ts @@ -6,6 +6,12 @@ import type { Environment } from './environment.js'; export interface ExceptionHandler { handleException(): void; + /** + * Recovers from a JavaScript error thrown while updating. Returns false when + * this handler does not catch errors right now, e.g. a boundary showing its + * fallback, so the error keeps propagating. + */ + handleCaughtError(error: unknown): boolean; } export interface RenderResult extends Bounds, ExceptionHandler { diff --git a/packages/@glimmer/interfaces/lib/vm-opcodes.d.ts b/packages/@glimmer/interfaces/lib/vm-opcodes.d.ts index 47dd693e94d..7e4b00358d5 100644 --- a/packages/@glimmer/interfaces/lib/vm-opcodes.d.ts +++ b/packages/@glimmer/interfaces/lib/vm-opcodes.d.ts @@ -109,7 +109,8 @@ export type VmIfInline = 109; export type VmNot = 110; export type VmGetDynamicVar = 111; export type VmLog = 112; -export type VmSize = 113; +export type VmInvokeComponentLayoutGuarded = 113; +export type VmSize = 114; export type VmOp = | VmHelper @@ -202,6 +203,7 @@ export type VmOp = | VmIfInline | VmNot | VmGetDynamicVar - | VmLog; + | VmLog + | VmInvokeComponentLayoutGuarded; export type SomeVmOp = VmOp | VmMachineOp; diff --git a/packages/@glimmer/manager/lib/public/component.ts b/packages/@glimmer/manager/lib/public/component.ts index 9ab0635c3dc..387d7e6a49e 100644 --- a/packages/@glimmer/manager/lib/public/component.ts +++ b/packages/@glimmer/manager/lib/public/component.ts @@ -39,6 +39,7 @@ const CAPABILITIES = { wrapped: false, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }; export function componentCapabilities( diff --git a/packages/@glimmer/manager/lib/util/capabilities.ts b/packages/@glimmer/manager/lib/util/capabilities.ts index 9ec370a32df..8621aa2d285 100644 --- a/packages/@glimmer/manager/lib/util/capabilities.ts +++ b/packages/@glimmer/manager/lib/util/capabilities.ts @@ -10,6 +10,7 @@ import type { DynamicScopeCapability, DynamicTagCapability, ElementHookCapability, + ErrorBoundaryCapability, Expand, HasSubOwnerCapability, InternalComponentCapability, @@ -62,7 +63,8 @@ export function capabilityFlagsFrom(capabilities: CapabilityOptions): Capability capability(capabilities, 'createInstance') | capability(capabilities, 'wrapped') | capability(capabilities, 'willDestroy') | - capability(capabilities, 'hasSubOwner')) as CapabilityMask; + capability(capabilities, 'hasSubOwner') | + capability(capabilities, 'errorBoundary')) as CapabilityMask; } function capability( @@ -99,7 +101,9 @@ export type InternalComponentCapabilityFor( _manager: InternalComponentManager, diff --git a/packages/@glimmer/manager/test/capabilities-test.ts b/packages/@glimmer/manager/test/capabilities-test.ts index 86aef75d329..d70fdbe849f 100644 --- a/packages/@glimmer/manager/test/capabilities-test.ts +++ b/packages/@glimmer/manager/test/capabilities-test.ts @@ -20,8 +20,9 @@ QUnit.test('encodes a capabilities object into a bitmap', (assert) => { wrapped: false, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }), - 0b0000000000000, + 0b00000000000000, 'empty capabilities' ); @@ -40,8 +41,9 @@ QUnit.test('encodes a capabilities object into a bitmap', (assert) => { wrapped: true, willDestroy: true, hasSubOwner: true, + errorBoundary: true, }), - 0b1111111111111, + 0b11111111111111, 'all capabilities' ); @@ -60,8 +62,9 @@ QUnit.test('encodes a capabilities object into a bitmap', (assert) => { wrapped: true, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }), - 0b0010100100101, + 0b00010100100101, 'random sample' ); }); @@ -81,6 +84,7 @@ QUnit.test('allows querying bitmap for a capability', (assert) => { wrapped: true, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }); assert.true( diff --git a/packages/@glimmer/manager/test/managers-test.ts b/packages/@glimmer/manager/test/managers-test.ts index 2d28b602635..990dd12c769 100644 --- a/packages/@glimmer/manager/test/managers-test.ts +++ b/packages/@glimmer/manager/test/managers-test.ts @@ -78,6 +78,7 @@ module('Managers', () => { wrapped: false, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }; } diff --git a/packages/@glimmer/opcode-compiler/lib/opcode-builder/delegate.ts b/packages/@glimmer/opcode-compiler/lib/opcode-builder/delegate.ts index fb83e191533..eaa24a7838c 100644 --- a/packages/@glimmer/opcode-compiler/lib/opcode-builder/delegate.ts +++ b/packages/@glimmer/opcode-compiler/lib/opcode-builder/delegate.ts @@ -18,6 +18,7 @@ export const DEFAULT_CAPABILITIES: InternalComponentCapabilities = { wrapped: false, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }; export const MINIMAL_CAPABILITIES: InternalComponentCapabilities = { @@ -34,6 +35,7 @@ export const MINIMAL_CAPABILITIES: InternalComponentCapabilities = { wrapped: false, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }; export interface ResolverDelegate { diff --git a/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/components.ts b/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/components.ts index 4bbfe09b09d..11b9fd53758 100644 --- a/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/components.ts +++ b/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/components.ts @@ -23,6 +23,7 @@ import { VM_GET_COMPONENT_LAYOUT_OP, VM_GET_COMPONENT_SELF_OP, VM_GET_COMPONENT_TAG_NAME_OP, + VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, VM_INVOKE_COMPONENT_LAYOUT_OP, VM_JUMP_UNLESS_OP, VM_LOAD_OP, @@ -197,7 +198,9 @@ function InvokeStaticComponent( ): void { let { symbolTable } = layout; - let bailOut = hasCapability(capabilities, InternalComponentCapabilities.prepareArgs); + let bailOut = + hasCapability(capabilities, InternalComponentCapabilities.prepareArgs) || + hasCapability(capabilities, InternalComponentCapabilities.errorBoundary); if (bailOut) { InvokeNonStaticComponent(op, { @@ -395,17 +398,24 @@ export function InvokeNonStaticComponent( CompileArgs(op, positional, named, blocks, atNames); op(VM_PREPARE_ARGS_OP, $s0); - invokePreparedComponent(op, blocks.has('default'), bindableBlocks, bindableAtNames, () => { - if (layout) { - op(VM_PUSH_SYMBOL_TABLE_OP, symbolTableOperand(layout.symbolTable)); - op(VM_CONSTANT_OP, layoutOperand(layout)); - op(VM_COMPILE_BLOCK_OP); - } else { - op(VM_GET_COMPONENT_LAYOUT_OP, $s0); - } + invokePreparedComponent( + op, + blocks.has('default'), + bindableBlocks, + bindableAtNames, + () => { + if (layout) { + op(VM_PUSH_SYMBOL_TABLE_OP, symbolTableOperand(layout.symbolTable)); + op(VM_CONSTANT_OP, layoutOperand(layout)); + op(VM_COMPILE_BLOCK_OP); + } else { + op(VM_GET_COMPONENT_LAYOUT_OP, $s0); + } - op(VM_POPULATE_LAYOUT_OP, $s0); - }); + op(VM_POPULATE_LAYOUT_OP, $s0); + }, + capabilities + ); op(VM_LOAD_OP, $s0); } @@ -443,7 +453,8 @@ export function invokePreparedComponent( hasBlock: boolean, bindableBlocks: boolean, bindableAtNames: boolean, - populateLayout: Nullable<() => void> = null + populateLayout: Nullable<() => void> = null, + capabilities: CapabilityMask | true = true ): void { op(VM_BEGIN_COMPONENT_TRANSACTION_OP, $s0); op(VM_PUSH_DYNAMIC_SCOPE_OP); @@ -469,7 +480,16 @@ export function invokePreparedComponent( if (bindableBlocks) op(VM_SET_BLOCKS_OP, $s0); op(VM_POP_OP, 1); - op(VM_INVOKE_COMPONENT_LAYOUT_OP, $s0); + + if ( + capabilities !== true && + hasCapability(capabilities, InternalComponentCapabilities.errorBoundary) + ) { + op(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, $s0); + } else { + op(VM_INVOKE_COMPONENT_LAYOUT_OP, $s0); + } + op(VM_DID_RENDER_LAYOUT_OP, $s0); op(VM_POP_FRAME_OP); diff --git a/packages/@glimmer/runtime/index.ts b/packages/@glimmer/runtime/index.ts index 72e95d58c30..1411e2fa063 100644 --- a/packages/@glimmer/runtime/index.ts +++ b/packages/@glimmer/runtime/index.ts @@ -12,6 +12,7 @@ export { templateOnlyComponent, TemplateOnlyComponentManager, } from './lib/component/template-only'; +export { ErrorBoundaryStateImpl, type ErrorBoundaryState } from './lib/component/error-boundary'; export { CurriedValue, curry } from './lib/curried-value'; export { DOMChanges, diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index 34aba151f2a..22c4114fb93 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -38,6 +38,7 @@ import { VM_GET_COMPONENT_LAYOUT_OP, VM_GET_COMPONENT_SELF_OP, VM_GET_COMPONENT_TAG_NAME_OP, + VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, VM_INVOKE_COMPONENT_LAYOUT_OP, VM_MAIN_OP, VM_POPULATE_LAYOUT_OP, @@ -71,7 +72,7 @@ import debugToString from '@glimmer/debug-util/lib/debug-to-string'; import { expect, unwrap } from '@glimmer/debug-util/lib/platform-utils'; import assert from '@glimmer/debug-util/lib/assert'; import { unwrapTemplate } from '@glimmer/debug-util/lib/template'; -import { registerDestructor } from '@glimmer/destroyable'; +import { associateDestroyableChild, registerDestructor } from '@glimmer/destroyable'; import { hasInternalComponentManager } from '@glimmer/manager/lib/internal/api'; import { managerHasCapability } from '@glimmer/manager/lib/util/capabilities'; import { isConstRef, valueForRef } from '@glimmer/reference/lib/reference'; @@ -85,8 +86,21 @@ import type { CurriedValue } from '../../curried-value'; import type { UpdatingVM } from '../../vm'; import type { VM } from '../../vm/append'; import type { BlockArgumentsImpl } from '../../vm/arguments'; +import { + beginErrorBoundary, + beginTrackFrame, + consumeTag, + endErrorBoundary, + endTrackFrame, + getTrackingDepth, + unwindTrackingTo, +} from '@glimmer/validator/lib/tracking'; + +import { NewTreeBuilder } from '../../vm/element-builder'; +import { clearDOMRange, ErrorBoundaryOpcode } from '../../vm/update'; import { ConcreteBounds } from '../../bounds'; +import type { ErrorBoundaryState } from '../../component/error-boundary'; import { hasCustomDebugRenderTreeLifecycle } from '../../component/interfaces'; import { resolveComponent } from '../../component/resolve'; import { isCurriedType, isCurriedValue, resolveCurriedValue } from '../../curried-value'; @@ -900,6 +914,158 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_OP, (vm, { op1: register }) => { vm.call(state.handle); }); +// Error Boundary Guarded Invocation +// Executes the component layout in a sub-VM wrapped in try-catch. +// On error during initial render, cleans up partial DOM and re-renders with error state. +APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register }) => { + let state = check(vm.fetchValue(check(register, CheckRegister)), CheckFinishedComponentInstance); + let errorState = state.state as ErrorBoundaryState; + + // Capture current scope (which has self, named args, blocks all set up) + // Use the layout handle's resolved address as the closure PC so the sub-VM + // starts executing from the layout code directly. + let layoutAddr = vm.context.program.heap.getaddr(state.handle); + let closure = vm.capture(0, layoutAddr); + + // Save the parent tree builder's cursor BEFORE pushing the block, so the + // sub-VM inserts content at the same position the parent VM would. Without + // this, beginBlock defaults to nextSibling=null, appending at the end of the + // parent element — which inverts the bounds when sibling content follows. + let parentNextSibling = vm.tree().nextSibling; + + let block = vm.tree().pushResettableBlock(); + + // Record insertion point so we can clean up partial DOM on error. + let parent = block.parentElement(); + // The node before the insertion point. A boundary inside a newly inserted + // `{{#each}}` row renders before existing siblings, so the range to clean up + // ends at the insertion point, not at the end of the parent. + let insertionMarker = parentNextSibling ? parentNextSibling.previousSibling : parent.lastChild; + + // Save debug render tree depth so we can roll back stale entries on error. + let renderTreeDepth = vm.env.debugRenderTree?.getDepth() ?? 0; + + // Save tracking frame depth so we can unwind stale frames from a failed + // sub-VM, then open a frame of our own so that everything the attempt + // consumes can be recovered from it if the attempt throws. + let trackingDepth = getTrackingDepth(); + beginTrackFrame(); + + let errorBoundaryOp = new ErrorBoundaryOpcode(closure, vm.context, block, [], errorState); + + // Scope the attempt's lifecycle work and remote content to this boundary, + // so a failed attempt can drop them. + let attempt = vm.env.beginAttempt(); + beginErrorBoundary(); + errorBoundaryOp.enter(); + + try { + // Use beginBlock (not resume) since this is a fresh block with no prior content. + // Pass parentNextSibling so content is inserted at the correct cursor position. + let subTree = NewTreeBuilder.beginBlock(vm.env, block, parentNextSibling); + let subVM = closure.evaluate(subTree); + + let children: UpdatingOpcode[] = []; + errorBoundaryOp.children = children; + + // Use executeErrorBoundary to avoid resetting the parent VM's tracking + // state and to clean up remote blocks on error. + let result = subVM.executeErrorBoundary((subVM) => { + subVM.updateWith(errorBoundaryOp); + subVM.pushUpdating(children); + }); + + errorBoundaryOp.exit(); + endErrorBoundary(); + vm.env.commitAttempt(attempt); + + // The attempt succeeded: hand what it consumed to the enclosing frame. + consumeTag(endTrackFrame()); + + associateDestroyableChild(errorBoundaryOp, result.drop); + vm.associateDestroyable(errorBoundaryOp); + vm.updateWith(errorBoundaryOp); + } catch (error) { + errorBoundaryOp.exit(); + endErrorBoundary(); + vm.env.abortAttempt(attempt); + + // Remote destructors run later, after the fallback may already render + // into the same destination: remove that content now. + errorBoundaryOp.clearRemoteBlocks(); + + if (DEBUG) { + // eslint-disable-next-line no-console + console.error('An error was caught by :', error); + } + + // Roll back the tracking frames left by the failed sub-VM render, keeping + // what they consumed so the boundary can retry when any of it changes. + let failedTag = unwindTrackingTo(trackingDepth); + + // Roll back stale debug render tree entries from the failed render. + vm.env.debugRenderTree?.rollbackTo(renderTreeDepth); + + // Remove any partial DOM nodes inserted during the failed render. We can't + // use clear(block) because child bounds may be partially initialized. + let start = insertionMarker ? insertionMarker.nextSibling : parent.firstChild; + clearDOMRange(parent, start, parentNextSibling); + + // Reset block to empty state for the retry render. + block.resetPartial(); + + // Set error state so the template takes the error branch + errorState.setError(error, failedTag); + errorState.consumeFailedTag(); + + // Re-execute layout with error state (will render the error block). + let retryTree = NewTreeBuilder.beginBlock(vm.env, block, parentNextSibling); + let retryVM = closure.evaluate(retryTree); + + let children: UpdatingOpcode[] = []; + errorBoundaryOp.children = children; + + let fallbackAttempt = vm.env.beginAttempt(); + let fallbackCompleted = false; + + beginErrorBoundary(); + errorBoundaryOp.enter(); + + let result; + + try { + result = retryVM.executeErrorBoundary((retryVM) => { + retryVM.updateWith(errorBoundaryOp); + retryVM.pushUpdating(children); + }); + fallbackCompleted = true; + } finally { + errorBoundaryOp.exit(); + endErrorBoundary(); + + if (fallbackCompleted) { + vm.env.commitAttempt(fallbackAttempt); + } else { + // The fallback threw too: its work and remote content go with it. + vm.env.abortAttempt(fallbackAttempt); + errorBoundaryOp.clearRemoteBlocks(); + } + } + + associateDestroyableChild(errorBoundaryOp, result.drop); + vm.associateDestroyable(errorBoundaryOp); + vm.updateWith(errorBoundaryOp); + } + + // Pop the ResettableBlock from the parent tree builder. It was pushed for + // the sub-VM's use, but the sub-VM has its own tree builder. Without this + // pop, VM_DID_RENDER_LAYOUT_OP pops the ResettableBlock instead of the + // AppendingBlock from VM_BEGIN_COMPONENT_TRANSACTION_OP, leaving the + // AppendingBlock orphaned on the stack and corrupting bounds for all + // subsequent sibling content. + vm.tree().popBlock(); +}); + APPEND_OPCODES.add(VM_DID_RENDER_LAYOUT_OP, (vm, { op1: register }) => { let instance = check(vm.fetchValue(check(register, CheckRegister)), CheckComponentInstance); let { manager, state, capabilities } = instance; diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/dom.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/dom.ts index 1976f099867..40edfb2aea7 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/dom.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/dom.ts @@ -52,6 +52,7 @@ import type { DynamicAttribute } from '../../vm/attributes/dynamic'; import { isCurriedType, resolveCurriedValue } from '../../curried-value'; import { APPEND_OPCODES } from '../../opcodes'; import { createCapturedArgs } from '../../vm/arguments'; +import { trackRemoteBlock } from '../../vm/update'; import { CheckArguments, CheckOperations, CheckReference } from './-debug-strip'; import { Assert } from './vm'; @@ -91,6 +92,7 @@ APPEND_OPCODES.add(VM_PUSH_REMOTE_ELEMENT_OP, (vm) => { let block = vm.tree().pushRemoteElement(element, guid, insertBefore); vm.associateDestroyable(block); + trackRemoteBlock(block); if (vm.env.debugRenderTree !== undefined) { // Note that there is nothing to update – when the args for an @@ -140,6 +142,7 @@ APPEND_OPCODES.add(VM_CLOSE_ELEMENT_OP, (vm) => { if (modifiers !== null) { modifiers.forEach((modifier) => { + vm.ownModifier(modifier); vm.env.scheduleInstallModifier(modifier); const { state } = modifier; const d = modifier.manager.getDestroyable(state); @@ -194,6 +197,7 @@ APPEND_OPCODES.add(VM_MODIFIER_OP, (vm, { op1: handle }) => { 'BUG: ElementModifier could not find operations to append to' ); + vm.trackUnownedModifier(instance); operations.addModifier(vm, instance, capturedArgs); let tag = manager.getTag(state); @@ -299,6 +303,7 @@ APPEND_OPCODES.add(VM_DYNAMIC_MODIFIER_OP, (vm) => { 'BUG: ElementModifier could not find operations to append to' ); + vm.trackUnownedModifier(instance); operations.addModifier(vm, instance, capturedArgs); tag = instance.manager.getTag(instance.state); diff --git a/packages/@glimmer/runtime/lib/component/error-boundary.ts b/packages/@glimmer/runtime/lib/component/error-boundary.ts new file mode 100644 index 00000000000..3efbfe06d9e --- /dev/null +++ b/packages/@glimmer/runtime/lib/component/error-boundary.ts @@ -0,0 +1,101 @@ +import type { Tag, UpdatableTag } from '@glimmer/interfaces'; +import type { Revision } from '@glimmer/validator/lib/validators'; +import { + CONSTANT_TAG, + DIRTY_TAG as dirtyTag, + validateTag, + valueForTag, +} from '@glimmer/validator/lib/validators'; +import { consumeTag } from '@glimmer/validator/lib/tracking'; +import { dirtyTagFor, tagFor } from '@glimmer/validator/lib/meta'; + +export interface ErrorBoundaryState { + error: unknown; + hasError: boolean; + setError(error: unknown, failedTag?: Tag): void; + retry(): void; + consumeFailedTag(): void; + shouldRetry(): boolean; +} + +export class ErrorBoundaryStateImpl implements ErrorBoundaryState { + private _error: unknown = null; + private _hasError = false; + + /** + * Everything the failed render consumed before it threw. When any of it + * changes, the render might succeed now, so the boundary retries. + */ + private failedTag: Tag = CONSTANT_TAG; + + /** Revision of `failedTag` when the error was caught. */ + private failedRevision: Revision = 0; + + get error(): unknown { + consumeTag(tagFor(this, '_error')); + return this._error; + } + + get hasError(): boolean { + consumeTag(tagFor(this, '_hasError')); + return this._hasError; + } + + setError(error: unknown, failedTag: Tag = CONSTANT_TAG) { + this._error = error; + // Use dirtyTag with disableConsumptionAssertion=true because setError + // is called from an error boundary catch handler, where the tag was + // already consumed during the (failed) render. This backflow is + // intentional for error boundary recovery. + dirtyTag(tagFor(this, '_error') as UpdatableTag, true); + this._hasError = true; + dirtyTag(tagFor(this, '_hasError') as UpdatableTag, true); + + // Snapshot after dirtying: the failed render read `hasError` itself, and + // that change must not count as a reason to retry. + this.failedTag = failedTag; + this.failedRevision = valueForTag(failedTag); + } + + /** + * Consume the failed render's tag so the enclosing cache group re-runs the + * boundary when any state read by the failed render changes. + */ + consumeFailedTag() { + if (this._hasError) { + consumeTag(this.failedTag); + } + } + + /** + * True when the boundary is in error state and state read by the failed + * render has changed since. Clears the error state so the caller can + * re-render the default block. + */ + shouldRetry(): boolean { + if (!this._hasError || validateTag(this.failedTag, this.failedRevision)) { + return false; + } + + this.clearError(); + return true; + } + + private clearError() { + this._error = null; + // Dirty with disableConsumptionAssertion=true because we may be + // inside a tracking frame that already consumed these tags. + dirtyTag(tagFor(this, '_error') as UpdatableTag, true); + this._hasError = false; + dirtyTag(tagFor(this, '_hasError') as UpdatableTag, true); + this.failedTag = CONSTANT_TAG; + } + + retry = () => { + this._error = null; + dirtyTagFor(this, '_error'); + this._hasError = false; + dirtyTagFor(this, '_hasError'); + this.failedTag = CONSTANT_TAG; + }; +} diff --git a/packages/@glimmer/runtime/lib/component/template-only.ts b/packages/@glimmer/runtime/lib/component/template-only.ts index 6871d658d27..2f93b89d3b5 100644 --- a/packages/@glimmer/runtime/lib/component/template-only.ts +++ b/packages/@glimmer/runtime/lib/component/template-only.ts @@ -17,6 +17,7 @@ const CAPABILITIES: InternalComponentCapabilities = { wrapped: false, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }; export class TemplateOnlyComponentManager implements InternalComponentManager { diff --git a/packages/@glimmer/runtime/lib/debug-render-tree.ts b/packages/@glimmer/runtime/lib/debug-render-tree.ts index a1994ffbe27..3fb02efe3b9 100644 --- a/packages/@glimmer/runtime/lib/debug-render-tree.ts +++ b/packages/@glimmer/runtime/lib/debug-render-tree.ts @@ -106,6 +106,16 @@ export default class DebugRenderTreeImpl< return this.captureRefs(this.roots); } + getDepth(): number { + return this.stack.size; + } + + rollbackTo(depth: number): void { + while (this.stack.size > depth) { + this.stack.pop(); + } + } + private reset(): void { if (this.stack.size !== 0) { // We probably encountered an error during the rendering loop. This will diff --git a/packages/@glimmer/runtime/lib/environment.ts b/packages/@glimmer/runtime/lib/environment.ts index bafbefe2f82..e0fb5fca30a 100644 --- a/packages/@glimmer/runtime/lib/environment.ts +++ b/packages/@glimmer/runtime/lib/environment.ts @@ -8,6 +8,7 @@ import type { GlimmerTreeConstruction, ModifierInstance, Nullable, + RenderAttempt, RuntimeArtifacts, RuntimeOptions, Transaction, @@ -25,26 +26,89 @@ import { isArgumentError } from './vm/arguments'; export const TRANSACTION: TransactionSymbol = Symbol('TRANSACTION') as TransactionSymbol; +/** One `{{#try}}` attempt's queued work, as `[queue, item]` pairs. */ +class RenderAttemptImpl implements RenderAttempt { + readonly entries: [unknown[], unknown][] = []; +} + class TransactionImpl implements Transaction { public scheduledInstallModifiers: ModifierInstance[] = []; public scheduledUpdateModifiers: ModifierInstance[] = []; public createdComponents: ComponentInstanceWithCreate[] = []; public updatedComponents: ComponentInstanceWithCreate[] = []; + /** + * Open attempts, innermost last. `null` is a root render's barrier: work an + * independent root queues belongs to no enclosing attempt. + */ + #attempts: Nullable[] = []; + didCreate(component: ComponentInstanceWithCreate) { - this.createdComponents.push(component); + this.#queue(this.createdComponents, component); } didUpdate(component: ComponentInstanceWithCreate) { - this.updatedComponents.push(component); + this.#queue(this.updatedComponents, component); } scheduleInstallModifier(modifier: ModifierInstance) { - this.scheduledInstallModifiers.push(modifier); + this.#queue(this.scheduledInstallModifiers, modifier); } scheduleUpdateModifier(modifier: ModifierInstance) { - this.scheduledUpdateModifiers.push(modifier); + this.#queue(this.scheduledUpdateModifiers, modifier); + } + + beginAttempt(): RenderAttemptImpl { + let attempt = new RenderAttemptImpl(); + this.#attempts.push(attempt); + return attempt; + } + + /** A successful attempt's work becomes its parent attempt's. */ + commitAttempt(attempt: RenderAttemptImpl) { + if (this.#popAttempt(attempt)) { + this.#attempts.at(-1)?.entries.push(...attempt.entries); + } + } + + abortAttempt(attempt: RenderAttemptImpl) { + if (this.#popAttempt(attempt)) { + for (let [queue, item] of attempt.entries) { + let index = queue.lastIndexOf(item); + + if (index !== -1) { + queue.splice(index, 1); + } + } + } + } + + beginRootRender() { + this.#attempts.push(null); + } + + endRootRender() { + while (this.#attempts.length > 0 && this.#attempts.pop() !== null) { + // Attempts left open inside the root render are abandoned with it. + } + } + + #queue(queue: T[], item: T) { + queue.push(item); + this.#attempts.at(-1)?.entries.push([queue, item]); + } + + /** Pops `attempt` and anything still open above it; false if it is not open. */ + #popAttempt(attempt: RenderAttemptImpl): boolean { + let index = this.#attempts.lastIndexOf(attempt); + + if (index === -1) { + return false; + } + + this.#attempts.length = index; + return true; } commit() { @@ -60,39 +124,64 @@ class TransactionImpl implements Transaction { let { scheduledInstallModifiers, scheduledUpdateModifiers } = this; + // Isolate each modifier install/update in a try-catch so that a single + // failing modifier does not prevent the remaining modifiers from being + // installed. Without this, all modifiers scheduled after the throwing one + // would be silently skipped, leaving their associated DOM elements without + // event listeners or other modifier behaviour. The first error encountered + // is re-thrown after all modifiers have been processed. + let firstError: unknown = null; + for (const { manager, state, definition } of scheduledInstallModifiers) { - let modifierTag = manager.getTag(state); - - if (modifierTag !== null) { - let tag = track( - () => manager.install(state), - DEBUG && - `- While rendering:\n (instance of a \`${ - definition.resolvedName || manager.getDebugName(definition.state) - }\` modifier)` - ); - updateTag(modifierTag, tag); - } else { - manager.install(state); + try { + let modifierTag = manager.getTag(state); + + if (modifierTag !== null) { + let tag = track( + () => manager.install(state), + DEBUG && + `- While rendering:\n (instance of a \`${ + definition.resolvedName || manager.getDebugName(definition.state) + }\` modifier)` + ); + updateTag(modifierTag, tag); + } else { + manager.install(state); + } + } catch (e) { + if (firstError === null) { + firstError = e; + } } } for (const { manager, state, definition } of scheduledUpdateModifiers) { - let modifierTag = manager.getTag(state); - - if (modifierTag !== null) { - let tag = track( - () => manager.update(state), - DEBUG && - `- While rendering:\n (instance of a \`${ - definition.resolvedName || manager.getDebugName(definition.state) - }\` modifier)` - ); - updateTag(modifierTag, tag); - } else { - manager.update(state); + try { + let modifierTag = manager.getTag(state); + + if (modifierTag !== null) { + let tag = track( + () => manager.update(state), + DEBUG && + `- While rendering:\n (instance of a \`${ + definition.resolvedName || manager.getDebugName(definition.state) + }\` modifier)` + ); + updateTag(modifierTag, tag); + } else { + manager.update(state); + } + } catch (e) { + if (firstError === null) { + firstError = e; + } } } + + if (firstError !== null) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw firstError; + } } } @@ -173,6 +262,26 @@ export class EnvironmentImpl implements Environment { } } + beginAttempt(): Nullable { + return this[TRANSACTION]?.beginAttempt() ?? null; + } + + commitAttempt(attempt: Nullable) { + if (attempt) this[TRANSACTION]?.commitAttempt(attempt as RenderAttemptImpl); + } + + abortAttempt(attempt: Nullable) { + if (attempt) this[TRANSACTION]?.abortAttempt(attempt as RenderAttemptImpl); + } + + beginRootRender() { + this[TRANSACTION]?.beginRootRender(); + } + + endRootRender() { + this[TRANSACTION]?.endRootRender(); + } + commit() { let transaction = this.transaction; this[TRANSACTION] = null; diff --git a/packages/@glimmer/runtime/lib/render.ts b/packages/@glimmer/runtime/lib/render.ts index 7061473ebe7..4f2251ddb49 100644 --- a/packages/@glimmer/runtime/lib/render.ts +++ b/packages/@glimmer/runtime/lib/render.ts @@ -21,6 +21,7 @@ import { debug } from '@glimmer/validator/lib/debug'; import { inTransaction } from './environment'; import { DynamicScopeImpl } from './scope'; import { VM } from './vm/append'; +import { beginRootBoundaryBarrier, endRootBoundaryBarrier } from './vm/update'; class TemplateIteratorImpl implements TemplateIterator { constructor(private vm: VM) {} @@ -41,7 +42,17 @@ class TemplateIteratorImpl implements TemplateIterator { export function renderSync(env: Environment, iterator: TemplateIterator): RenderResult { let result: RenderResult; - inTransaction(env, () => (result = iterator.sync())); + inTransaction(env, () => { + env.beginRootRender(); + beginRootBoundaryBarrier(); + + try { + result = iterator.sync(); + } finally { + endRootBoundaryBarrier(); + env.endRootRender(); + } + }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme return result!; diff --git a/packages/@glimmer/runtime/lib/vm/append.ts b/packages/@glimmer/runtime/lib/vm/append.ts index b27b704785b..550360bc010 100644 --- a/packages/@glimmer/runtime/lib/vm/append.ts +++ b/packages/@glimmer/runtime/lib/vm/append.ts @@ -10,6 +10,8 @@ import type { DynamicScope, Environment, EvaluationContext, + ModifierInstance, + Nullable, Owner, Program, ProgramConstants, @@ -25,7 +27,7 @@ import type { Reference } from '@glimmer/reference/lib/reference'; import type { MachineRegister, Register, SyscallRegister } from '@glimmer/vm/lib/registers'; import { dev, expect } from '@glimmer/debug-util/lib/platform-utils'; import { unwrapHandle } from '@glimmer/debug-util/lib/template'; -import { associateDestroyableChild } from '@glimmer/destroyable'; +import { associateDestroyableChild, destroy } from '@glimmer/destroyable'; import { DESTROYABLE_META_KEY } from '@glimmer/util/lib/destroyable-key'; import { assertGlobalContextWasSet } from '@glimmer/global-context'; import { LOCAL_DEBUG, LOCAL_TRACE_LOGGING } from '@glimmer/local-debug-flags'; @@ -34,7 +36,12 @@ import { UNDEFINED_REFERENCE } from '@glimmer/reference/lib/reference'; import { reverse } from '@glimmer/util/lib/array-utils'; import { StackImpl as Stack } from '@glimmer/util/lib/collections'; import { LOCAL_LOGGER } from '@glimmer/util'; -import { beginTrackFrame, endTrackFrame, resetTracking } from '@glimmer/validator/lib/tracking'; +import { + beginTrackFrame, + endTrackFrame, + isInErrorBoundary, + resetTracking, +} from '@glimmer/validator/lib/tracking'; import { $pc, isLowLevelRegister } from '@glimmer/vm/lib/registers'; import type { ScopeOptions } from '../scope'; @@ -738,6 +745,98 @@ export class VM { /// EXECUTION + /** + * Execute the VM for an error boundary sub-VM. Unlike `execute`, this does + * NOT wrap in a tracking transaction or call `resetTracking()` on error, + * which would destroy the parent VM's tracking state. It only cleans up + * open blocks on error before re-throwing. + */ + /** + * Execute the VM without resetting the parent's tracking state on error. + * Used by TryOpcode re-renders — popBlock/finalize inserts placeholder + * comments (), which is correct for conditional blocks. + */ + executeGuarded(initialize?: (vm: this) => void): RenderResult { + try { + return this._execute(initialize); + } catch (e) { + if (isInErrorBoundary()) { + // A boundary recovers from this error and removes the DOM by range, + // so the destroyables this render created are unreachable otherwise. + destroy(this.#stacks.drop); + } + + this.#releaseUnownedModifiers(); + let elements = this.tree(); + while (elements.hasBlocks) { + elements.popBlock(); + } + throw e; + } + } + + /** + * Execute the VM for an ErrorBoundary sub-VM. Unlike executeGuarded, this + * drops blocks without finalize (the DOM is discarded by the error boundary) + * and destroys the sub-VM's destroyable root to clean up RemoteBlocks. + */ + executeErrorBoundary(initialize?: (vm: this) => void): RenderResult { + try { + return this._execute(initialize); + } catch (e) { + // Drop open blocks without finalizing — finalize() inserts placeholder + // comments, but the DOM is being discarded by the error boundary anyway. + this.tree().dropBlocks(); + // Destroy the sub-VM's destroyable root to clean up any associated + // resources (e.g. RemoteBlocks from {{#in-element}}). + destroy(this.#stacks.drop); + this.#releaseUnownedModifiers(); + throw e; + } + } + + /** + * Modifiers created by a boundary attempt that are not yet owned by a + * destroyable. One is created while its element's attributes run and is only + * associated when the element closes; an attempt that throws in between must + * destroy it. + */ + #unownedModifiers: Nullable> = null; + + trackUnownedModifier(modifier: ModifierInstance): void { + if (isInErrorBoundary()) { + (this.#unownedModifiers ??= new Set()).add(modifier); + } + } + + ownModifier(modifier: ModifierInstance): void { + this.#unownedModifiers?.delete(modifier); + } + + #releaseUnownedModifiers(): void { + let modifiers = this.#unownedModifiers; + this.#unownedModifiers = null; + + modifiers?.forEach(({ manager, state }) => { + let destroyable = manager.getDestroyable(state); + + if (destroyable !== null) { + destroy(destroyable); + } + + // The debug render tree entry, as `VM_CLOSE_ELEMENT_OP` would have + // associated it. + if ( + this.env.debugRenderTree !== undefined && + destroyable !== state && + state !== null && + (typeof state === 'object' || typeof state === 'function') + ) { + destroy(state); + } + }); + } + execute(initialize?: (vm: this) => void): RenderResult { if (DEBUG) { let hasErrored = true; diff --git a/packages/@glimmer/runtime/lib/vm/element-builder.ts b/packages/@glimmer/runtime/lib/vm/element-builder.ts index 718646dd5bc..7a11146f0fa 100644 --- a/packages/@glimmer/runtime/lib/vm/element-builder.ts +++ b/packages/@glimmer/runtime/lib/vm/element-builder.ts @@ -108,6 +108,27 @@ export class NewTreeBuilder implements TreeBuilder { return stack; } + /** + * Creates a tree builder that renders into a fresh (empty) resettable block. + * Unlike `resume`, this does NOT call `block.reset()`, so it's safe for + * blocks that have never been rendered or have partially-initialized children. + * + * @param nextSibling - Optional insertion point. When provided, new content + * is inserted before this node instead of appended at the end. Used by + * error boundary recovery to maintain correct position among siblings. + */ + static beginBlock( + env: Environment, + block: ResettableBlock, + nextSibling?: Nullable + ): NewTreeBuilder { + let parentNode = block.parentElement(); + let stack = new this(env, parentNode, nextSibling ?? null).initialize(); + stack.pushBlock(block); + + return stack; + } + constructor(env: Environment, parentNode: SimpleElement, nextSibling: Nullable) { this.pushElement(parentNode, nextSibling); this.env = env; @@ -187,6 +208,19 @@ export class NewTreeBuilder implements TreeBuilder { return expect(this.blockStack.pop(), 'Expected popBlock to return a block'); } + /** + * Drop all blocks from the stack without finalizing them. + * Used during error cleanup where the DOM is being discarded anyway — + * finalize() would insert placeholder comments into blocks that are + * about to be destroyed. + */ + dropBlocks(): void { + while (this.blockStack.size > 0) { + this.__closeBlock(); + this.blockStack.pop(); + } + } + __openBlock(): void {} __closeBlock(): void {} @@ -250,7 +284,7 @@ export class NewTreeBuilder implements TreeBuilder { } } - let block = new RemoteBlock(element); + let block = new RemoteBlock(element, insertBefore ?? null); return this.pushBlock(block, true); } @@ -259,6 +293,7 @@ export class NewTreeBuilder implements TreeBuilder { const block = this.popBlock(); assert(block instanceof RemoteBlock, '[BUG] expecting a RemoteBlock'); this.popElement(); + block.didRender(); return block; } @@ -475,12 +510,30 @@ export class AppendingBlockImpl implements AppendingBlock { } export class RemoteBlock extends AppendingBlockImpl { - constructor(parent: SimpleElement) { + /** + * The nodes just outside this block's content in the destination, kept so a + * `{{#try}}` can remove the content by range even when the bounds of a + * failed render are only partly initialized. `#right` is `null` at the end. + */ + #left: Nullable; + #right: Nullable; + + /** Set once a `{{#try}}` removed the content, so destruction does not repeat it. */ + #cleared = false; + + constructor(parent: SimpleElement, insertBefore: Nullable = null) { super(parent); + this.#right = insertBefore; + this.#left = insertBefore ? insertBefore.previousSibling : parent.lastChild; + setLocalDebugType('block:remote', this); registerDestructor(this, () => { + if (this.#cleared) { + return; + } + // In general, you only need to clear the root of a hierarchy, and should never // need to clear any child nodes. This is an important constraint that gives us // a strong guarantee that clearing a subtree is a single DOM operation. @@ -510,6 +563,66 @@ export class RemoteBlock extends AppendingBlockImpl { } }); } + + /** After a complete render the bounds are valid, so re-anchor on them. */ + didRender(): void { + this.#left = this.firstNode().previousSibling; + this.#right = this.lastNode().nextSibling; + } + + /** + * Removes this block's content synchronously, by range, for a `{{#try}}` + * that is discarding it. Uses the block's own bounds when they are complete, + * so later content in a shared destination survives, and the anchors saved + * at the last complete render when a failed render left them partial. Does + * nothing when the left anchor was itself removed, rather than guess at a + * range that might hold unrelated content. + */ + clearForAbort(): void { + if (this.#cleared) { + return; + } + + let parent = this.parentElement(); + let left = this.#left; + let right = this.#right; + + try { + let first = this.firstNode(); + let last = this.lastNode(); + + // An enclosing remote block's range already took this content with it. + if (last.parentNode !== parent) { + this.#cleared = true; + return; + } + + right = last.nextSibling; + + // A nested `{{#in-element}}` without `insertBefore` empties the + // destination, detaching the front of this block's range: clear from + // the saved left anchor instead. + if (first.parentNode === parent) { + left = first.previousSibling; + } + } catch { + // A failed render left the bounds partial: keep the saved anchors. + } + + if (left !== null && left.parentNode !== parent) { + return; + } + + let node = left ? left.nextSibling : parent.firstChild; + + while (node !== null && node !== right) { + let next = node.nextSibling; + parent.removeChild(node); + node = next; + } + + this.#cleared = true; + } } export class ResettableBlockImpl extends AppendingBlockImpl implements ResettableBlock { @@ -520,14 +633,30 @@ export class ResettableBlockImpl extends AppendingBlockImpl implements Resettabl reset(): Nullable { destroy(this); - let nextSibling = clear(this); + let nextSibling = this.first ? clear(this) : null; this.first = null; this.last = null; this.nesting = 0; - return nextSibling; } + + /** + * Reset the block's internal tracking state without touching the DOM. + * Used during error boundary recovery when the DOM has already been + * cleaned up manually (because child bounds may be partially initialized). + * + * Note: destroy(this) marks the block as destroyed and runs child + * destructors, but the block itself is then reused for DOM position + * tracking only. New destroyable children are associated with the + * ErrorBoundaryOpcode, not this block, so the destroyed state is safe. + */ + resetPartial(): void { + destroy(this); + this.first = null; + this.last = null; + this.nesting = 0; + } } // FIXME: All the noops in here indicate a modelling problem diff --git a/packages/@glimmer/runtime/lib/vm/render-result.ts b/packages/@glimmer/runtime/lib/vm/render-result.ts index a2696846909..8d92abb39be 100644 --- a/packages/@glimmer/runtime/lib/vm/render-result.ts +++ b/packages/@glimmer/runtime/lib/vm/render-result.ts @@ -47,4 +47,8 @@ export default class RenderResultImpl implements RenderResult { handleException() { unreachable(`this should never happen`); } + + handleCaughtError(): boolean { + unreachable(`this should never happen`); + } } diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index ca239649fd3..bcb6bc72c47 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -8,29 +8,49 @@ import type { ExceptionHandler, GlimmerTreeChanges, Nullable, + RenderAttempt, ResettableBlock, Scope, SimpleComment, + SimpleNode, + Tag, UpdatingOpcode, UpdatingVM as IUpdatingVM, } from '@glimmer/interfaces'; import type { OpaqueIterationItem, OpaqueIterator } from '@glimmer/reference/lib/iterable'; import type { Reference } from '@glimmer/reference/lib/reference'; -import { expect, unwrap } from '@glimmer/debug-util/lib/platform-utils'; -import { associateDestroyableChild, destroy, destroyChildren } from '@glimmer/destroyable'; +import { expect, unreachable, unwrap } from '@glimmer/debug-util/lib/platform-utils'; +import { + associateDestroyableChild, + destroy, + destroyChildren, + registerDestructor, +} from '@glimmer/destroyable'; import { DESTROYABLE_META_KEY } from '@glimmer/util/lib/destroyable-key'; import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; import { updateRef, valueForRef } from '@glimmer/reference/lib/reference'; import { logStep } from '@glimmer/util/lib/debug-steps'; import { StackImpl as Stack } from '@glimmer/util/lib/collections'; import { debug } from '@glimmer/validator/lib/debug'; -import { resetTracking } from '@glimmer/validator/lib/tracking'; - +import { + beginErrorBoundary, + beginTrackFrame, + consumeTag, + endErrorBoundary, + endTrackFrame, + getTrackingDepth, + resetTracking, + unwindTrackingTo, +} from '@glimmer/validator/lib/tracking'; + +import type { SimpleElement } from '@simple-dom/interface'; + +import type { ErrorBoundaryState } from '../component/error-boundary'; import type { Closure } from './append'; import type { AppendingBlockList } from './element-builder'; import { clear, move as moveBounds } from '../bounds'; -import { NewTreeBuilder } from './element-builder'; +import { NewTreeBuilder, RemoteBlock } from './element-builder'; export class UpdatingVM implements IUpdatingVM { public env: Environment; @@ -38,6 +58,7 @@ export class UpdatingVM implements IUpdatingVM { public alwaysRevalidate: boolean; private frameStack: Stack = new Stack(); + private _errorBoundaryDepth = 0; constructor(env: Environment, { alwaysRevalidate = false }) { this.env = env; @@ -74,18 +95,78 @@ export class UpdatingVM implements IUpdatingVM { this.try(opcodes, handler); + try { + this._run(); + } finally { + // An error nobody caught abandons the remaining frames. + while (!frameStack.isEmpty()) { + this._popFrame(); + } + } + } + + private _run() { + let { frameStack } = this; + while (!frameStack.isEmpty()) { let opcode = this.frame.nextStatement(); if (opcode === undefined) { - frameStack.pop(); + this._popFrame(true); continue; } - opcode.evaluate(this); + if (this._errorBoundaryDepth > 0) { + // Only pay the try-catch cost when inside an error boundary. + let trackingDepth = getTrackingDepth(); + + try { + opcode.evaluate(this); + } catch (error) { + // Unwind tracking frames to the depth before the failed opcode, + // folding what they consumed into the enclosing frame. The error + // boundary that handles the error collects it from there. + consumeTag(unwindTrackingTo(trackingDepth)); + + // Walk up the frame stack to find an error boundary that can handle + // JavaScript errors. + let handled = false; + let current: unknown = error; + + while (!frameStack.isEmpty()) { + try { + if (this.frame.handleCaughtError(current)) { + this._popFrame(); + handled = true; + break; + } + } catch (fallbackError) { + // The boundary's fallback threw while it recovered: that error + // keeps propagating to the boundaries further out. + current = fallbackError; + } + this._popFrame(); + } + + if (!handled) { + throw current; + } + } + } else { + opcode.evaluate(this); + } } } + private _popFrame(completed = false) { + let frame = this.frameStack.current; + if (frame && frame.isErrorBoundary) { + this._errorBoundaryDepth--; + frame.didExit(completed); + } + this.frameStack.pop(); + } + private get frame() { return expect(this.frameStack.current, 'bug: expected a frame'); } @@ -98,9 +179,22 @@ export class UpdatingVM implements IUpdatingVM { this.frameStack.push(new UpdatingVMFrame(ops, handler)); } + /** + * Push an error boundary's frame. `onComplete` runs only when every opcode + * in the frame has been evaluated, not when an error or a re-render unwinds it. + */ + tryErrorBoundary( + ops: UpdatingOpcode[], + handler: ExceptionHandler, + onExit: (completed: boolean) => void + ) { + this._errorBoundaryDepth++; + this.frameStack.push(new UpdatingVMFrame(ops, handler, true, onExit)); + } + throw() { this.frame.handleException(); - this.frameStack.pop(); + this._popFrame(); } } @@ -154,6 +248,10 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { vm.try(this.children, this); } + handleCaughtError(_error: unknown): boolean { + unreachable('handleCaughtError called on TryOpcode'); + } + handleException() { let { state, @@ -168,7 +266,13 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { let children = (this.children = []); - let result = vm.execute((vm) => { + // Use executeGuarded() instead of execute() to prevent resetTracking() + // from being called if the re-render throws. resetTracking() wipes ALL + // tracking state (including parent frames), which corrupts the UpdatingVM's + // tracking context and causes "attempted to close a tracking frame, but one + // was not open" errors. With executeGuarded(), errors propagate to the + // UpdatingVM's catch handler, which can route them to an error boundary. + let result = vm.executeGuarded((vm) => { vm.updateWith(this); vm.pushUpdating(children); }); @@ -177,6 +281,421 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { } } +/** + * Remove all DOM nodes from `from` up to (but not including) `to`. + * If `to` is null, removes all remaining siblings after `from`. + */ +export function clearDOMRange( + parent: SimpleElement, + from: SimpleNode | null, + to: SimpleNode | null +): void { + let current = from; + while (current && current !== to) { + let next: SimpleNode | null = current.nextSibling; + parent.removeChild(current); + current = next; + } +} + +/** + * The boundaries currently rendering, innermost last. Each one, not just the + * innermost, must be able to remove the remote content rendered under it. + * `null` is a root render's barrier: an independent root rendered from inside + * an attempt owns its remote content, so no enclosing boundary may remove it. + */ +const ACTIVE_BOUNDARIES: Nullable[] = []; + +/** Registers a `{{#in-element}}` block with every boundary rendering it. */ +export function trackRemoteBlock(block: Bounds): void { + if (!(block instanceof RemoteBlock)) { + return; + } + + for (let i = ACTIVE_BOUNDARIES.length - 1; i >= 0; i--) { + let boundary = ACTIVE_BOUNDARIES[i]; + + if (!boundary) { + return; + } + + boundary.trackRemoteBlock(block); + } +} + +export function beginRootBoundaryBarrier(): void { + ACTIVE_BOUNDARIES.push(null); +} + +export function endRootBoundaryBarrier(): void { + ACTIVE_BOUNDARIES.splice(ACTIVE_BOUNDARIES.lastIndexOf(null)); +} + +export class ErrorBoundaryOpcode extends TryOpcode { + public type = 'error-boundary'; + + // Cache DOM references from the last successful render so we can clean up + // even when the bounds tree is corrupted by a failed inner re-render. + // We cache the first node, its previous sibling, and the nextSibling AFTER + // the last node, so that cleanup removes everything from firstNode up to + // (but not including) nextSibling — including any dynamically inserted nodes + // like list markers. The previousSibling is needed because inner TryOpcodes + // may detach cachedFirstNode from the DOM via bounds.reset() before the error + // reaches us. + private cachedFirstNode: SimpleNode | null = null; + private cachedPreviousSibling: SimpleNode | null = null; + private cachedNextSibling: SimpleNode | null = null; + private cachedRenderTreeDepth = 0; + private cachedTrackingDepth = 0; + + /** Lifecycle work queued by the children while they update. */ + #childrenAttempt: Nullable = null; + + /** + * Whether the rendered content is the `<:try>` block, whose errors this + * boundary catches. A fallback's own errors propagate to the next boundary. + */ + #protecting = false; + + /** + * `{{#in-element}}` blocks rendered under this boundary. Their destructors + * run later, after a fallback may already render into the same destination, + * so the boundary removes their content itself when it discards them. + */ + #remoteBlocks = new Set(); + + constructor( + state: Closure, + context: EvaluationContext, + bounds: ResettableBlock, + children: UpdatingOpcode[], + private errorState: ErrorBoundaryState + ) { + super(state, context, bounds, children); + } + + override evaluate(vm: UpdatingVM) { + // Snapshot current DOM boundaries before child opcodes run. + if (LOCAL_DEBUG) { + expect( + this.bounds.firstNode(), + 'BUG: ErrorBoundaryOpcode.evaluate() called with uninitialized bounds' + ); + } + this.cachedFirstNode = this.bounds.firstNode(); + this.cachedPreviousSibling = this.cachedFirstNode.previousSibling; + this.cachedNextSibling = this.bounds.lastNode().nextSibling; + this.cachedRenderTreeDepth = vm.env.debugRenderTree?.getDepth() ?? 0; + this.cachedTrackingDepth = getTrackingDepth(); + + // Always consume hasError so its tag is captured in the EB's tracking + // frame. Without this, after error recovery the EB's JumpIfNotModified + // combined tag would lose hasError and never detect future changes. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this.errorState.hasError; + + // While in error state, keep the failed render's tag in the EB's tracking + // frame so a change to anything it read brings us back here. + this.errorState.consumeFailedTag(); + + // Retry if state read by the failed render has changed since. + if (this.errorState.shouldRetry()) { + this.handleException(); + return; + } + + // Track the children in a frame of our own, so that if one of them throws, + // handleCaughtError can recover everything they consumed before the throw. + this.#protecting = !this.errorState.hasError; + + beginTrackFrame(); + beginErrorBoundary(); + this.enter(); + this.#childrenAttempt = this.context.env.beginAttempt(); + vm.tryErrorBoundary(this.children, this, this.didExitChildren); + } + + private didExitChildren = (completed: boolean) => { + this.exit(); + endErrorBoundary(); + + if (completed) { + this.context.env.commitAttempt(this.#childrenAttempt); + consumeTag(endTrackFrame()); + } else { + this.context.env.abortAttempt(this.#childrenAttempt); + } + }; + + trackRemoteBlock(block: RemoteBlock): void { + this.#remoteBlocks.add(block); + registerDestructor(block, () => this.#remoteBlocks.delete(block)); + } + + /** Marks this boundary as rendering, for `trackRemoteBlock`. */ + enter(): void { + ACTIVE_BOUNDARIES.push(this); + } + + exit(): void { + ACTIVE_BOUNDARIES.splice(ACTIVE_BOUNDARIES.lastIndexOf(this), 1); + } + + /** Removes the content of every remote block this boundary rendered, synchronously. */ + clearRemoteBlocks(): void { + for (let block of this.#remoteBlocks) { + block.clearForAbort(); + } + + this.#remoteBlocks.clear(); + } + + /** + * Restore tracking frames and consumed tags to the state captured in + * evaluate(), before children ran. This prevents stale tracking frames + * (opened by BeginTrackFrameOpcode but never closed) from leaking into + * OPEN_TRACK_FRAMES / TRANSACTION_STACK, which would cause spurious + * backtracking assertions on later mutations. + */ + private restoreTracking() { + this.unwindTracking(); + } + + /** + * Like restoreTracking(), but returns a tag combining everything the + * unwound frames consumed. + */ + private unwindTracking(): Tag { + let tag = unwindTrackingTo(this.cachedTrackingDepth); + if (DEBUG) { + debug.resetConsumedTags?.(); + } + return tag; + } + + /** + * Resolve the start node for DOM cleanup. Handles the case where + * cachedFirstNode may have been detached by an inner TryOpcode's + * bounds.reset(), falling back to the cached previousSibling or + * the parent's firstChild. + */ + private resolveCachedStart(parent: SimpleElement): SimpleNode | null { + if (this.cachedFirstNode && this.cachedFirstNode.parentNode === parent) { + return this.cachedFirstNode; + } else if (this.cachedPreviousSibling) { + return this.cachedPreviousSibling.nextSibling; + } else { + return parent.firstChild; + } + } + + /** + * Re-render the boundary's template into its block. Used by both the + * happy-path re-render (handleException) and error-state transitions + * (handleCaughtError). Assumes the block has already been cleaned up + * (destroyChildren + DOM cleared + resetPartial). + */ + private renderIntoBlock(): void { + let { + bounds, + context: { env }, + } = this; + let attempt = env.beginAttempt(); + let completed = false; + + this.#protecting = !this.errorState.hasError; + beginErrorBoundary(); + this.enter(); + + try { + let tree = NewTreeBuilder.beginBlock(env, bounds, this.cachedNextSibling); + let vm = this.state.evaluate(tree); + let children = (this.children = []); + // A failed attempt's destroyables are unreachable from here, so the + // sub-VM destroys them itself. + let result = vm.executeErrorBoundary((vm) => { + vm.updateWith(this); + vm.pushUpdating(children); + }); + associateDestroyableChild(this, result.drop); + completed = true; + } finally { + this.exit(); + endErrorBoundary(); + + if (completed) { + env.commitAttempt(attempt); + } else { + env.abortAttempt(attempt); + } + } + } + + /** + * Null out cached DOM references after an error transition. The cached + * nodes pointed at pre-error content which has been removed; fresh + * values are captured in the next evaluate(). + */ + private clearCachedNodes(): void { + this.cachedFirstNode = null; + this.cachedPreviousSibling = null; + this.cachedNextSibling = null; + } + + override handleException() { + let { + bounds, + context: { env }, + } = this; + let parent = bounds.parentElement(); + + this.context.env.abortAttempt(this.#childrenAttempt); + this.restoreTracking(); + let trackingDepth = this.cachedTrackingDepth; + + this.clearRemoteBlocks(); + destroyChildren(this); + + // Clear DOM directly using cached node references instead of + // NewTreeBuilder.resume() (which walks the bounds delegation chain). + // The delegation chain — block.firstNode() → child.firstNode() → ... — + // can become stale when inner TryOpcodes or ListBlockOpcodes modify + // their blocks during the same update cycle, leading to crashes or + // incomplete DOM cleanup. Using concrete cached references avoids this. + if (this.cachedFirstNode) { + clearDOMRange(parent, this.resolveCachedStart(parent), this.cachedNextSibling); + } + + // Reset block state without DOM cleanup (already done above). + bounds.resetPartial(); + + beginTrackFrame(); + + try { + this.renderIntoBlock(); + consumeTag(endTrackFrame()); + } catch (error) { + // Unwind tracking frames opened by the failed re-render attempt, + // keeping what they consumed. + let failedTag = unwindTrackingTo(trackingDepth); + + if (!this.#protecting) { + // The fallback itself failed: remove it and let the error propagate, + // keeping what it read for the boundary that catches it. + consumeTag(failedTag); + this.discardFailedRender(); + throw error; + } + + this.clearRemoteBlocks(); + + // Roll back stale debug render tree entries. + env.debugRenderTree?.rollbackTo(this.cachedRenderTreeDepth); + + // Remove partial DOM nodes left by the failed re-render. + let start: SimpleNode | null = this.cachedPreviousSibling + ? this.cachedPreviousSibling.nextSibling + : parent.firstChild; + clearDOMRange(parent, start, this.cachedNextSibling); + + bounds.resetPartial(); + + if (DEBUG) { + // eslint-disable-next-line no-console + console.error('An error was caught by :', error); + } + this.errorState.setError(error, failedTag); + this.errorState.consumeFailedTag(); + + this.renderFallback(); + } + } + + /** Renders the fallback after a failure; if it throws too, removes it and rethrows. */ + private renderFallback(): void { + let depth = getTrackingDepth(); + + try { + this.renderIntoBlock(); + } catch (fallbackError) { + // Keep what the failed fallback read: the boundary that catches this + // error retries when any of it changes. + consumeTag(unwindTrackingTo(depth)); + this.discardFailedRender(); + throw fallbackError; + } finally { + this.clearCachedNodes(); + } + } + + /** Removes a render that threw, by DOM range: its remote content, DOM and debug nodes. */ + private discardFailedRender(): void { + let parent = this.bounds.parentElement(); + + this.clearRemoteBlocks(); + destroyChildren(this); + this.context.env.debugRenderTree?.rollbackTo(this.cachedRenderTreeDepth); + + let start: SimpleNode | null = this.cachedPreviousSibling + ? this.cachedPreviousSibling.nextSibling + : parent.firstChild; + clearDOMRange(parent, start, this.cachedNextSibling); + + this.bounds.resetPartial(); + this.#protecting = false; + } + + /** + * Handle a JavaScript error that escaped during updating evaluation. + * Called directly by the UpdatingVM when a JS exception occurs, + * skipping inner TryOpcode handlers that would corrupt block state. + */ + handleCaughtError(error: unknown): boolean { + if (!this.#protecting) { + return false; + } + + let { + bounds, + context: { env }, + } = this; + + this.context.env.abortAttempt(this.#childrenAttempt); + let failedTag = this.unwindTracking(); + + this.clearRemoteBlocks(); + destroyChildren(this); + + if (DEBUG) { + // eslint-disable-next-line no-console + console.error('An error was caught by :', error); + } + this.errorState.setError(error, failedTag); + this.errorState.consumeFailedTag(); + + // Clean up DOM manually rather than using bounds.reset() (via resume()), + // because the bounds tree may be corrupted: inner TryOpcodes or + // ListBlockOpcodes can leave child blocks with null first/last pointers, + // and list sync may have inserted temporary marker nodes outside the + // bounds tree. Walking the DOM directly using cached node references + // handles both cases. + let parent = bounds.parentElement(); + + if (this.cachedFirstNode) { + clearDOMRange(parent, this.resolveCachedStart(parent), this.cachedNextSibling); + } + + // Roll back the debug render tree stack to discard stale entries left by + // DebugRenderTreeUpdateOpcodes that pushed but never got their matching + // DebugRenderTreeDidRenderOpcode pop due to the error. + env.debugRenderTree?.rollbackTo(this.cachedRenderTreeDepth); + + bounds.resetPartial(); + this.renderFallback(); + return true; + } +} + export class ListItemOpcode extends TryOpcode { public retained = false; public index = -1; @@ -366,7 +885,7 @@ export class ListBlockOpcode extends BlockOpcode { let vm = state.evaluate(elementStack); - vm.execute((vm) => { + vm.executeGuarded((vm) => { let opcode = vm.enterItem(item); opcode.index = children.length; @@ -431,9 +950,18 @@ class UpdatingVMFrame { constructor( private ops: UpdatingOpcode[], - private exceptionHandler: Nullable + private exceptionHandler: Nullable, + readonly isErrorBoundary = false, + private onExit: ((completed: boolean) => void) | null = null ) {} + /** Runs once when the frame is popped, whether its opcodes completed or not. */ + didExit(completed: boolean) { + let onExit = this.onExit; + this.onExit = null; + onExit?.(completed); + } + goto(index: number) { this.current = index; } @@ -447,4 +975,16 @@ class UpdatingVMFrame { this.exceptionHandler.handleException(); } } + + /** + * Try to handle a JavaScript error (not a tracked-value change). + * Returns true if the handler accepted the error, false otherwise. + * Only error boundary handlers accept JavaScript errors. + */ + handleCaughtError(error: unknown): boolean { + if (this.isErrorBoundary && this.exceptionHandler) { + return this.exceptionHandler.handleCaughtError(error); + } + return false; + } } diff --git a/packages/@glimmer/validator/index.ts b/packages/@glimmer/validator/index.ts index 920b34148d7..79b94013c3b 100644 --- a/packages/@glimmer/validator/index.ts +++ b/packages/@glimmer/validator/index.ts @@ -31,10 +31,13 @@ export { createCache, endTrackFrame, endUntrackFrame, + getTrackingDepth, getValue, isConst, isTracking, resetTracking, + restoreTrackingTo, + unwindTrackingTo, track, untrack, } from './lib/tracking'; diff --git a/packages/@glimmer/validator/lib/debug.ts b/packages/@glimmer/validator/lib/debug.ts index 154acaee33a..bc1b3a717da 100644 --- a/packages/@glimmer/validator/lib/debug.ts +++ b/packages/@glimmer/validator/lib/debug.ts @@ -12,6 +12,7 @@ interface DebugTransaction { runInTrackingTransaction?: undefined | ((fn: () => T, debuggingContext?: string | false) => T); resetTrackingTransaction?: undefined | (() => string); + resetConsumedTags?: undefined | (() => void); setTrackingTransactionEnv?: | undefined | ((env: { debugMessage?(obj?: unknown, keyName?: string): string }) => void); @@ -88,6 +89,19 @@ if (DEBUG) { } }; + /** + * Replace CONSUMED_TAGS with a fresh WeakMap, discarding all entries. + * Used during error boundary recovery: tracking frames from a failed render + * are popped via restoreTrackingTo(), but their consumed-tag entries persist + * in the WeakMap because it can't be selectively pruned. Without this, + * stale entries cause spurious backtracking assertions on later mutations. + */ + debug.resetConsumedTags = () => { + if (TRANSACTION_STACK.length > 0) { + CONSUMED_TAGS = new WeakMap(); + } + }; + debug.resetTrackingTransaction = () => { let stack = ''; diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index d94ae456e6b..9496598df06 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -95,6 +95,76 @@ export function endUntrackFrame(): void { CURRENT_TRACKER = OPEN_TRACK_FRAMES.pop() || null; } +/** + * Return the current depth of the tracking frame stack. Used together with + * `restoreTrackingTo` for error boundary rollback: save the depth before a + * sub-VM runs and restore it if the sub-VM throws, discarding any stale + * tracking frames from the failed render. + */ +export function getTrackingDepth(): number { + return OPEN_TRACK_FRAMES.length; +} + +/** + * Pop tracking frames (and their associated DEBUG tracking transactions) back + * to the given depth. This discards stale frames left by a failed render. + * + * OPEN_TRACK_FRAMES stores the *previous* CURRENT_TRACKER when each frame was + * opened. So the entry at index N is the tracker that was active at depth N + * (the tracker that was current before depth N+1 was opened). When popping + * back to `depth`, the last popped entry is the tracker that was active at + * the target depth — that's what CURRENT_TRACKER should be restored to. + */ +export function restoreTrackingTo(depth: number): void { + unwindTrackingTo(depth); +} + +/** + * Pop tracking frames back to the given depth like `restoreTrackingTo`, but + * keep what they consumed: returns a tag combining every tag consumed by the + * popped frames. An error boundary uses it to learn which tracked state a + * failed render read before it threw, so it can retry when that state changes. + */ +export function unwindTrackingTo(depth: number): Tag { + let tags: Tag[] = []; + + while (OPEN_TRACK_FRAMES.length > depth) { + // CURRENT_TRACKER is the innermost open frame, the one being discarded. + // It is null inside an untrack frame, which consumed nothing. + if (CURRENT_TRACKER !== null) { + tags.push(CURRENT_TRACKER.combine()); + } + + // Each popped entry is the CURRENT_TRACKER that was saved when + // beginTrackFrame opened the next deeper frame. The last popped + // entry is the tracker for the target depth. + CURRENT_TRACKER = OPEN_TRACK_FRAMES.pop() as Tracker | null; + if (DEBUG) { + unwrap(debug.endTrackingTransaction)(); + } + } + + return combine(tags); +} + +/** + * How many error boundaries are currently rendering. Work that only matters + * when a render can be recovered from is skipped while there are none. + */ +let ERROR_BOUNDARY_DEPTH = 0; + +export function beginErrorBoundary(): void { + ERROR_BOUNDARY_DEPTH++; +} + +export function endErrorBoundary(): void { + ERROR_BOUNDARY_DEPTH--; +} + +export function isInErrorBoundary(): boolean { + return ERROR_BOUNDARY_DEPTH > 0; +} + // This function is only for handling errors and resetting to a valid state export function resetTracking(): string | void { while (OPEN_TRACK_FRAMES.length > 0) { @@ -234,11 +304,21 @@ export function track(block: () => void, debugLabel?: string | false): Tag { beginTrackFrame(debugLabel); let tag; + let threw = true; try { block(); + threw = false; } finally { tag = endTrackFrame(); + + // A block that throws still depended on what it read before throwing. + // Hand that to the parent frame, so an error boundary can retry when it + // changes. A flag rather than catch + rethrow keeps "pause on uncaught + // exceptions" pointing at the original throw. + if (threw) { + consumeTag(tag); + } } return tag; diff --git a/packages/@glimmer/vm/lib/flags.ts b/packages/@glimmer/vm/lib/flags.ts index 7ca9922c12f..9f9831ea29c 100644 --- a/packages/@glimmer/vm/lib/flags.ts +++ b/packages/@glimmer/vm/lib/flags.ts @@ -9,6 +9,7 @@ import type { DynamicTagCapability, ElementHookCapability, EmptyCapability, + ErrorBoundaryCapability, HasSubOwnerCapability, MACHINE_MASK as IMACHINE_MASK, MAX_SIZE as IMAX_SIZE, @@ -36,6 +37,7 @@ export const InternalComponentCapabilities = { wrapped: 0b0010000000000 satisfies WrappedCapability, willDestroy: 0b0100000000000 satisfies WillDestroyCapability, hasSubOwner: 0b1000000000000 satisfies HasSubOwnerCapability, + errorBoundary: 0b10000000000000 satisfies ErrorBoundaryCapability, } as const; export const ARG_SHIFT = 8 as const satisfies IARG_SHIFT;