From a12b4f1a4504ec45b768caa6514e1fbf4274b775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Sat, 28 Feb 2026 09:56:21 -0300 Subject: [PATCH 01/25] First draft --- packages/@ember/-internals/glimmer/index.ts | 1 + .../glimmer/lib/component-managers/curly.ts | 1 + .../glimmer/lib/component-managers/mount.ts | 1 + .../glimmer/lib/component-managers/outlet.ts | 1 + .../glimmer/lib/component-managers/root.ts | 1 + .../lib/component-managers/route-template.ts | 1 + .../glimmer/lib/components/error-boundary.ts | 92 +++++++++++++++++++ .../glimmer/lib/components/internal.ts | 1 + .../-internals/glimmer/lib/setup-registry.ts | 2 + .../glimmer/lib/templates/error-boundary.ts | 11 +++ packages/@ember/component/index.ts | 2 +- .../lib/components/emberish-curly.ts | 1 + .../integration-tests/test/owner-test.ts | 1 + packages/@glimmer/component/src/index.ts | 2 + .../@glimmer/constants/lib/syscall-ops.ts | 4 +- .../@glimmer/debug/lib/opcode-metadata.ts | 8 ++ .../lib/managers/internal/component.d.ts | 10 +- .../@glimmer/interfaces/lib/vm-opcodes.d.ts | 6 +- .../@glimmer/manager/lib/public/component.ts | 1 + .../@glimmer/manager/lib/util/capabilities.ts | 8 +- .../manager/test/capabilities-test.ts | 10 +- .../@glimmer/manager/test/managers-test.ts | 1 + .../lib/opcode-builder/delegate.ts | 2 + .../lib/opcode-builder/helpers/components.ts | 46 +++++++--- packages/@glimmer/runtime/index.ts | 4 + .../runtime/lib/compiled/opcodes/component.ts | 77 +++++++++++++++- .../runtime/lib/component/error-boundary.ts | 38 ++++++++ .../runtime/lib/component/template-only.ts | 1 + packages/@glimmer/runtime/lib/vm/update.ts | 50 ++++++++++ packages/@glimmer/vm/lib/flags.ts | 2 + 30 files changed, 361 insertions(+), 25 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/lib/components/error-boundary.ts create mode 100644 packages/@ember/-internals/glimmer/lib/templates/error-boundary.ts create mode 100644 packages/@glimmer/runtime/lib/component/error-boundary.ts diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index 4cf8bd70367..b8c21ea46b6 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -447,6 +447,7 @@ export { templateFactory as template, templateCacheCounters } from '@glimmer/opcode-compiler'; 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 44b4cdc5c61..25aea2e17d1 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts @@ -549,6 +549,7 @@ const CURLY_CAPABILITIES: InternalComponentCapabilities = { wrapped: true, willDestroy: true, hasSubOwner: false, + errorBoundary: false, }; export const CURLY_COMPONENT_MANAGER = 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 1fa7a7c8e3c..d6abddf9b5e 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/outlet.ts b/packages/@ember/-internals/glimmer/lib/component-managers/outlet.ts index e39b60250a3..8c23ce59df9 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/outlet.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/outlet.ts @@ -59,6 +59,7 @@ const CAPABILITIES: InternalComponentCapabilities = { wrapped: false, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }; const CAPABILITIES_MASK = capabilityFlagsFrom(CAPABILITIES); diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/root.ts b/packages/@ember/-internals/glimmer/lib/component-managers/root.ts index a0488f74488..60db9c422a4 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/root.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/root.ts @@ -92,6 +92,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 10f291d7dfe..b2ad3739fe4 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/route-template.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/route-template.ts @@ -45,6 +45,7 @@ const CAPABILITIES: InternalComponentCapabilities = { wrapped: false, willDestroy: false, hasSubOwner: false, + errorBoundary: false, }; const CAPABILITIES_MASK = capabilityFlagsFrom(CAPABILITIES); 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..d0a79a91285 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts @@ -0,0 +1,92 @@ +import type { + Bounds, + Destroyable, + DynamicScope, + Environment, + InternalComponentCapabilities, + InternalComponentManager, + Nullable, + Owner, + VMArguments, + WithCreateInstance, +} from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference'; +import { setComponentTemplate, setInternalComponentManager } from '@glimmer/manager'; +import { createConstRef } from '@glimmer/reference'; +import { ErrorBoundaryState } from '@glimmer/runtime'; + +import ErrorBoundaryTemplate from '../templates/error-boundary'; + +const CAPABILITIES: InternalComponentCapabilities = { + dynamicLayout: false, + dynamicTag: false, + prepareArgs: false, + createArgs: false, + 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 { + return new ErrorBoundaryState(); + } + + 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; + } + + update(_instance: ErrorBoundaryState, _dynamicScope: Nullable): void {} + + didSplatAttributes( + _instance: ErrorBoundaryState, + _element: ErrorBoundaryState, + _operations: Bounds + ): void {} +} + +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 9bd6c132b6a..6ecbf263dee 100644 --- a/packages/@ember/-internals/glimmer/lib/components/internal.ts +++ b/packages/@ember/-internals/glimmer/lib/components/internal.ts @@ -169,6 +169,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 1608fe1f185..0762242d4c9 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'; import { privatize as P } from '@ember/-internals/container'; 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'; @@ -47,6 +48,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..0b7319aaedc --- /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="error"}}{{else}}{{yield}}{{/if}}`, + { + moduleName: 'packages/@ember/-internals/glimmer/lib/templates/error-boundary.hbs', + strictMode: true, + scope() { + return {}; + }, + } +); diff --git a/packages/@ember/component/index.ts b/packages/@ember/component/index.ts index 0834c935f80..9471ff653df 100644 --- a/packages/@ember/component/index.ts +++ b/packages/@ember/component/index.ts @@ -5,7 +5,7 @@ export { setComponentTemplate, getComponentTemplate } from '@glimmer/manager'; -export { Component as default, Input, Textarea } from '@ember/-internals/glimmer'; +export { Component as default, ErrorBoundary, Input, Textarea } from '@ember/-internals/glimmer'; export { componentCapabilities as capabilities, setComponentManager, 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/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/component/src/index.ts b/packages/@glimmer/component/src/index.ts index 55936ebf3a2..dd7ee2aa8db 100644 --- a/packages/@glimmer/component/src/index.ts +++ b/packages/@glimmer/component/src/index.ts @@ -406,3 +406,5 @@ export default class Component extends _GlimmerComponent { setComponentManager((owner: Owner) => { return new GlimmerComponentManager(owner); }, Component); + +export { ErrorBoundary } from '@ember/component'; diff --git a/packages/@glimmer/constants/lib/syscall-ops.ts b/packages/@glimmer/constants/lib/syscall-ops.ts index 22e49360b86..5b101e7859b 100644 --- a/packages/@glimmer/constants/lib/syscall-ops.ts +++ b/packages/@glimmer/constants/lib/syscall-ops.ts @@ -52,6 +52,7 @@ import type { VmJumpIf, VmJumpUnless, VmLoad, + VmInvokeComponentLayoutGuarded, VmLog, VmMain, VmModifier, @@ -187,7 +188,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 deeb0ff6bee..8cbb97104e7 100644 --- a/packages/@glimmer/debug/lib/opcode-metadata.ts +++ b/packages/@glimmer/debug/lib/opcode-metadata.ts @@ -47,6 +47,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_STATIC_OP, VM_INVOKE_VIRTUAL_OP, @@ -705,6 +706,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/managers/internal/component.d.ts b/packages/@glimmer/interfaces/lib/managers/internal/component.d.ts index f10165973ce..1852451bd5a 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/vm-opcodes.d.ts b/packages/@glimmer/interfaces/lib/vm-opcodes.d.ts index 53058efcddb..4dd382c90f4 100644 --- a/packages/@glimmer/interfaces/lib/vm-opcodes.d.ts +++ b/packages/@glimmer/interfaces/lib/vm-opcodes.d.ts @@ -111,7 +111,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 @@ -206,6 +207,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 f8f9be9b7a7..47eb8430d93 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 7254e129804..a3ca7eecbee 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 3339e3540ff..015b28ab7db 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_INVOKE_VIRTUAL_OP, VM_JUMP_UNLESS_OP, @@ -194,7 +195,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, { @@ -392,17 +395,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); } @@ -440,7 +450,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); @@ -466,7 +477,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 9ec4eb2b603..bdcf8914b2f 100644 --- a/packages/@glimmer/runtime/index.ts +++ b/packages/@glimmer/runtime/index.ts @@ -17,6 +17,10 @@ export { templateOnlyComponent, TemplateOnlyComponentManager, } from './lib/component/template-only'; +export { + ErrorBoundaryState, + type ErrorBoundaryStateInterface, +} 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 a8091130ec7..e6aa2f5e4c9 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, @@ -68,7 +69,7 @@ import { CheckSyscallRegister, } from '@glimmer/debug'; import { debugToString, expect, localAssert, unwrap, unwrapTemplate } from '@glimmer/debug-util'; -import { registerDestructor } from '@glimmer/destroyable'; +import { associateDestroyableChild, destroyChildren, registerDestructor } from '@glimmer/destroyable'; import { managerHasCapability } from '@glimmer/manager'; import { isConstRef, valueForRef } from '@glimmer/reference'; import { assign, dict, EMPTY_STRING_ARRAY, enumerate } from '@glimmer/util'; @@ -78,8 +79,11 @@ import type { CurriedValue } from '../../curried-value'; import type { UpdatingVM } from '../../vm'; import type { VM } from '../../vm/append'; import type { BlockArgumentsImpl } from '../../vm/arguments'; +import { NewTreeBuilder } from '../../vm/element-builder'; +import { ErrorBoundaryOpcode } from '../../vm/update'; -import { ConcreteBounds } from '../../bounds'; +import { clear, ConcreteBounds } from '../../bounds'; +import type { ErrorBoundaryStateInterface } from '../../component/error-boundary'; import { hasCustomDebugRenderTreeLifecycle } from '../../component/interfaces'; import { resolveComponent } from '../../component/resolve'; import { isCurriedType, isCurriedValue, resolveCurriedValue } from '../../curried-value'; @@ -875,6 +879,75 @@ 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 ErrorBoundaryStateInterface; + + // 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); + let block = vm.tree().pushResettableBlock(); + + try { + let subTree = NewTreeBuilder.resume(vm.env, block); + let subVM = closure.evaluate(subTree); + + let children: UpdatingOpcode[] = []; + let errorBoundaryOp = new ErrorBoundaryOpcode( + closure, + vm.context, + block, + children, + errorState + ); + + let result = subVM.execute((subVM) => { + subVM.updateWith(errorBoundaryOp); + subVM.pushUpdating(children); + }); + + associateDestroyableChild(errorBoundaryOp, result.drop); + vm.associateDestroyable(errorBoundaryOp); + vm.updateWith(errorBoundaryOp); + vm.pushUpdating(children); + } catch (error) { + // Clean up any partial DOM from the failed render + destroyChildren(block); + clear(block); + + // Set error state so the template takes the error branch + errorState.setError(error); + + // Re-execute layout with error state (will render the error block) + let retryTree = NewTreeBuilder.resume(vm.env, block); + let retryVM = closure.evaluate(retryTree); + + let children: UpdatingOpcode[] = []; + let errorBoundaryOp = new ErrorBoundaryOpcode( + closure, + vm.context, + block, + children, + errorState + ); + + let result = retryVM.execute((retryVM) => { + retryVM.updateWith(errorBoundaryOp); + retryVM.pushUpdating(children); + }); + + associateDestroyableChild(errorBoundaryOp, result.drop); + vm.associateDestroyable(errorBoundaryOp); + vm.updateWith(errorBoundaryOp); + vm.pushUpdating(children); + } +}); + 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/component/error-boundary.ts b/packages/@glimmer/runtime/lib/component/error-boundary.ts new file mode 100644 index 00000000000..9bef11ae898 --- /dev/null +++ b/packages/@glimmer/runtime/lib/component/error-boundary.ts @@ -0,0 +1,38 @@ +import { dirtyTagFor, tagFor } from '@glimmer/validator'; +import { consumeTag } from '@glimmer/validator'; + +export interface ErrorBoundaryStateInterface { + error: unknown; + hasError: boolean; + setError(error: unknown): void; + retry(): void; +} + +export class ErrorBoundaryState implements ErrorBoundaryStateInterface { + private _error: unknown = null; + private _hasError = false; + + get error(): unknown { + consumeTag(tagFor(this, '_error')); + return this._error; + } + + get hasError(): boolean { + consumeTag(tagFor(this, '_hasError')); + return this._hasError; + } + + setError(error: unknown) { + this._error = error; + dirtyTagFor(this, '_error'); + this._hasError = true; + dirtyTagFor(this, '_hasError'); + } + + retry = () => { + this._error = null; + dirtyTagFor(this, '_error'); + this._hasError = false; + dirtyTagFor(this, '_hasError'); + }; +} diff --git a/packages/@glimmer/runtime/lib/component/template-only.ts b/packages/@glimmer/runtime/lib/component/template-only.ts index ba3ee9720f3..a1f859142b8 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/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 06386416da2..92ec2034f36 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -22,6 +22,7 @@ import { updateRef, valueForRef } from '@glimmer/reference'; import { logStep, Stack } from '@glimmer/util'; import { debug, resetTracking } from '@glimmer/validator'; +import type { ErrorBoundaryStateInterface } from '../component/error-boundary'; import type { Closure } from './append'; import type { AppendingBlockList } from './element-builder'; @@ -171,6 +172,55 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { } } +export class ErrorBoundaryOpcode extends TryOpcode { + public type = 'error-boundary'; + + constructor( + state: Closure, + context: EvaluationContext, + bounds: ResettableBlock, + children: UpdatingOpcode[], + private errorState: ErrorBoundaryStateInterface + ) { + super(state, context, bounds, children); + } + + override evaluate(vm: UpdatingVM) { + vm.try(this.children, this); + } + + override handleException() { + try { + super.handleException(); + } catch (error) { + this.transitionToError(error); + } + } + + private transitionToError(error: unknown) { + let { + bounds, + context: { env }, + } = this; + + destroyChildren(this); + + this.errorState.setError(error); + + let tree = NewTreeBuilder.resume(env, bounds); + let vm = this.state.evaluate(tree); + + let children = (this.children = []); + + let result = vm.execute((vm) => { + vm.updateWith(this); + vm.pushUpdating(children); + }); + + associateDestroyableChild(this, result.drop); + } +} + export class ListItemOpcode extends TryOpcode { public retained = false; public index = -1; 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; From 4d4201f6cd3c295aa2a87d2bfedb325995d1e56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Sat, 28 Feb 2026 21:17:40 -0300 Subject: [PATCH 02/25] [BUGFIX] Fix ErrorBoundary runtime: update-phase error handling and integration tests Fixes the updating opcode integration so ErrorBoundaryOpcode is properly placed in the parent VM's updating list (not pushed onto the stack where it gets lost). Adds JS exception handling in UpdatingVM._execute with tracking frame rollback, executeGuarded() for sub-VM isolation, debug render tree rollback, and backflow assertion bypass in setError(). Includes 7 integration tests covering initial render, rerender, retry, nested boundaries, and missing error block. --- .../components/error-boundary-test.ts | 186 ++++++++++++++++++ .../interfaces/lib/dom/attributes.d.ts | 2 + .../lib/runtime/debug-render-tree.d.ts | 6 + .../runtime/lib/compiled/opcodes/component.ts | 46 ++++- .../runtime/lib/component/error-boundary.ts | 11 +- .../@glimmer/runtime/lib/debug-render-tree.ts | 10 + packages/@glimmer/runtime/lib/vm/append.ts | 19 ++ .../runtime/lib/vm/element-builder.ts | 27 ++- packages/@glimmer/runtime/lib/vm/update.ts | 59 +++++- packages/@glimmer/validator/index.ts | 2 + packages/@glimmer/validator/lib/tracking.ts | 26 +++ 11 files changed, 377 insertions(+), 17 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts 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..3ee30c5fe96 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -0,0 +1,186 @@ +import { + AbstractStrictTestCase, + assertHTML, + buildOwner, + clickElement, + defComponent, + moduleFor, + runDestroy, +} from 'internal-test-helpers'; + +import { ErrorBoundary } from '@ember/component'; +import { on } from '@glimmer/runtime'; +import { tracked } from '@glimmer/tracking'; +import GlimmerishComponent from '../../utils/glimmerish-component'; + +import { run } from '@ember/runloop'; +import { associateDestroyableChild, registerDestructor } from '@glimmer/destroyable'; +import { renderComponent, type RenderResult } from '../../../lib/renderer'; +import type Owner from '@ember/owner'; + +// --- Test helper components --- + +const Throwing = defComponent('{{this.boom}}', { + component: class extends GlimmerishComponent { + get boom(): never { + throw new Error('render error'); + } + }, +}); + +const MaybeThrow = defComponent('{{this.value}}', { + component: 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(); + } +} + +// --- Tests --- + +moduleFor( + 'ErrorBoundary', + class extends ErrorBoundaryTestCase { + afterEach() { + if (this.component) { + runDestroy(this); + } + } + + '@test renders default block when no error'() { + let Root = defComponent( + '<:default>hello<:error as |err|>caught', + { scope: { ErrorBoundary } } + ); + + this.renderComponent(Root, { expect: 'hello' }); + } + + '@test catches error during initial render and shows error block'() { + let Root = defComponent( + '<:default><:error as |err|>caught', + { scope: { ErrorBoundary, Throwing } } + ); + + this.renderComponent(Root, { expect: 'caught' }); + } + + '@test passes error object to error block'() { + let Root = defComponent( + '<:default><:error as |err|>caught: {{err.message}}', + { scope: { ErrorBoundary, Throwing } } + ); + + this.renderComponent(Root, { expect: 'caught: render error' }); + } + + '@test catches error during rerender'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = defComponent( + '<:default><:error as |err|>caught', + { scope: { ErrorBoundary, MaybeThrow, state } } + ); + + 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 = defComponent( + '<:default><:error as |err retry|>', + { scope: { ErrorBoundary, MaybeThrow, state, on } } + ); + + this.renderComponent(Root, { expect: '' }); + + state.shouldThrow = false; + + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + } + + '@test nested boundaries — inner catches, outer unaffected'() { + let Root = defComponent( + '<:default>outer ok <:default><:error as |err|>inner caught<:error as |err|>outer caught', + { scope: { ErrorBoundary, Throwing } } + ); + + this.renderComponent(Root, { expect: 'outer ok inner caught' }); + } + + '@test renders nothing when no error block provided'() { + let Root = defComponent('', { + scope: { ErrorBoundary, Throwing }, + }); + + this.renderComponent(Root, { expect: '' }); + } + } +); diff --git a/packages/@glimmer/interfaces/lib/dom/attributes.d.ts b/packages/@glimmer/interfaces/lib/dom/attributes.d.ts index 47919a1e7ac..e04327d0f6a 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 { 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 e3a7aafd754..940494b4573 100644 --- a/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts @@ -48,4 +48,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/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index e6aa2f5e4c9..0bddcee7172 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -79,6 +79,8 @@ import type { CurriedValue } from '../../curried-value'; import type { UpdatingVM } from '../../vm'; import type { VM } from '../../vm/append'; import type { BlockArgumentsImpl } from '../../vm/arguments'; +import { getTrackingDepth, restoreTrackingTo } from '@glimmer/validator'; + import { NewTreeBuilder } from '../../vm/element-builder'; import { ErrorBoundaryOpcode } from '../../vm/update'; @@ -893,8 +895,19 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } let closure = vm.capture(0, layoutAddr); let block = vm.tree().pushResettableBlock(); + // Record insertion point so we can clean up partial DOM on error. + let parent = block.parentElement(); + let insertionMarker = 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 discard stale frames from a failed sub-VM. + let trackingDepth = getTrackingDepth(); + try { - let subTree = NewTreeBuilder.resume(vm.env, block); + // Use beginBlock (not resume) since this is a fresh block with no prior content. + let subTree = NewTreeBuilder.beginBlock(vm.env, block); let subVM = closure.evaluate(subTree); let children: UpdatingOpcode[] = []; @@ -906,7 +919,8 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } errorState ); - let result = subVM.execute((subVM) => { + // Use executeGuarded to avoid resetting the parent VM's tracking state. + let result = subVM.executeGuarded((subVM) => { subVM.updateWith(errorBoundaryOp); subVM.pushUpdating(children); }); @@ -914,17 +928,30 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } associateDestroyableChild(errorBoundaryOp, result.drop); vm.associateDestroyable(errorBoundaryOp); vm.updateWith(errorBoundaryOp); - vm.pushUpdating(children); } catch (error) { - // Clean up any partial DOM from the failed render - destroyChildren(block); - clear(block); + // Roll back stale tracking frames left by the failed sub-VM render. + restoreTrackingTo(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 cursor = insertionMarker ? insertionMarker.nextSibling : parent.firstChild; + while (cursor) { + let next = cursor.nextSibling; + parent.removeChild(cursor); + cursor = next; + } + + // Reset block to empty state for the retry render. + block.resetPartial(); // Set error state so the template takes the error branch errorState.setError(error); - // Re-execute layout with error state (will render the error block) - let retryTree = NewTreeBuilder.resume(vm.env, block); + // Re-execute layout with error state (will render the error block). + let retryTree = NewTreeBuilder.beginBlock(vm.env, block); let retryVM = closure.evaluate(retryTree); let children: UpdatingOpcode[] = []; @@ -936,7 +963,7 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } errorState ); - let result = retryVM.execute((retryVM) => { + let result = retryVM.executeGuarded((retryVM) => { retryVM.updateWith(errorBoundaryOp); retryVM.pushUpdating(children); }); @@ -944,7 +971,6 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } associateDestroyableChild(errorBoundaryOp, result.drop); vm.associateDestroyable(errorBoundaryOp); vm.updateWith(errorBoundaryOp); - vm.pushUpdating(children); } }); diff --git a/packages/@glimmer/runtime/lib/component/error-boundary.ts b/packages/@glimmer/runtime/lib/component/error-boundary.ts index 9bef11ae898..b22164a0857 100644 --- a/packages/@glimmer/runtime/lib/component/error-boundary.ts +++ b/packages/@glimmer/runtime/lib/component/error-boundary.ts @@ -1,5 +1,4 @@ -import { dirtyTagFor, tagFor } from '@glimmer/validator'; -import { consumeTag } from '@glimmer/validator'; +import { consumeTag, dirtyTag, dirtyTagFor, tagFor } from '@glimmer/validator'; export interface ErrorBoundaryStateInterface { error: unknown; @@ -24,9 +23,13 @@ export class ErrorBoundaryState implements ErrorBoundaryStateInterface { setError(error: unknown) { this._error = error; - dirtyTagFor(this, '_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'), true); this._hasError = true; - dirtyTagFor(this, '_hasError'); + dirtyTag(tagFor(this, '_hasError'), true); } retry = () => { diff --git a/packages/@glimmer/runtime/lib/debug-render-tree.ts b/packages/@glimmer/runtime/lib/debug-render-tree.ts index b48626fb8a3..3581d465e99 100644 --- a/packages/@glimmer/runtime/lib/debug-render-tree.ts +++ b/packages/@glimmer/runtime/lib/debug-render-tree.ts @@ -105,6 +105,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/vm/append.ts b/packages/@glimmer/runtime/lib/vm/append.ts index 5d43072ccf5..dabf7606bf5 100644 --- a/packages/@glimmer/runtime/lib/vm/append.ts +++ b/packages/@glimmer/runtime/lib/vm/append.ts @@ -723,6 +723,25 @@ 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. + */ + executeGuarded(initialize?: (vm: this) => void): RenderResult { + try { + return this._execute(initialize); + } catch (e) { + // Clean up block stack without resetting tracking (preserve parent's state). + let elements = this.tree(); + while (elements.hasBlocks) { + elements.popBlock(); + } + throw e; + } + } + 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 160944fefa4..c0ebcd6abbf 100644 --- a/packages/@glimmer/runtime/lib/vm/element-builder.ts +++ b/packages/@glimmer/runtime/lib/vm/element-builder.ts @@ -105,6 +105,19 @@ 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. + */ + static beginBlock(env: Environment, block: ResettableBlock): NewTreeBuilder { + let parentNode = block.parentElement(); + let stack = new this(env, parentNode, null).initialize(); + stack.pushBlock(block); + + return stack; + } + constructor(env: Environment, parentNode: SimpleElement, nextSibling: Nullable) { this.pushElement(parentNode, nextSibling); this.env = env; @@ -515,7 +528,7 @@ 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; @@ -523,6 +536,18 @@ export class ResettableBlockImpl extends AppendingBlockImpl implements Resettabl 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). + */ + 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/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 92ec2034f36..aa79b325563 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -20,7 +20,7 @@ import { associateDestroyableChild, destroy, destroyChildren } from '@glimmer/de import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; import { updateRef, valueForRef } from '@glimmer/reference'; import { logStep, Stack } from '@glimmer/util'; -import { debug, resetTracking } from '@glimmer/validator'; +import { debug, getTrackingDepth, resetTracking, restoreTrackingTo } from '@glimmer/validator'; import type { ErrorBoundaryStateInterface } from '../component/error-boundary'; import type { Closure } from './append'; @@ -79,7 +79,36 @@ export class UpdatingVM implements IUpdatingVM { continue; } - opcode.evaluate(this); + // Save tracking frame depth so we can discard stale frames if the + // opcode throws a JS exception (e.g., a tracked getter throwing). + let trackingDepth = getTrackingDepth(); + + try { + opcode.evaluate(this); + } catch (error) { + // Restore tracking frames to the depth before the failed opcode. + // Without this, stale frames corrupt the parent's tracking context. + restoreTrackingTo(trackingDepth); + + // Walk up the frame stack to find an error boundary that can handle + // JavaScript errors. We skip regular TryOpcode handlers because they + // would reset their block and re-render, which corrupts parent block + // references if the re-render also fails. + let handled = false; + + while (!frameStack.isEmpty()) { + if (this.frame.handleError(error)) { + frameStack.pop(); + handled = true; + break; + } + frameStack.pop(); + } + + if (!handled) { + throw error; + } + } } } @@ -197,6 +226,15 @@ export class ErrorBoundaryOpcode extends TryOpcode { } } + /** + * 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. + */ + handleError(error: unknown) { + this.transitionToError(error); + } + private transitionToError(error: unknown) { let { bounds, @@ -491,4 +529,21 @@ 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. + */ + handleError(error: unknown): boolean { + if ( + this.exceptionHandler && + 'handleError' in this.exceptionHandler && + typeof (this.exceptionHandler as any).handleError === 'function' + ) { + (this.exceptionHandler as any).handleError(error); + return true; + } + return false; + } } diff --git a/packages/@glimmer/validator/index.ts b/packages/@glimmer/validator/index.ts index a3abcaa5007..6412ede81b7 100644 --- a/packages/@glimmer/validator/index.ts +++ b/packages/@glimmer/validator/index.ts @@ -25,10 +25,12 @@ export { createCache, endTrackFrame, endUntrackFrame, + getTrackingDepth, getValue, isConst, isTracking, resetTracking, + restoreTrackingTo, track, untrack, } from './lib/tracking'; diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index d94ae456e6b..2527b4afd63 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -95,6 +95,32 @@ 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. + */ +export function restoreTrackingTo(depth: number): void { + while (OPEN_TRACK_FRAMES.length > depth) { + OPEN_TRACK_FRAMES.pop(); + if (DEBUG) { + unwrap(debug.endTrackingTransaction)(); + } + } + CURRENT_TRACKER = OPEN_TRACK_FRAMES.length > 0 + ? OPEN_TRACK_FRAMES[OPEN_TRACK_FRAMES.length - 1] + : null; +} + // This function is only for handling errors and resetting to a valid state export function resetTracking(): string | void { while (OPEN_TRACK_FRAMES.length > 0) { From 1910fafda2e5740bd4cbf29faa352b493854a445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 3 Mar 2026 23:03:03 -0300 Subject: [PATCH 03/25] [BUGFIX] Fix ErrorBoundary rerender recovery and expand test coverage Cache DOM boundaries before child opcodes execute so error recovery can clean up correctly even when inner TryOpcodes corrupt the bounds tree. Add beginBlock nextSibling parameter for proper insertion positioning. Add comprehensive integration tests for error boundary edge cases. --- package.json | 1 - .../components/error-boundary-test.ts | 374 +++++++++++++++++- .../runtime/lib/vm/element-builder.ts | 8 +- packages/@glimmer/runtime/lib/vm/update.ts | 43 +- 4 files changed, 419 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 2259f6a680c..dcf2beb6e7d 100644 --- a/package.json +++ b/package.json @@ -340,7 +340,6 @@ "@simple-dom/document/index.js": "ember-source/@simple-dom/document/index.js", "backburner.js/index.js": "ember-source/backburner.js/index.js", "dag-map/index.js": "ember-source/dag-map/index.js", - "ember-template-compiler/index.js": "ember-source/ember-template-compiler/index.js", "ember-testing/index.js": "ember-source/ember-testing/index.js", "ember-testing/lib/adapters/adapter.js": "ember-source/ember-testing/lib/adapters/adapter.js", "ember-testing/lib/adapters/qunit.js": "ember-source/ember-testing/lib/adapters/qunit.js", 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 index 3ee30c5fe96..2aae4b99eea 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -4,19 +4,23 @@ import { buildOwner, clickElement, defComponent, + defineSimpleHelper, + defineSimpleModifier, moduleFor, runDestroy, } from 'internal-test-helpers'; -import { ErrorBoundary } from '@ember/component'; -import { on } from '@glimmer/runtime'; +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, registerDestructor } from '@glimmer/destroyable'; +import { associateDestroyableChild, destroy, registerDestructor } from '@glimmer/destroyable'; +import { componentCapabilities } from '@glimmer/manager'; import { renderComponent, type RenderResult } from '../../../lib/renderer'; import type Owner from '@ember/owner'; +import { setOwner } from '@ember/-internals/owner'; // --- Test helper components --- @@ -182,5 +186,369 @@ moduleFor( this.renderComponent(Root, { expect: '' }); } + + '@test tracked properties work after error recovery via retry'() { + class State { + @tracked shouldThrow = true; + } + let state = new State(); + + let Counter = defComponent( + '{{this.count}}', + { + component: class extends GlimmerishComponent { + @tracked count = 0; + increment = () => this.count++; + }, + scope: { on }, + } + ); + + let Root = defComponent( + '<:default><:error as |err retry|>', + { scope: { ErrorBoundary, MaybeThrow, Counter, state, on } } + ); + + 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 = defComponent( + 'before<:default><:error as |err|>caughtafter', + { scope: { ErrorBoundary, Throwing } } + ); + + this.renderComponent(Root, { expect: 'beforecaughtafter' }); + } + + '@test catches error from deeply nested grandchild component'() { + let Child = defComponent('', { scope: { Throwing } }); + let Parent = defComponent('', { scope: { Child } }); + + let Root = defComponent( + '<:default><:error as |err|>caught: {{err.message}}', + { scope: { ErrorBoundary, Parent } } + ); + + this.renderComponent(Root, { expect: 'caught: render error' }); + } + + '@test catches error from item in each loop'() { + let ItemComponent = defComponent('{{this.value}}', { + component: 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 = defComponent( + '<:default>{{#each (array "good" "bad" "also-good") as |item|}}{{/each}}<:error as |err|>caught: {{err.message}}', + { scope: { ErrorBoundary, ItemComponent, array } } + ); + + 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: boolean) => { + if (shouldThrow) throw new Error('helper rerender error'); + return 'helper ok'; + }); + + let Root = defComponent( + '<:default>{{maybeThrowHelper state.shouldThrow}}<:error as |err|>caught: {{err.message}}', + { scope: { ErrorBoundary, maybeThrowHelper, state } } + ); + + 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 = defComponent( + '<:default>{{throwingHelper}}<:error as |err|>caught: {{err.message}}', + { scope: { ErrorBoundary, throwingHelper } } + ); + + 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 = defComponent( + '<:default>
content
<:error as |err|>caught
', + { scope: { ErrorBoundary, trackingModifier } } + ); + + 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 = defComponent('{{this.fullName}}', { + component: class extends GlimmerishComponent { + get fullName() { + return `${(this as any).args.first} ${(this as any).args.last}`; + } + }, + }); + + let Root = defComponent( + '<:default><:error as |err|>caught', + { scope: { ErrorBoundary, FullName, state } } + ); + + 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 = defComponent( + '<:default><:error as |err|>first caught<:default>second ok<:error as |err|>second caught', + { scope: { ErrorBoundary, Throwing } } + ); + + 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 = defComponent( + '{{state.counter}}<:default><:error as |err|>caught', + { scope: { ErrorBoundary, Throwing, state } } + ); + + 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 = defComponent( + '{{state.label}}<:default><:error as |err|>caught', + { scope: { ErrorBoundary, Throwing, state } } + ); + + this.renderComponent(Root, { expect: 'hellocaught' }); + + this.assertChange({ + change: () => (state.label = 'world'), + expect: 'worldcaught', + }); + } + + '@test catches error from conditional branch during initial render'() { + let Root = defComponent( + '<:default>{{#if true}}{{else}}safe{{/if}}<:error as |err|>caught: {{err.message}}', + { scope: { ErrorBoundary, Throwing } } + ); + + 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 = defComponent('{{this.value}}', { + component: 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 = defComponent( + '<:default>{{#each state.items as |item|}}{{/each}}<:error as |err|>caught: {{err.message}}', + { scope: { ErrorBoundary, ItemComponent, state } } + ); + + 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 = defComponent( + '<:default>
content
<:error as |err|>caught: {{err.message}}
', + { scope: { ErrorBoundary, throwingModifier } } + ); + + 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 = defComponent('{{this.value}}', { + component: DestroyableComponent as any, + }); + + let Root = defComponent( + '<:default><:error as |err|>caught', + { scope: { ErrorBoundary, Tracked, state } } + ); + + 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 = defComponent( + '<:default><:error as |err retry|>caught ', + { scope: { ErrorBoundary, Throwing, on } } + ); + + 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 = defComponent( + '<:default>{{#if state.showDanger}}{{else}}safe{{/if}}<:error as |err|>caught', + { scope: { ErrorBoundary, Throwing, state } } + ); + + this.renderComponent(Root, { expect: 'safe' }); + + this.assertChange({ + change: () => (state.showDanger = true), + expect: 'caught', + }); + } } ); diff --git a/packages/@glimmer/runtime/lib/vm/element-builder.ts b/packages/@glimmer/runtime/lib/vm/element-builder.ts index c0ebcd6abbf..4c9d59993dd 100644 --- a/packages/@glimmer/runtime/lib/vm/element-builder.ts +++ b/packages/@glimmer/runtime/lib/vm/element-builder.ts @@ -109,10 +109,14 @@ export class NewTreeBuilder implements TreeBuilder { * 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): NewTreeBuilder { + static beginBlock(env: Environment, block: ResettableBlock, nextSibling?: Nullable): NewTreeBuilder { let parentNode = block.parentElement(); - let stack = new this(env, parentNode, null).initialize(); + let stack = new this(env, parentNode, nextSibling ?? null).initialize(); stack.pushBlock(block); return stack; diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index aa79b325563..dab987bc2ca 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -11,6 +11,7 @@ import type { ResettableBlock, Scope, SimpleComment, + SimpleNode, UpdatingOpcode, UpdatingVM as IUpdatingVM, } from '@glimmer/interfaces'; @@ -204,6 +205,14 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { 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, 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. + private lastFirstNode: SimpleNode | null = null; + private lastNextSibling: SimpleNode | null = null; + constructor( state: Closure, context: EvaluationContext, @@ -215,6 +224,17 @@ export class ErrorBoundaryOpcode extends TryOpcode { } override evaluate(vm: UpdatingVM) { + // Snapshot current DOM boundaries before child opcodes run. + // We need the nextSibling after lastNode (not lastNode itself) because + // child opcodes may insert temporary nodes (e.g., list sync markers) + // after lastNode. Using nextSibling ensures cleanup covers those too. + try { + this.lastFirstNode = this.bounds.firstNode(); + this.lastNextSibling = this.bounds.lastNode().nextSibling; + } catch { + // Bounds not yet initialized (first render) — no cache needed. + } + vm.try(this.children, this); } @@ -245,7 +265,28 @@ export class ErrorBoundaryOpcode extends TryOpcode { this.errorState.setError(error); - let tree = NewTreeBuilder.resume(env, bounds); + // 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.lastFirstNode && this.lastFirstNode.parentNode === parent) { + let current: SimpleNode | null = this.lastFirstNode; + let stop = this.lastNextSibling; + + while (current && current !== stop) { + let next: SimpleNode | null = current.nextSibling; + parent.removeChild(current); + current = next; + } + } + + bounds.resetPartial(); + let tree = NewTreeBuilder.beginBlock(env, bounds, this.lastNextSibling); + let vm = this.state.evaluate(tree); let children = (this.children = []); From 8aca29055aafe6b223a895e9e218c79c485873c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 3 Mar 2026 23:25:52 -0300 Subject: [PATCH 04/25] [PERF] Remove try-catch from ErrorBoundary hot path and clear stale DOM refs Replace try-catch in evaluate() with unconditional snapshot + debug assertion, since bounds are always initialized. Null out cached DOM references after error recovery to avoid retaining detached nodes. --- packages/@glimmer/runtime/lib/vm/update.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index dab987bc2ca..7af6299929d 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -228,12 +228,18 @@ export class ErrorBoundaryOpcode extends TryOpcode { // We need the nextSibling after lastNode (not lastNode itself) because // child opcodes may insert temporary nodes (e.g., list sync markers) // after lastNode. Using nextSibling ensures cleanup covers those too. - try { - this.lastFirstNode = this.bounds.firstNode(); - this.lastNextSibling = this.bounds.lastNode().nextSibling; - } catch { - // Bounds not yet initialized (first render) — no cache needed. + // + // Bounds are always initialized here because: + // - Initial render completes (with finalize()) before UpdatingVM is created. + // - transitionToError() re-renders synchronously, repopulating bounds. + if (LOCAL_DEBUG) { + expect( + this.bounds.firstNode(), + 'BUG: ErrorBoundaryOpcode.evaluate() called with uninitialized bounds' + ); } + this.lastFirstNode = this.bounds.firstNode(); + this.lastNextSibling = this.bounds.lastNode().nextSibling; vm.try(this.children, this); } @@ -297,6 +303,11 @@ export class ErrorBoundaryOpcode extends TryOpcode { }); associateDestroyableChild(this, result.drop); + + // Clear cached DOM references — they pointed at the pre-error content + // which has been removed. Fresh values are captured in the next evaluate(). + this.lastFirstNode = null; + this.lastNextSibling = null; } } From e4340ad8d02bae8f3c7c4967e560d9a0821c14c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 3 Mar 2026 23:37:30 -0300 Subject: [PATCH 05/25] Restore accidentally removed ember-template-compiler mapping in package.json The ember-template-compiler/index.js exposed dependency mapping was unintentionally removed in an earlier commit. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index dcf2beb6e7d..2259f6a680c 100644 --- a/package.json +++ b/package.json @@ -340,6 +340,7 @@ "@simple-dom/document/index.js": "ember-source/@simple-dom/document/index.js", "backburner.js/index.js": "ember-source/backburner.js/index.js", "dag-map/index.js": "ember-source/dag-map/index.js", + "ember-template-compiler/index.js": "ember-source/ember-template-compiler/index.js", "ember-testing/index.js": "ember-source/ember-testing/index.js", "ember-testing/lib/adapters/adapter.js": "ember-source/ember-testing/lib/adapters/adapter.js", "ember-testing/lib/adapters/qunit.js": "ember-source/ember-testing/lib/adapters/qunit.js", From 233fbb02212e8e6ca4b29831f071bed185b1fbf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Wed, 4 Mar 2026 18:23:28 -0300 Subject: [PATCH 06/25] [BUGFIX] Prevent tracking state corruption during error recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace vm.execute() with vm.executeGuarded() in nested VM contexts to prevent resetTracking() from destroying parent tracking frames. In DEBUG mode, AppendingVM.execute() calls resetTracking() on error, which wipes ALL tracking state including the parent UpdatingVM's frames. This made ErrorBoundary recovery impossible and caused "attempted to close a tracking frame" crashes. Fixes: - Backtracking assertions on tracked mutation after error recovery - Double-trigger tracking frame corruption (error → error → retry) - Error-in-error-block rendering failures bubbling incorrectly - ListBlockOpcode.insertItem tracking corruption when new items throw Changes: - TryOpcode.handleException(): use executeGuarded() so errors propagate cleanly to the UpdatingVM's catch handler without wiping tracking state - ErrorBoundaryOpcode.handleException(): restore tracking before re-render, use executeGuarded(), add robust DOM cleanup with lastPreviousSibling fallback for when inner TryOpcodes detach cached nodes via bounds.reset() - ErrorBoundaryOpcode.handleError(): restore tracking and consumed tags before transitioning to error state - ErrorBoundaryOpcode.transitionToError(): use executeGuarded(), add previousSibling-based DOM cleanup fallback, roll back debug render tree - ListBlockOpcode.insertItem(): use executeGuarded() - Add resetConsumedTags() to debug.ts to discard stale CONSUMED_TAGS entries after popping tracking frames during error recovery Adjusts assertion count in "readable error stack" test (7→6): the single outer console.error from UpdatingVM.execute() now captures full tracking info since the inner call no longer prematurely wipes it. Adds 5 regression tests covering all fix scenarios. --- .../components/error-boundary-test.ts | 161 ++++++++++++++++++ .../lib/suites/components.ts | 2 +- packages/@glimmer/runtime/lib/vm/update.ts | 137 +++++++++++++-- packages/@glimmer/validator/lib/debug.ts | 14 ++ 4 files changed, 303 insertions(+), 11 deletions(-) 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 index 2aae4b99eea..0223c8330f4 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -550,5 +550,166 @@ moduleFor( 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 = defComponent( + '<:default><:error as |err retry|>caught ', + { scope: { ErrorBoundary, MaybeThrow, state, on } } + ); + + 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 = defComponent( + '<:default><:error as |err retry|>caught ', + { scope: { ErrorBoundary, MaybeThrow, state, on } } + ); + + 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 = defComponent('{{this.boom}}', { + component: class extends GlimmerishComponent { + get boom(): never { + throw new Error('fallback error'); + } + }, + }); + + let Root = defComponent( + '<:default><:default><:error as |err|><:error as |err|>outer caught: {{err.message}}', + { scope: { ErrorBoundary, Throwing, ThrowingFallback } } + ); + + 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 = defComponent('{{this.value}}', { + component: class extends GlimmerishComponent { + get value() { + if ((this as any).args.item === 'bomb') { + throw new Error('bomb'); + } + return (this as any).args.item; + } + }, + }); + + let Root = defComponent( + '<:default>{{#each state.items as |item|}}{{/each}}<:error as |err retry|>caught ', + { scope: { ErrorBoundary, ItemComponent, state, on } } + ); + + 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 = defComponent('{{this.value}}', { + component: class extends GlimmerishComponent { + get value() { + if ((this as any).args.item === 'bomb') { + throw new Error('bomb'); + } + return (this as any).args.item; + } + }, + }); + + let Root = defComponent( + '<:default>{{#each state.items as |item|}}{{/each}}<:error as |err retry|>caught ', + { scope: { ErrorBoundary, ItemComponent, state, on } } + ); + + 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', + }); + } } ); 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/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 7af6299929d..64e9bfa6875 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -193,7 +193,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); }); @@ -207,11 +213,17 @@ export class ErrorBoundaryOpcode extends TryOpcode { // 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, 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. + // 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 lastFirstNode from the DOM via bounds.reset() before the error + // reaches us. private lastFirstNode: SimpleNode | null = null; + private lastPreviousSibling: SimpleNode | null = null; private lastNextSibling: SimpleNode | null = null; + private lastRenderTreeDepth = 0; + private lastTrackingDepth = 0; constructor( state: Closure, @@ -239,16 +251,97 @@ export class ErrorBoundaryOpcode extends TryOpcode { ); } this.lastFirstNode = this.bounds.firstNode(); + this.lastPreviousSibling = this.lastFirstNode.previousSibling; this.lastNextSibling = this.bounds.lastNode().nextSibling; + this.lastRenderTreeDepth = vm.env.debugRenderTree?.getDepth() ?? 0; + this.lastTrackingDepth = getTrackingDepth(); vm.try(this.children, this); } override handleException() { + let { + bounds, + context: { env }, + } = this; + let parent = bounds.parentElement(); + + // Restore tracking to the depth captured in evaluate(), BEFORE children + // ran. When vm.throw() is called (e.g. by an Assert opcode), it + // short-circuits the children's frame — any tracking frames opened by + // BeginTrackFrameOpcode will never see their matching EndTrackFrameOpcode. + // Without this, stale frames (and their DEBUG tracking transactions) leak + // onto OPEN_TRACK_FRAMES / TRANSACTION_STACK, keeping CONSUMED_TAGS alive + // and causing spurious backtracking assertions on later mutations. + restoreTrackingTo(this.lastTrackingDepth); + // Discard consumed-tag entries from the popped tracking frames. The + // CONSUMED_TAGS WeakMap can't be selectively pruned, so replace it + // with a fresh one. Without this, stale entries (e.g. a @tracked + // property consumed during the failed render) cause backtracking + // assertions when the property is later mutated outside the render. + if (DEBUG) { + debug.resetConsumedTags?.(); + } + let trackingDepth = this.lastTrackingDepth; + + // Save insertion marker: the sibling just before the boundary's content. + // After resume() removes old DOM via bounds.reset(), any nodes between + // this marker and lastNextSibling are partial leftovers from a failed render. + let insertionMarker = this.lastFirstNode + ? this.lastFirstNode.previousSibling + : null; + + destroyChildren(this); + try { - super.handleException(); + // Attempt a normal re-render like TryOpcode.handleException(), but use + // executeGuarded() instead of execute(). In DEBUG mode, execute() calls + // resetTracking() on error, which wipes out the parent UpdatingVM's + // tracking transaction (TRANSACTION_STACK / CONSUMED_TAGS), causing + // spurious backtracking assertions on later tracked property mutations. + let tree = NewTreeBuilder.resume(env, bounds); + let vm = this.state.evaluate(tree); + let children = (this.children = []); + let result = vm.executeGuarded((vm) => { + vm.updateWith(this); + vm.pushUpdating(children); + }); + associateDestroyableChild(this, result.drop); } catch (error) { - this.transitionToError(error); + // Restore tracking frames opened by the failed re-render attempt. + restoreTrackingTo(trackingDepth); + + // Roll back stale debug render tree entries. + env.debugRenderTree?.rollbackTo(this.lastRenderTreeDepth); + + // Remove partial DOM nodes left by the failed re-render. + // resume() already removed old content via bounds.reset(), so any nodes + // between insertionMarker and lastNextSibling are from the failed render. + let cursor: SimpleNode | null = insertionMarker + ? insertionMarker.nextSibling + : parent.firstChild; + let stop = this.lastNextSibling; + while (cursor && cursor !== stop) { + let next: SimpleNode | null = cursor.nextSibling; + parent.removeChild(cursor); + cursor = next; + } + + bounds.resetPartial(); + this.errorState.setError(error); + + let retryTree = NewTreeBuilder.beginBlock(env, bounds, this.lastNextSibling); + let retryVM = this.state.evaluate(retryTree); + let children = (this.children = []); + let result = retryVM.executeGuarded((vm) => { + vm.updateWith(this); + vm.pushUpdating(children); + }); + associateDestroyableChild(this, result.drop); + + this.lastFirstNode = null; + this.lastPreviousSibling = null; + this.lastNextSibling = null; } } @@ -258,6 +351,14 @@ export class ErrorBoundaryOpcode extends TryOpcode { * skipping inner TryOpcode handlers that would corrupt block state. */ handleError(error: unknown) { + // Restore tracking to the depth from evaluate(), before children ran. + // _execute's catch only restores to the depth of the failing opcode, + // which doesn't cover tracking frames opened by earlier opcodes + // (like BeginTrackFrameOpcode) in the children's frame. + restoreTrackingTo(this.lastTrackingDepth); + if (DEBUG) { + debug.resetConsumedTags?.(); + } this.transitionToError(error); } @@ -279,8 +380,18 @@ export class ErrorBoundaryOpcode extends TryOpcode { // handles both cases. let parent = bounds.parentElement(); - if (this.lastFirstNode && this.lastFirstNode.parentNode === parent) { - let current: SimpleNode | null = this.lastFirstNode; + if (this.lastFirstNode) { + // Determine cleanup start: if lastFirstNode is still in the DOM, start + // there. If it was detached (by an inner TryOpcode's bounds.reset()), + // use the cached previousSibling to find the current start point. + let current: SimpleNode | null; + if (this.lastFirstNode.parentNode === parent) { + current = this.lastFirstNode; + } else if (this.lastPreviousSibling) { + current = this.lastPreviousSibling.nextSibling; + } else { + current = parent.firstChild; + } let stop = this.lastNextSibling; while (current && current !== stop) { @@ -290,6 +401,11 @@ export class ErrorBoundaryOpcode extends TryOpcode { } } + // 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.lastRenderTreeDepth); + bounds.resetPartial(); let tree = NewTreeBuilder.beginBlock(env, bounds, this.lastNextSibling); @@ -297,7 +413,7 @@ export class ErrorBoundaryOpcode extends TryOpcode { let children = (this.children = []); - let result = vm.execute((vm) => { + let result = vm.executeGuarded((vm) => { vm.updateWith(this); vm.pushUpdating(children); }); @@ -307,6 +423,7 @@ export class ErrorBoundaryOpcode extends TryOpcode { // Clear cached DOM references — they pointed at the pre-error content // which has been removed. Fresh values are captured in the next evaluate(). this.lastFirstNode = null; + this.lastPreviousSibling = null; this.lastNextSibling = null; } } @@ -500,7 +617,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; diff --git a/packages/@glimmer/validator/lib/debug.ts b/packages/@glimmer/validator/lib/debug.ts index 3578a060562..36b9f4c70c8 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 = ''; From fab98371958d1cafe40b36a7f9ffcb758dfe6155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Wed, 4 Mar 2026 19:50:49 -0300 Subject: [PATCH 07/25] Fix CI type checking, linting, and formatting errors - Add handleError() to ExceptionHandler interface, removing unsafe `any` casts in UpdatingVMFrame.handleError() - Cast tagFor() results to UpdatableTag in ErrorBoundaryState.setError() to satisfy dirtyTag() parameter type - Cast array access in restoreTrackingTo() to fix Tracker | undefined not assignable to Tracker | null - Extract inline decorated class in test to fix "decorators are not valid here" TS error - Change defineSimpleHelper param from boolean to unknown to match expected signature - Remove unused imports (destroyChildren, clear) from component.ts - Run prettier on all modified files --- .../components/error-boundary-test.ts | 17 ++++++++-------- .../interfaces/lib/runtime/render.d.ts | 1 + .../runtime/lib/compiled/opcodes/component.ts | 20 ++++--------------- .../runtime/lib/component/error-boundary.ts | 5 +++-- .../runtime/lib/vm/element-builder.ts | 6 +++++- packages/@glimmer/runtime/lib/vm/update.ts | 12 +++-------- packages/@glimmer/validator/lib/tracking.ts | 7 ++++--- 7 files changed, 28 insertions(+), 40 deletions(-) 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 index 0223c8330f4..02a0d25e286 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -65,10 +65,7 @@ class ErrorBoundaryTestCase extends AbstractStrictTestCase { this.assertStableRerender(); } - renderComponent( - component: object, - options: { args?: Record; expect: string } - ) { + renderComponent(component: object, options: { args?: Record; expect: string }) { let { owner } = this; run(() => { @@ -193,13 +190,15 @@ moduleFor( } let state = new State(); + class CounterComponent extends GlimmerishComponent { + @tracked count = 0; + increment = () => this.count++; + } + let Counter = defComponent( '{{this.count}}', { - component: class extends GlimmerishComponent { - @tracked count = 0; - increment = () => this.count++; - }, + component: CounterComponent, scope: { on }, } ); @@ -271,7 +270,7 @@ moduleFor( } let state = new State(); - let maybeThrowHelper = defineSimpleHelper((shouldThrow: boolean) => { + let maybeThrowHelper = defineSimpleHelper((shouldThrow: unknown) => { if (shouldThrow) throw new Error('helper rerender error'); return 'helper ok'; }); diff --git a/packages/@glimmer/interfaces/lib/runtime/render.d.ts b/packages/@glimmer/interfaces/lib/runtime/render.d.ts index bd8ad0a2023..a5835eed660 100644 --- a/packages/@glimmer/interfaces/lib/runtime/render.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/render.d.ts @@ -6,6 +6,7 @@ import type { Environment } from './environment.js'; export interface ExceptionHandler { handleException(): void; + handleError?(error: unknown): void; } export interface RenderResult extends Bounds, ExceptionHandler { diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index 0bddcee7172..c43461bf516 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -69,7 +69,7 @@ import { CheckSyscallRegister, } from '@glimmer/debug'; import { debugToString, expect, localAssert, unwrap, unwrapTemplate } from '@glimmer/debug-util'; -import { associateDestroyableChild, destroyChildren, registerDestructor } from '@glimmer/destroyable'; +import { associateDestroyableChild, registerDestructor } from '@glimmer/destroyable'; import { managerHasCapability } from '@glimmer/manager'; import { isConstRef, valueForRef } from '@glimmer/reference'; import { assign, dict, EMPTY_STRING_ARRAY, enumerate } from '@glimmer/util'; @@ -84,7 +84,7 @@ import { getTrackingDepth, restoreTrackingTo } from '@glimmer/validator'; import { NewTreeBuilder } from '../../vm/element-builder'; import { ErrorBoundaryOpcode } from '../../vm/update'; -import { clear, ConcreteBounds } from '../../bounds'; +import { ConcreteBounds } from '../../bounds'; import type { ErrorBoundaryStateInterface } from '../../component/error-boundary'; import { hasCustomDebugRenderTreeLifecycle } from '../../component/interfaces'; import { resolveComponent } from '../../component/resolve'; @@ -911,13 +911,7 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } let subVM = closure.evaluate(subTree); let children: UpdatingOpcode[] = []; - let errorBoundaryOp = new ErrorBoundaryOpcode( - closure, - vm.context, - block, - children, - errorState - ); + let errorBoundaryOp = new ErrorBoundaryOpcode(closure, vm.context, block, children, errorState); // Use executeGuarded to avoid resetting the parent VM's tracking state. let result = subVM.executeGuarded((subVM) => { @@ -955,13 +949,7 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } let retryVM = closure.evaluate(retryTree); let children: UpdatingOpcode[] = []; - let errorBoundaryOp = new ErrorBoundaryOpcode( - closure, - vm.context, - block, - children, - errorState - ); + let errorBoundaryOp = new ErrorBoundaryOpcode(closure, vm.context, block, children, errorState); let result = retryVM.executeGuarded((retryVM) => { retryVM.updateWith(errorBoundaryOp); diff --git a/packages/@glimmer/runtime/lib/component/error-boundary.ts b/packages/@glimmer/runtime/lib/component/error-boundary.ts index b22164a0857..aa4cec83a23 100644 --- a/packages/@glimmer/runtime/lib/component/error-boundary.ts +++ b/packages/@glimmer/runtime/lib/component/error-boundary.ts @@ -1,3 +1,4 @@ +import type { UpdatableTag } from '@glimmer/validator'; import { consumeTag, dirtyTag, dirtyTagFor, tagFor } from '@glimmer/validator'; export interface ErrorBoundaryStateInterface { @@ -27,9 +28,9 @@ export class ErrorBoundaryState implements ErrorBoundaryStateInterface { // 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'), true); + dirtyTag(tagFor(this, '_error') as UpdatableTag, true); this._hasError = true; - dirtyTag(tagFor(this, '_hasError'), true); + dirtyTag(tagFor(this, '_hasError') as UpdatableTag, true); } retry = () => { diff --git a/packages/@glimmer/runtime/lib/vm/element-builder.ts b/packages/@glimmer/runtime/lib/vm/element-builder.ts index 4c9d59993dd..b65c729f92e 100644 --- a/packages/@glimmer/runtime/lib/vm/element-builder.ts +++ b/packages/@glimmer/runtime/lib/vm/element-builder.ts @@ -114,7 +114,11 @@ export class NewTreeBuilder implements TreeBuilder { * 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 { + 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); diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 64e9bfa6875..30a78e9ced9 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -287,9 +287,7 @@ export class ErrorBoundaryOpcode extends TryOpcode { // Save insertion marker: the sibling just before the boundary's content. // After resume() removes old DOM via bounds.reset(), any nodes between // this marker and lastNextSibling are partial leftovers from a failed render. - let insertionMarker = this.lastFirstNode - ? this.lastFirstNode.previousSibling - : null; + let insertionMarker = this.lastFirstNode ? this.lastFirstNode.previousSibling : null; destroyChildren(this); @@ -705,12 +703,8 @@ class UpdatingVMFrame { * Only error boundary handlers accept JavaScript errors. */ handleError(error: unknown): boolean { - if ( - this.exceptionHandler && - 'handleError' in this.exceptionHandler && - typeof (this.exceptionHandler as any).handleError === 'function' - ) { - (this.exceptionHandler as any).handleError(error); + if (this.exceptionHandler?.handleError) { + this.exceptionHandler.handleError(error); return true; } return false; diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index 2527b4afd63..f164a6034b7 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -116,9 +116,10 @@ export function restoreTrackingTo(depth: number): void { unwrap(debug.endTrackingTransaction)(); } } - CURRENT_TRACKER = OPEN_TRACK_FRAMES.length > 0 - ? OPEN_TRACK_FRAMES[OPEN_TRACK_FRAMES.length - 1] - : null; + CURRENT_TRACKER = + OPEN_TRACK_FRAMES.length > 0 + ? (OPEN_TRACK_FRAMES[OPEN_TRACK_FRAMES.length - 1] as Tracker) + : null; } // This function is only for handling errors and resetting to a valid state From cf260167f50e6decfa23c8abb036fed970244b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Wed, 4 Mar 2026 22:16:10 -0300 Subject: [PATCH 08/25] Add DEBUG console.error logging when ErrorBoundary catches errors --- .../components/error-boundary-test.ts | 73 +++++++++++++++++++ .../runtime/lib/compiled/opcodes/component.ts | 5 ++ packages/@glimmer/runtime/lib/vm/update.ts | 9 +++ 3 files changed, 87 insertions(+) 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 index 02a0d25e286..bc5fe6a13e1 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -118,6 +118,79 @@ moduleFor( this.renderComponent(Root, { expect: 'caught' }); } + /* eslint-disable no-console */ + '@test logs caught error to console.error in DEBUG mode during initial render'(assert: Assert) { + let originalConsoleError = console.error; + let errors: unknown[][] = []; + console.error = (...args: unknown[]) => errors.push(args); + + try { + let Root = defComponent( + '<:default><:error as |err|>caught', + { scope: { ErrorBoundary, Throwing } } + ); + + 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) { + let originalConsoleError = console.error; + let errors: unknown[][] = []; + + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = defComponent( + '<:default><:error as |err|>caught', + { scope: { ErrorBoundary, MaybeThrow, state } } + ); + + 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 = defComponent( '<:default><:error as |err|>caught: {{err.message}}', diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index c43461bf516..296776c3032 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -923,6 +923,11 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } vm.associateDestroyable(errorBoundaryOp); vm.updateWith(errorBoundaryOp); } catch (error) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.error('An error was caught by :', error); + } + // Roll back stale tracking frames left by the failed sub-VM render. restoreTrackingTo(trackingDepth); diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 30a78e9ced9..00afe0f8e05 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -306,6 +306,11 @@ export class ErrorBoundaryOpcode extends TryOpcode { }); associateDestroyableChild(this, result.drop); } catch (error) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.error('An error was caught by :', error); + } + // Restore tracking frames opened by the failed re-render attempt. restoreTrackingTo(trackingDepth); @@ -349,6 +354,10 @@ export class ErrorBoundaryOpcode extends TryOpcode { * skipping inner TryOpcode handlers that would corrupt block state. */ handleError(error: unknown) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.error('An error was caught by :', error); + } // Restore tracking to the depth from evaluate(), before children ran. // _execute's catch only restores to the depth of the failing opcode, // which doesn't cover tracking frames opened by earlier opcodes From 85a03c10c14d1580d26115f71035241b832f8b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Thu, 5 Mar 2026 12:58:26 -0300 Subject: [PATCH 09/25] [BUGFIX] Remove ErrorBoundary re-export from @glimmer/component ErrorBoundary should only be importable from @ember/component, matching the pattern of Input and Textarea. The value re-export was pulling 51 source files into type-tests compilation, causing 349 type errors. --- packages/@glimmer/component/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/@glimmer/component/src/index.ts b/packages/@glimmer/component/src/index.ts index dd7ee2aa8db..55936ebf3a2 100644 --- a/packages/@glimmer/component/src/index.ts +++ b/packages/@glimmer/component/src/index.ts @@ -406,5 +406,3 @@ export default class Component extends _GlimmerComponent { setComponentManager((owner: Owner) => { return new GlimmerComponentManager(owner); }, Component); - -export { ErrorBoundary } from '@ember/component'; From 758104547cb3bfbbc7ee8e4f41b3bdd8fae5443c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Thu, 5 Mar 2026 13:46:44 -0300 Subject: [PATCH 10/25] Skip DEBUG-only console.error tests in production builds The console.error logging is guarded by DEBUG in the source, so these tests can't pass in production builds. --- .../integration/components/error-boundary-test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 index bc5fe6a13e1..c4c5e57f61b 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -10,6 +10,7 @@ import { runDestroy, } from 'internal-test-helpers'; +import { DEBUG } from '@glimmer/env'; import { ErrorBoundary, setComponentManager } from '@ember/component'; import { array, on } from '@glimmer/runtime'; import { tracked } from '@glimmer/tracking'; @@ -120,6 +121,11 @@ moduleFor( /* 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); @@ -150,6 +156,11 @@ moduleFor( } '@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[][] = []; From 62cddba3540da13d802fd9fda5c00197f18f79a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Thu, 5 Mar 2026 17:29:10 -0300 Subject: [PATCH 11/25] [BUGFIX] Isolate modifier install errors in transaction commit A single modifier throwing during transaction.commit() previously aborted the entire loop, leaving all subsequently-scheduled modifiers uninstalled. Wrap each install/update in try-catch, collect the first error, and re-throw after all modifiers have been processed. --- packages/@glimmer/runtime/lib/environment.ts | 76 +++++++++++++------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/packages/@glimmer/runtime/lib/environment.ts b/packages/@glimmer/runtime/lib/environment.ts index dfc6ad3a765..73e51f308cd 100644 --- a/packages/@glimmer/runtime/lib/environment.ts +++ b/packages/@glimmer/runtime/lib/environment.ts @@ -58,39 +58,63 @@ 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) { + throw firstError; + } } } From 812e12c89aca582bceea6d62964bc2ef6c37fc39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Fri, 6 Mar 2026 16:41:48 -0300 Subject: [PATCH 12/25] Add @retryWith support to ErrorBoundary for automatic error recovery --- .../glimmer/lib/components/error-boundary.ts | 30 +- .../components/error-boundary-test.ts | 383 ++++++++++++++++++ .../runtime/lib/component/error-boundary.ts | 82 ++++ packages/@glimmer/runtime/lib/vm/update.ts | 39 +- packages/@glimmer/validator/lib/tracking.ts | 15 +- 5 files changed, 522 insertions(+), 27 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts b/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts index d0a79a91285..02c1fd3f8c4 100644 --- a/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts +++ b/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts @@ -12,7 +12,7 @@ import type { } from '@glimmer/interfaces'; import type { Reference } from '@glimmer/reference'; import { setComponentTemplate, setInternalComponentManager } from '@glimmer/manager'; -import { createConstRef } from '@glimmer/reference'; +import { createConstRef, valueForRef } from '@glimmer/reference'; import { ErrorBoundaryState } from '@glimmer/runtime'; import ErrorBoundaryTemplate from '../templates/error-boundary'; @@ -21,12 +21,12 @@ const CAPABILITIES: InternalComponentCapabilities = { dynamicLayout: false, dynamicTag: false, prepareArgs: false, - createArgs: false, + createArgs: true, attributeHook: false, elementHook: false, createCaller: false, dynamicScope: false, - updateHook: false, + updateHook: true, createInstance: true, wrapped: false, willDestroy: false, @@ -44,13 +44,21 @@ class ErrorBoundaryManager create( _owner: Owner, _definition: object, - _args: Nullable, + args: Nullable, _env: Environment, _dynamicScope: Nullable, _caller: Nullable, _hasDefaultBlock: boolean ): ErrorBoundaryState { - return new ErrorBoundaryState(); + let state = new ErrorBoundaryState(); + + if (args && args.named.has('retryWith')) { + let retryWithRef = args.named.get('retryWith'); + state.retryWithRef = retryWithRef; + state._lastRetryWithValue = valueForRef(retryWithRef); + } + + return state; } didCreate(): void {} @@ -70,7 +78,17 @@ class ErrorBoundaryManager return null; } - update(_instance: ErrorBoundaryState, _dynamicScope: Nullable): void {} + update(instance: ErrorBoundaryState, _dynamicScope: Nullable): void { + // Consume the @retryWith ref's tag at the ROOT tracking level (outside + // the EB component's JumpIfNotModified scope). This ensures the root's + // combined tag includes the retryWith value, so root JumpIfNotModified + // falls through when @retryWith changes — which in turn allows the EB's + // opcodes to run. + // The actual reset logic is in ErrorBoundaryOpcode.evaluate(). + if (instance.retryWithRef) { + valueForRef(instance.retryWithRef); + } + } didSplatAttributes( _instance: ErrorBoundaryState, 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 index c4c5e57f61b..85eb6b664bc 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -794,5 +794,388 @@ moduleFor( expect: 'xy', }); } + + // --- @retryWith tests --- + + '@test @retryWith resets error state when value changes'() { + let state = new (class { + @tracked shouldThrow = false; + @tracked routeName = 'route-a'; + })(); + + let ConditionalThrow = defComponent('{{this.value}}', { + component: class extends GlimmerishComponent { + get value() { + if ((this as any).args.shouldThrow) { + throw new Error('route error'); + } + return 'ok'; + } + }, + }); + + let Root = defComponent( + '<:default><:error as |err|>caught: {{err.message}}', + { scope: { ErrorBoundary, ConditionalThrow, state } } + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Trigger error + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught: route error', + }); + + // Change @retryWith value AND fix the error condition — boundary should reset + this.assertChange({ + change: () => { + state.shouldThrow = false; + state.routeName = 'route-b'; + }, + expect: 'ok', + }); + } + + '@test @retryWith does not reset if value unchanged'() { + let state = new (class { + @tracked shouldThrow = false; + @tracked routeName = 'route-a'; + })(); + + let ConditionalThrow = defComponent('{{this.value}}', { + component: class extends GlimmerishComponent { + get value() { + if ((this as any).args.shouldThrow) { + throw new Error('route error'); + } + return 'ok'; + } + }, + }); + + let Root = defComponent( + '<:default><:error as |err|>caught', + { scope: { ErrorBoundary, ConditionalThrow, state } } + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Trigger error + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + + // Fix the throw condition but DON'T change @retryWith — should stay in error + this.assertChange({ + change: () => (state.shouldThrow = false), + expect: 'caught', + }); + } + + '@test @retryWith re-catches if new value also causes error'() { + let state = new (class { + @tracked routeName = 'route-a'; + })(); + + // Always throws regardless of route + let Root = defComponent( + '<:default><:error as |err|>caught: {{err.message}}', + { scope: { ErrorBoundary, Throwing, state } } + ); + + this.renderComponent(Root, { expect: 'caught: render error' }); + + // Change @retryWith — boundary resets, but immediately catches again + this.assertChange({ + change: () => (state.routeName = 'route-b'), + expect: 'caught: render error', + }); + } + + '@test @retryWith with retry that still throws re-catches'() { + let state = new (class { + @tracked routeName = 'route-a'; + })(); + + // Always throws + let Root = defComponent( + '<:default><:error as |err retry|>caught ', + { scope: { ErrorBoundary, Throwing, state, on } } + ); + + this.renderComponent(Root, { expect: 'caught ' }); + + // Retry while error still exists — should re-catch + this.assertChange({ + change: () => clickElement('button'), + expect: 'caught ', + }); + } + + '@test @retryWith rerender error then retry without fixing re-catches'() { + let state = new (class { + @tracked shouldThrow = false; + @tracked routeName = 'route-a'; + })(); + + let ConditionalThrow = defComponent('{{this.value}}', { + component: class extends GlimmerishComponent { + get value() { + if ((this as any).args.shouldThrow) { + throw new Error('route error'); + } + return 'ok'; + } + }, + }); + + let Root = defComponent( + '<:default><:error as |err retry|>caught ', + { scope: { ErrorBoundary, ConditionalThrow, state, on } } + ); + + 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 @retryWith with retry after fixing state recovers'() { + let state = new (class { + @tracked shouldThrow = false; + @tracked routeName = 'route-a'; + })(); + + let ConditionalThrow = defComponent('{{this.value}}', { + component: class extends GlimmerishComponent { + get value() { + if ((this as any).args.shouldThrow) { + throw new Error('route error'); + } + return 'ok'; + } + }, + }); + + let Root = defComponent( + '<:default><:error as |err retry|>caught ', + { scope: { ErrorBoundary, ConditionalThrow, state, on } } + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Trigger error + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Fix state and retry — should recover + state.shouldThrow = false; + + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + } + + '@test multiple error-recovery cycles do not require extra retry clicks'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = defComponent( + '<:default><:error as |err retry|>caught ', + { scope: { ErrorBoundary, MaybeThrow, state, on } } + ); + + 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 @retryWith with array value resets when element changes'() { + let state = new (class { + @tracked shouldThrow = false; + @tracked valA = 'a'; + @tracked valB = 'b'; + })(); + + let ConditionalThrow = defComponent('{{this.value}}', { + component: class extends GlimmerishComponent { + get value() { + if ((this as any).args.shouldThrow) { + throw new Error('route error'); + } + return 'ok'; + } + }, + }); + + let Root = defComponent( + '<:default><:error as |err|>caught', + { scope: { ErrorBoundary, ConditionalThrow, state, array } } + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Trigger error + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + + // Change one array element AND fix the error — should reset + this.assertChange({ + change: () => { + state.shouldThrow = false; + state.valA = 'changed'; + }, + expect: 'ok', + }); + } + + '@test @retryWith with array value does not reset if elements unchanged'() { + let state = new (class { + @tracked shouldThrow = false; + @tracked valA = 'a'; + @tracked valB = 'b'; + })(); + + let ConditionalThrow = defComponent('{{this.value}}', { + component: class extends GlimmerishComponent { + get value() { + if ((this as any).args.shouldThrow) { + throw new Error('route error'); + } + return 'ok'; + } + }, + }); + + let Root = defComponent( + '<:default><:error as |err|>caught', + { scope: { ErrorBoundary, ConditionalThrow, state, array } } + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Trigger error + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + + // Fix throw condition but DON'T change array elements — should stay in error + this.assertChange({ + change: () => (state.shouldThrow = false), + expect: 'caught', + }); + } + + '@test @retryWith with undefined value works without error'() { + let state = new (class { + @tracked shouldThrow = false; + })(); + + // @retryWith is not passed — tests that undefined/missing arg is handled + let Root = defComponent( + '<:default><:error as |err|>caught', + { scope: { ErrorBoundary, MaybeThrow, state } } + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Trigger error — should still catch normally + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + } + + '@test ErrorBoundary without @retryWith stays in error state'() { + let state = new (class { + @tracked shouldThrow = false; + })(); + + let Root = defComponent( + '<:default><:error as |err|>caught', + { scope: { ErrorBoundary, MaybeThrow, state } } + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Trigger error + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + + // Fix the condition — but without @retryWith, boundary stays in error + this.assertChange({ + change: () => (state.shouldThrow = false), + expect: 'caught', + }); + } } ); diff --git a/packages/@glimmer/runtime/lib/component/error-boundary.ts b/packages/@glimmer/runtime/lib/component/error-boundary.ts index aa4cec83a23..af8202d4395 100644 --- a/packages/@glimmer/runtime/lib/component/error-boundary.ts +++ b/packages/@glimmer/runtime/lib/component/error-boundary.ts @@ -1,4 +1,6 @@ +import type { Reference } from '@glimmer/reference'; import type { UpdatableTag } from '@glimmer/validator'; +import { valueForRef } from '@glimmer/reference'; import { consumeTag, dirtyTag, dirtyTagFor, tagFor } from '@glimmer/validator'; export interface ErrorBoundaryStateInterface { @@ -6,12 +8,61 @@ export interface ErrorBoundaryStateInterface { hasError: boolean; setError(error: unknown): void; retry(): void; + checkRetryWith(): boolean; +} + +/** + * Shallow-compare two values. Supports primitives (===), arrays (element-wise), + * and plain objects (own-property value-wise). + */ +function shallowEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a == null || b == null) return false; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; + } + + if (typeof a === 'object' && typeof b === 'object') { + let keysA = Object.keys(a); + let keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (let key of keysA) { + if ( + (a as Record)[key] !== (b as Record)[key] + ) { + return false; + } + } + return true; + } + + return false; +} + +/** + * Snapshot a value for later comparison. Clones arrays and plain objects + * to prevent reference aliasing. + */ +function snapshot(value: unknown): unknown { + if (Array.isArray(value)) return value.slice(); + if (value !== null && typeof value === 'object') return { ...value }; + return value; } export class ErrorBoundaryState implements ErrorBoundaryStateInterface { private _error: unknown = null; private _hasError = false; + // @retryWith support: tracks a reference whose value, when changed, + // automatically clears the error state and retries the default block. + retryWithRef: Reference | null = null; + _lastRetryWithValue: unknown = undefined; + get error(): unknown { consumeTag(tagFor(this, '_error')); return this._error; @@ -39,4 +90,35 @@ export class ErrorBoundaryState implements ErrorBoundaryStateInterface { this._hasError = false; dirtyTagFor(this, '_hasError'); }; + + /** + * Check if the @retryWith value has changed. If it has and the boundary + * is in error state, clear the error state and return true so the caller + * (ErrorBoundaryOpcode.evaluate()) can re-render via handleException(). + * + * Also consumes the retryWith ref's tag (via valueForRef), keeping it in + * the current tracking frame. This ensures the EB component's + * JumpIfNotModified detects future @retryWith changes. + */ + checkRetryWith(): boolean { + if (this.retryWithRef === null) return false; + + let current = valueForRef(this.retryWithRef); + + if (!shallowEqual(current, this._lastRetryWithValue)) { + this._lastRetryWithValue = snapshot(current); + + if (this._hasError) { + 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); + return true; + } + } + + return false; + } } diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 00afe0f8e05..e316def3763 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -237,13 +237,6 @@ export class ErrorBoundaryOpcode extends TryOpcode { override evaluate(vm: UpdatingVM) { // Snapshot current DOM boundaries before child opcodes run. - // We need the nextSibling after lastNode (not lastNode itself) because - // child opcodes may insert temporary nodes (e.g., list sync markers) - // after lastNode. Using nextSibling ensures cleanup covers those too. - // - // Bounds are always initialized here because: - // - Initial render completes (with finalize()) before UpdatingVM is created. - // - transitionToError() re-renders synchronously, repopulating bounds. if (LOCAL_DEBUG) { expect( this.bounds.firstNode(), @@ -256,6 +249,20 @@ export class ErrorBoundaryOpcode extends TryOpcode { this.lastRenderTreeDepth = vm.env.debugRenderTree?.getDepth() ?? 0; this.lastTrackingDepth = 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; + + // Check @retryWith: consumes the retryWith ref's tag (keeping it in the + // EB's tracking frame) and, if the value changed while in error state, + // clears the error and returns true to trigger a re-render. + if (this.errorState.checkRetryWith()) { + this.handleException(); + return; + } + vm.try(this.children, this); } @@ -306,11 +313,6 @@ export class ErrorBoundaryOpcode extends TryOpcode { }); associateDestroyableChild(this, result.drop); } catch (error) { - if (DEBUG) { - // eslint-disable-next-line no-console - console.error('An error was caught by :', error); - } - // Restore tracking frames opened by the failed re-render attempt. restoreTrackingTo(trackingDepth); @@ -331,6 +333,11 @@ export class ErrorBoundaryOpcode extends TryOpcode { } bounds.resetPartial(); + + if (DEBUG) { + // eslint-disable-next-line no-console + console.error('An error was caught by :', error); + } this.errorState.setError(error); let retryTree = NewTreeBuilder.beginBlock(env, bounds, this.lastNextSibling); @@ -354,10 +361,6 @@ export class ErrorBoundaryOpcode extends TryOpcode { * skipping inner TryOpcode handlers that would corrupt block state. */ handleError(error: unknown) { - if (DEBUG) { - // eslint-disable-next-line no-console - console.error('An error was caught by :', error); - } // Restore tracking to the depth from evaluate(), before children ran. // _execute's catch only restores to the depth of the failing opcode, // which doesn't cover tracking frames opened by earlier opcodes @@ -377,6 +380,10 @@ export class ErrorBoundaryOpcode extends TryOpcode { destroyChildren(this); + if (DEBUG) { + // eslint-disable-next-line no-console + console.error('An error was caught by :', error); + } this.errorState.setError(error); // Clean up DOM manually rather than using bounds.reset() (via resume()), diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index f164a6034b7..67d684dc8c5 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -108,18 +108,23 @@ export function getTrackingDepth(): number { /** * 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 { while (OPEN_TRACK_FRAMES.length > depth) { - OPEN_TRACK_FRAMES.pop(); + // 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)(); } } - CURRENT_TRACKER = - OPEN_TRACK_FRAMES.length > 0 - ? (OPEN_TRACK_FRAMES[OPEN_TRACK_FRAMES.length - 1] as Tracker) - : null; } // This function is only for handling errors and resetting to a valid state From 3ddf1a1b4ddc3f6bcd1bfa529b00e16a15e21c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Fri, 6 Mar 2026 17:41:54 -0300 Subject: [PATCH 13/25] Fix CI: prettier formatting, eslint throw error, and TS decorator placement --- .../components/error-boundary-test.ts | 68 ++++++++----------- .../runtime/lib/component/error-boundary.ts | 4 +- packages/@glimmer/runtime/lib/environment.ts | 1 + 3 files changed, 32 insertions(+), 41 deletions(-) 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 index 85eb6b664bc..8176352b30c 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -23,6 +23,26 @@ import { renderComponent, type RenderResult } from '../../../lib/renderer'; import type Owner from '@ember/owner'; import { setOwner } from '@ember/-internals/owner'; +// --- Tracked state helpers for @retryWith tests --- +// Declared at module level to avoid TS1206 "Decorators are not valid here" +// which occurs with decorators inside anonymous class expressions. + +class RetryState { + @tracked shouldThrow = false; + @tracked routeName = 'route-a'; +} +class RouteOnlyState { + @tracked routeName = 'route-a'; +} +class ArrayRetryState { + @tracked shouldThrow = false; + @tracked valA = 'a'; + @tracked valB = 'b'; +} +class ThrowOnlyState { + @tracked shouldThrow = false; +} + // --- Test helper components --- const Throwing = defComponent('{{this.boom}}', { @@ -798,10 +818,7 @@ moduleFor( // --- @retryWith tests --- '@test @retryWith resets error state when value changes'() { - let state = new (class { - @tracked shouldThrow = false; - @tracked routeName = 'route-a'; - })(); + let state = new RetryState(); let ConditionalThrow = defComponent('{{this.value}}', { component: class extends GlimmerishComponent { @@ -838,10 +855,7 @@ moduleFor( } '@test @retryWith does not reset if value unchanged'() { - let state = new (class { - @tracked shouldThrow = false; - @tracked routeName = 'route-a'; - })(); + let state = new RetryState(); let ConditionalThrow = defComponent('{{this.value}}', { component: class extends GlimmerishComponent { @@ -875,9 +889,7 @@ moduleFor( } '@test @retryWith re-catches if new value also causes error'() { - let state = new (class { - @tracked routeName = 'route-a'; - })(); + let state = new RouteOnlyState(); // Always throws regardless of route let Root = defComponent( @@ -895,9 +907,7 @@ moduleFor( } '@test @retryWith with retry that still throws re-catches'() { - let state = new (class { - @tracked routeName = 'route-a'; - })(); + let state = new RouteOnlyState(); // Always throws let Root = defComponent( @@ -915,10 +925,7 @@ moduleFor( } '@test @retryWith rerender error then retry without fixing re-catches'() { - let state = new (class { - @tracked shouldThrow = false; - @tracked routeName = 'route-a'; - })(); + let state = new RetryState(); let ConditionalThrow = defComponent('{{this.value}}', { component: class extends GlimmerishComponent { @@ -952,10 +959,7 @@ moduleFor( } '@test @retryWith with retry after fixing state recovers'() { - let state = new (class { - @tracked shouldThrow = false; - @tracked routeName = 'route-a'; - })(); + let state = new RetryState(); let ConditionalThrow = defComponent('{{this.value}}', { component: class extends GlimmerishComponent { @@ -1055,11 +1059,7 @@ moduleFor( } '@test @retryWith with array value resets when element changes'() { - let state = new (class { - @tracked shouldThrow = false; - @tracked valA = 'a'; - @tracked valB = 'b'; - })(); + let state = new ArrayRetryState(); let ConditionalThrow = defComponent('{{this.value}}', { component: class extends GlimmerishComponent { @@ -1096,11 +1096,7 @@ moduleFor( } '@test @retryWith with array value does not reset if elements unchanged'() { - let state = new (class { - @tracked shouldThrow = false; - @tracked valA = 'a'; - @tracked valB = 'b'; - })(); + let state = new ArrayRetryState(); let ConditionalThrow = defComponent('{{this.value}}', { component: class extends GlimmerishComponent { @@ -1134,9 +1130,7 @@ moduleFor( } '@test @retryWith with undefined value works without error'() { - let state = new (class { - @tracked shouldThrow = false; - })(); + let state = new ThrowOnlyState(); // @retryWith is not passed — tests that undefined/missing arg is handled let Root = defComponent( @@ -1154,9 +1148,7 @@ moduleFor( } '@test ErrorBoundary without @retryWith stays in error state'() { - let state = new (class { - @tracked shouldThrow = false; - })(); + let state = new ThrowOnlyState(); let Root = defComponent( '<:default><:error as |err|>caught', diff --git a/packages/@glimmer/runtime/lib/component/error-boundary.ts b/packages/@glimmer/runtime/lib/component/error-boundary.ts index af8202d4395..ab64f902b56 100644 --- a/packages/@glimmer/runtime/lib/component/error-boundary.ts +++ b/packages/@glimmer/runtime/lib/component/error-boundary.ts @@ -32,9 +32,7 @@ function shallowEqual(a: unknown, b: unknown): boolean { let keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (let key of keysA) { - if ( - (a as Record)[key] !== (b as Record)[key] - ) { + if ((a as Record)[key] !== (b as Record)[key]) { return false; } } diff --git a/packages/@glimmer/runtime/lib/environment.ts b/packages/@glimmer/runtime/lib/environment.ts index 73e51f308cd..ea629812b05 100644 --- a/packages/@glimmer/runtime/lib/environment.ts +++ b/packages/@glimmer/runtime/lib/environment.ts @@ -113,6 +113,7 @@ class TransactionImpl implements Transaction { } if (firstError !== null) { + // eslint-disable-next-line @typescript-eslint/only-throw-error throw firstError; } } From 3eb60148035d84d4db3e11f65b7a85b233270f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Fri, 6 Mar 2026 19:12:22 -0300 Subject: [PATCH 14/25] Fix CI: always run runDestroy in ErrorBoundary test afterEach The DEBUG-only console.error tests bail early in production builds without ever setting this.component, causing the conditional runDestroy to be skipped and leaking Application namespaces. --- .../tests/integration/components/error-boundary-test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index 8176352b30c..440a1fa0e43 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -116,9 +116,7 @@ moduleFor( 'ErrorBoundary', class extends ErrorBoundaryTestCase { afterEach() { - if (this.component) { - runDestroy(this); - } + runDestroy(this); } '@test renders default block when no error'() { From 9029fc305371d7195f351c93d194fab280672049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Mon, 9 Mar 2026 11:41:20 -0300 Subject: [PATCH 15/25] [BUGFIX] Fix sub-VM cursor position causing inverted bounds in ErrorBoundary The guarded opcode handler (VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP) created a sub-VM tree builder with nextSibling=null, causing content to be appended at the end of the parent element instead of at the parent VM's cursor position. This inverted the bounds delegation chain (firstNode after lastNode in DOM order), crashing clear() during route transitions. Fix: capture the parent tree builder's nextSibling before pushing the ResettableBlock and pass it to the sub-VM's tree builder. Also improves ErrorBoundaryOpcode.handleException to use direct DOM cleanup with cached node references instead of walking the delegation chain, and balances the parent tree builder's block stack with popBlock(). --- .../components/error-boundary-test.ts | 153 ++++++++++++++++++ .../runtime/lib/compiled/opcodes/component.ts | 20 ++- .../runtime/lib/vm/element-builder.ts | 1 - packages/@glimmer/runtime/lib/vm/update.ts | 45 ++++-- 4 files changed, 201 insertions(+), 18 deletions(-) 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 index 440a1fa0e43..4efcae6ee82 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -42,6 +42,15 @@ class ArrayRetryState { class ThrowOnlyState { @tracked shouldThrow = false; } +class SiblingState { + @tracked label = 'before'; +} +class ConditionalState { + @tracked show = true; +} +class InnerState { + @tracked value = 'hello'; +} // --- Test helper components --- @@ -1167,5 +1176,149 @@ moduleFor( expect: 'caught', }); } + + '@test sibling content after ErrorBoundary updates correctly'() { + let state = new SiblingState(); + + let Sibling = defComponent('{{state.label}}', { scope: { state } }); + + let Root = defComponent( + '<:default>content<:error as |err|>caught', + { scope: { ErrorBoundary, Sibling } } + ); + + 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 = defComponent('{{state.value}}', { scope: { state } }); + + let Root = defComponent( + '<:default><:error as |err|>caught{{siblingState.label}}', + { scope: { ErrorBoundary, Inner, siblingState } } + ); + + 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 = defComponent( + '<:default>{{state.value}}<:error as |err|>caught', + { scope: { ErrorBoundary, state } } + ); + + 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 = defComponent( + '{{#if state.show}}<:default>content<:error as |err|>caught{{/if}}', + { scope: { ErrorBoundary, state } } + ); + + 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 = defComponent( + '{{#if cond.show}}<:default>eb<:error as |err|>caught{{state.label}}{{/if}}', + { scope: { ErrorBoundary, state, cond } } + ); + + 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 = defComponent('{{state.value}}', { scope: { state } }); + + let Root = defComponent( + '<:default><:error as |err|>caught', + { scope: { ErrorBoundary, Inner } } + ); + + this.renderComponent(Root, { expect: 'hello' }); + + this.assertChange({ + change: () => (state.value = 'world'), + expect: 'world', + }); + } + + '@test multiple re-renders of ErrorBoundary content'() { + let state = new InnerState(); + + let Root = defComponent( + '<:default>{{state.value}}<:error as |err|>caught', + { scope: { ErrorBoundary, state } } + ); + + 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/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index 296776c3032..d451139a852 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -893,6 +893,13 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } // 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. @@ -907,7 +914,8 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } try { // Use beginBlock (not resume) since this is a fresh block with no prior content. - let subTree = NewTreeBuilder.beginBlock(vm.env, block); + // 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[] = []; @@ -950,7 +958,7 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } errorState.setError(error); // Re-execute layout with error state (will render the error block). - let retryTree = NewTreeBuilder.beginBlock(vm.env, block); + let retryTree = NewTreeBuilder.beginBlock(vm.env, block, parentNextSibling); let retryVM = closure.evaluate(retryTree); let children: UpdatingOpcode[] = []; @@ -965,6 +973,14 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } 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 }) => { diff --git a/packages/@glimmer/runtime/lib/vm/element-builder.ts b/packages/@glimmer/runtime/lib/vm/element-builder.ts index b65c729f92e..a35dfec845e 100644 --- a/packages/@glimmer/runtime/lib/vm/element-builder.ts +++ b/packages/@glimmer/runtime/lib/vm/element-builder.ts @@ -541,7 +541,6 @@ export class ResettableBlockImpl extends AppendingBlockImpl implements Resettabl this.first = null; this.last = null; this.nesting = 0; - return nextSibling; } diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index e316def3763..0d6dc806f95 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -291,20 +291,37 @@ export class ErrorBoundaryOpcode extends TryOpcode { } let trackingDepth = this.lastTrackingDepth; - // Save insertion marker: the sibling just before the boundary's content. - // After resume() removes old DOM via bounds.reset(), any nodes between - // this marker and lastNextSibling are partial leftovers from a failed render. - let insertionMarker = this.lastFirstNode ? this.lastFirstNode.previousSibling : null; - 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.lastFirstNode) { + let current: SimpleNode | null; + if (this.lastFirstNode.parentNode === parent) { + current = this.lastFirstNode; + } else if (this.lastPreviousSibling) { + current = this.lastPreviousSibling.nextSibling; + } else { + current = parent.firstChild; + } + let stop = this.lastNextSibling; + while (current && current !== stop) { + let next: SimpleNode | null = current.nextSibling; + parent.removeChild(current); + current = next; + } + } + + // Reset block state without DOM cleanup (already done above). + bounds.resetPartial(); + try { - // Attempt a normal re-render like TryOpcode.handleException(), but use - // executeGuarded() instead of execute(). In DEBUG mode, execute() calls - // resetTracking() on error, which wipes out the parent UpdatingVM's - // tracking transaction (TRANSACTION_STACK / CONSUMED_TAGS), causing - // spurious backtracking assertions on later tracked property mutations. - let tree = NewTreeBuilder.resume(env, bounds); + // Re-render using beginBlock (not resume, which would call reset/clear). + let tree = NewTreeBuilder.beginBlock(env, bounds, this.lastNextSibling); let vm = this.state.evaluate(tree); let children = (this.children = []); let result = vm.executeGuarded((vm) => { @@ -320,10 +337,8 @@ export class ErrorBoundaryOpcode extends TryOpcode { env.debugRenderTree?.rollbackTo(this.lastRenderTreeDepth); // Remove partial DOM nodes left by the failed re-render. - // resume() already removed old content via bounds.reset(), so any nodes - // between insertionMarker and lastNextSibling are from the failed render. - let cursor: SimpleNode | null = insertionMarker - ? insertionMarker.nextSibling + let cursor: SimpleNode | null = this.lastPreviousSibling + ? this.lastPreviousSibling.nextSibling : parent.firstChild; let stop = this.lastNextSibling; while (cursor && cursor !== stop) { From 117963480d18734436e04faea59740252780aad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Mon, 9 Mar 2026 16:25:17 -0300 Subject: [PATCH 16/25] [PERF] Skip try-catch in UpdatingVM when no ErrorBoundary is active Track error boundary depth on the UpdatingVM so _execute() only pays the try-catch cost when inside an ErrorBoundary frame. Also reverts ListBlockOpcode.insertItem() to use execute() instead of executeGuarded(). --- packages/@glimmer/runtime/lib/vm/update.ts | 75 +++++++++++++--------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 0d6dc806f95..66769710354 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -36,6 +36,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; @@ -76,43 +77,51 @@ export class UpdatingVM implements IUpdatingVM { let opcode = this.frame.nextStatement(); if (opcode === undefined) { - frameStack.pop(); + this._popFrame(); continue; } - // Save tracking frame depth so we can discard stale frames if the - // opcode throws a JS exception (e.g., a tracked getter throwing). - let trackingDepth = getTrackingDepth(); + if (this._errorBoundaryDepth > 0) { + // Only pay the try-catch cost when inside an error boundary. + let trackingDepth = getTrackingDepth(); - try { - opcode.evaluate(this); - } catch (error) { - // Restore tracking frames to the depth before the failed opcode. - // Without this, stale frames corrupt the parent's tracking context. - restoreTrackingTo(trackingDepth); - - // Walk up the frame stack to find an error boundary that can handle - // JavaScript errors. We skip regular TryOpcode handlers because they - // would reset their block and re-render, which corrupts parent block - // references if the re-render also fails. - let handled = false; - - while (!frameStack.isEmpty()) { - if (this.frame.handleError(error)) { - frameStack.pop(); - handled = true; - break; + try { + opcode.evaluate(this); + } catch (error) { + // Restore tracking frames to the depth before the failed opcode. + restoreTrackingTo(trackingDepth); + + // Walk up the frame stack to find an error boundary that can handle + // JavaScript errors. + let handled = false; + + while (!frameStack.isEmpty()) { + if (this.frame.handleError(error)) { + this._popFrame(); + handled = true; + break; + } + this._popFrame(); } - frameStack.pop(); - } - if (!handled) { - throw error; + if (!handled) { + throw error; + } } + } else { + opcode.evaluate(this); } } } + private _popFrame() { + let frame = this.frameStack.current; + if (frame && frame.isErrorBoundary) { + this._errorBoundaryDepth--; + } + this.frameStack.pop(); + } + private get frame() { return expect(this.frameStack.current, 'bug: expected a frame'); } @@ -125,9 +134,14 @@ export class UpdatingVM implements IUpdatingVM { this.frameStack.push(new UpdatingVMFrame(ops, handler)); } + tryErrorBoundary(ops: UpdatingOpcode[], handler: ExceptionHandler) { + this._errorBoundaryDepth++; + this.frameStack.push(new UpdatingVMFrame(ops, handler, true)); + } + throw() { this.frame.handleException(); - this.frameStack.pop(); + this._popFrame(); } } @@ -263,7 +277,7 @@ export class ErrorBoundaryOpcode extends TryOpcode { return; } - vm.try(this.children, this); + vm.tryErrorBoundary(this.children, this); } override handleException() { @@ -646,7 +660,7 @@ export class ListBlockOpcode extends BlockOpcode { let vm = state.evaluate(elementStack); - vm.executeGuarded((vm) => { + vm.execute((vm) => { let opcode = vm.enterItem(item); opcode.index = children.length; @@ -711,7 +725,8 @@ class UpdatingVMFrame { constructor( private ops: UpdatingOpcode[], - private exceptionHandler: Nullable + private exceptionHandler: Nullable, + readonly isErrorBoundary = false ) {} goto(index: number) { From 76624f7d09689e98eb484805243e0d7374be6926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Mon, 9 Mar 2026 17:46:08 -0300 Subject: [PATCH 17/25] Clean up ErrorBoundary: extract helpers, improve naming, remove dead code - Make handleCaughtError required on ExceptionHandler interface - Add unreachable() stubs in TryOpcode and RenderResultImpl - Add clarifying comments for null end marker and resetPartial --- .../glimmer/lib/components/error-boundary.ts | 20 +- .../interfaces/lib/runtime/render.d.ts | 2 +- packages/@glimmer/runtime/index.ts | 4 +- .../runtime/lib/compiled/opcodes/component.ts | 17 +- .../runtime/lib/component/error-boundary.ts | 27 +- .../runtime/lib/vm/element-builder.ts | 5 + .../@glimmer/runtime/lib/vm/render-result.ts | 4 + packages/@glimmer/runtime/lib/vm/update.ts | 253 +++++++++--------- 8 files changed, 169 insertions(+), 163 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts b/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts index 02c1fd3f8c4..52fbea1478f 100644 --- a/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts +++ b/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts @@ -1,5 +1,4 @@ import type { - Bounds, Destroyable, DynamicScope, Environment, @@ -13,7 +12,9 @@ import type { import type { Reference } from '@glimmer/reference'; import { setComponentTemplate, setInternalComponentManager } from '@glimmer/manager'; import { createConstRef, valueForRef } from '@glimmer/reference'; -import { ErrorBoundaryState } from '@glimmer/runtime'; +import { ErrorBoundaryStateImpl } from '@glimmer/runtime'; + +import type { ErrorBoundaryState } from '@glimmer/runtime'; import ErrorBoundaryTemplate from '../templates/error-boundary'; @@ -50,12 +51,11 @@ class ErrorBoundaryManager _caller: Nullable, _hasDefaultBlock: boolean ): ErrorBoundaryState { - let state = new ErrorBoundaryState(); + let state = new ErrorBoundaryStateImpl(); if (args && args.named.has('retryWith')) { let retryWithRef = args.named.get('retryWith'); - state.retryWithRef = retryWithRef; - state._lastRetryWithValue = valueForRef(retryWithRef); + state.initRetryWith(retryWithRef, valueForRef(retryWithRef)); } return state; @@ -85,16 +85,8 @@ class ErrorBoundaryManager // falls through when @retryWith changes — which in turn allows the EB's // opcodes to run. // The actual reset logic is in ErrorBoundaryOpcode.evaluate(). - if (instance.retryWithRef) { - valueForRef(instance.retryWithRef); - } + instance.consumeRetryWith(); } - - didSplatAttributes( - _instance: ErrorBoundaryState, - _element: ErrorBoundaryState, - _operations: Bounds - ): void {} } const MANAGER = new ErrorBoundaryManager(); diff --git a/packages/@glimmer/interfaces/lib/runtime/render.d.ts b/packages/@glimmer/interfaces/lib/runtime/render.d.ts index a5835eed660..f7ae5d1f72b 100644 --- a/packages/@glimmer/interfaces/lib/runtime/render.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/render.d.ts @@ -6,7 +6,7 @@ import type { Environment } from './environment.js'; export interface ExceptionHandler { handleException(): void; - handleError?(error: unknown): void; + handleCaughtError(error: unknown): void; } export interface RenderResult extends Bounds, ExceptionHandler { diff --git a/packages/@glimmer/runtime/index.ts b/packages/@glimmer/runtime/index.ts index bdcf8914b2f..9f6c4ba4586 100644 --- a/packages/@glimmer/runtime/index.ts +++ b/packages/@glimmer/runtime/index.ts @@ -18,8 +18,8 @@ export { TemplateOnlyComponentManager, } from './lib/component/template-only'; export { - ErrorBoundaryState, - type ErrorBoundaryStateInterface, + ErrorBoundaryStateImpl, + type ErrorBoundaryState, } from './lib/component/error-boundary'; export { CurriedValue, curry } from './lib/curried-value'; export { diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index d451139a852..a4837935656 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -82,10 +82,10 @@ import type { BlockArgumentsImpl } from '../../vm/arguments'; import { getTrackingDepth, restoreTrackingTo } from '@glimmer/validator'; import { NewTreeBuilder } from '../../vm/element-builder'; -import { ErrorBoundaryOpcode } from '../../vm/update'; +import { clearDOMRange, ErrorBoundaryOpcode } from '../../vm/update'; import { ConcreteBounds } from '../../bounds'; -import type { ErrorBoundaryStateInterface } from '../../component/error-boundary'; +import type { ErrorBoundaryState } from '../../component/error-boundary'; import { hasCustomDebugRenderTreeLifecycle } from '../../component/interfaces'; import { resolveComponent } from '../../component/resolve'; import { isCurriedType, isCurriedValue, resolveCurriedValue } from '../../curried-value'; @@ -886,7 +886,7 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_OP, (vm, { op1: register }) => { // 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 ErrorBoundaryStateInterface; + 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 @@ -944,12 +944,11 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } // Remove any partial DOM nodes inserted during the failed render. // We can't use clear(block) because child bounds may be partially initialized. - let cursor = insertionMarker ? insertionMarker.nextSibling : parent.firstChild; - while (cursor) { - let next = cursor.nextSibling; - parent.removeChild(cursor); - cursor = next; - } + // null end marker is safe here: during initial render, no sibling content + // has been rendered after this component yet, so removing from start to + // the end of the parent won't affect other nodes. + let start = insertionMarker ? insertionMarker.nextSibling : parent.firstChild; + clearDOMRange(parent, start, null); // Reset block to empty state for the retry render. block.resetPartial(); diff --git a/packages/@glimmer/runtime/lib/component/error-boundary.ts b/packages/@glimmer/runtime/lib/component/error-boundary.ts index ab64f902b56..ef76423e7f3 100644 --- a/packages/@glimmer/runtime/lib/component/error-boundary.ts +++ b/packages/@glimmer/runtime/lib/component/error-boundary.ts @@ -3,11 +3,13 @@ import type { UpdatableTag } from '@glimmer/validator'; import { valueForRef } from '@glimmer/reference'; import { consumeTag, dirtyTag, dirtyTagFor, tagFor } from '@glimmer/validator'; -export interface ErrorBoundaryStateInterface { +export interface ErrorBoundaryState { error: unknown; hasError: boolean; setError(error: unknown): void; retry(): void; + initRetryWith(ref: Reference, initialValue: unknown): void; + consumeRetryWith(): void; checkRetryWith(): boolean; } @@ -17,6 +19,8 @@ export interface ErrorBoundaryStateInterface { */ function shallowEqual(a: unknown, b: unknown): boolean { if (a === b) return true; + // After the === check above, at most one side is null/undefined. + // Treat null and undefined as distinct (null !== undefined). if (a == null || b == null) return false; if (Array.isArray(a) && Array.isArray(b)) { @@ -52,14 +56,14 @@ function snapshot(value: unknown): unknown { return value; } -export class ErrorBoundaryState implements ErrorBoundaryStateInterface { +export class ErrorBoundaryStateImpl implements ErrorBoundaryState { private _error: unknown = null; private _hasError = false; // @retryWith support: tracks a reference whose value, when changed, // automatically clears the error state and retries the default block. - retryWithRef: Reference | null = null; - _lastRetryWithValue: unknown = undefined; + private retryWithRef: Reference | null = null; + private lastRetryWithValue: unknown = undefined; get error(): unknown { consumeTag(tagFor(this, '_error')); @@ -89,6 +93,17 @@ export class ErrorBoundaryState implements ErrorBoundaryStateInterface { dirtyTagFor(this, '_hasError'); }; + initRetryWith(ref: Reference, initialValue: unknown) { + this.retryWithRef = ref; + this.lastRetryWithValue = snapshot(initialValue); + } + + consumeRetryWith() { + if (this.retryWithRef) { + valueForRef(this.retryWithRef); + } + } + /** * Check if the @retryWith value has changed. If it has and the boundary * is in error state, clear the error state and return true so the caller @@ -103,8 +118,8 @@ export class ErrorBoundaryState implements ErrorBoundaryStateInterface { let current = valueForRef(this.retryWithRef); - if (!shallowEqual(current, this._lastRetryWithValue)) { - this._lastRetryWithValue = snapshot(current); + if (!shallowEqual(current, this.lastRetryWithValue)) { + this.lastRetryWithValue = snapshot(current); if (this._hasError) { this._error = null; diff --git a/packages/@glimmer/runtime/lib/vm/element-builder.ts b/packages/@glimmer/runtime/lib/vm/element-builder.ts index a35dfec845e..bffff6012e6 100644 --- a/packages/@glimmer/runtime/lib/vm/element-builder.ts +++ b/packages/@glimmer/runtime/lib/vm/element-builder.ts @@ -548,6 +548,11 @@ export class ResettableBlockImpl extends AppendingBlockImpl implements Resettabl * 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); diff --git a/packages/@glimmer/runtime/lib/vm/render-result.ts b/packages/@glimmer/runtime/lib/vm/render-result.ts index bb2129ddfa7..daf9db73981 100644 --- a/packages/@glimmer/runtime/lib/vm/render-result.ts +++ b/packages/@glimmer/runtime/lib/vm/render-result.ts @@ -44,4 +44,8 @@ export default class RenderResultImpl implements RenderResult { handleException() { unreachable(`this should never happen`); } + + handleCaughtError() { + unreachable(`this should never happen`); + } } diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 66769710354..ce38b851277 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -16,14 +16,16 @@ import type { UpdatingVM as IUpdatingVM, } from '@glimmer/interfaces'; import type { OpaqueIterationItem, OpaqueIterator, Reference } from '@glimmer/reference'; -import { expect, unwrap } from '@glimmer/debug-util'; +import { expect, unreachable, unwrap } from '@glimmer/debug-util'; import { associateDestroyableChild, destroy, destroyChildren } from '@glimmer/destroyable'; import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; import { updateRef, valueForRef } from '@glimmer/reference'; import { logStep, Stack } from '@glimmer/util'; import { debug, getTrackingDepth, resetTracking, restoreTrackingTo } from '@glimmer/validator'; -import type { ErrorBoundaryStateInterface } from '../component/error-boundary'; +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'; @@ -96,7 +98,7 @@ export class UpdatingVM implements IUpdatingVM { let handled = false; while (!frameStack.isEmpty()) { - if (this.frame.handleError(error)) { + if (this.frame.handleCaughtError(error)) { this._popFrame(); handled = true; break; @@ -193,6 +195,10 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { vm.try(this.children, this); } + handleCaughtError(_error: unknown) { + unreachable('handleCaughtError called on TryOpcode'); + } + handleException() { let { state, @@ -222,6 +228,23 @@ 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; + } +} + export class ErrorBoundaryOpcode extends TryOpcode { public type = 'error-boundary'; @@ -231,20 +254,20 @@ export class ErrorBoundaryOpcode extends TryOpcode { // 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 lastFirstNode from the DOM via bounds.reset() before the error + // may detach cachedFirstNode from the DOM via bounds.reset() before the error // reaches us. - private lastFirstNode: SimpleNode | null = null; - private lastPreviousSibling: SimpleNode | null = null; - private lastNextSibling: SimpleNode | null = null; - private lastRenderTreeDepth = 0; - private lastTrackingDepth = 0; + private cachedFirstNode: SimpleNode | null = null; + private cachedPreviousSibling: SimpleNode | null = null; + private cachedNextSibling: SimpleNode | null = null; + private cachedRenderTreeDepth = 0; + private cachedTrackingDepth = 0; constructor( state: Closure, context: EvaluationContext, bounds: ResettableBlock, children: UpdatingOpcode[], - private errorState: ErrorBoundaryStateInterface + private errorState: ErrorBoundaryState ) { super(state, context, bounds, children); } @@ -257,11 +280,11 @@ export class ErrorBoundaryOpcode extends TryOpcode { 'BUG: ErrorBoundaryOpcode.evaluate() called with uninitialized bounds' ); } - this.lastFirstNode = this.bounds.firstNode(); - this.lastPreviousSibling = this.lastFirstNode.previousSibling; - this.lastNextSibling = this.bounds.lastNode().nextSibling; - this.lastRenderTreeDepth = vm.env.debugRenderTree?.getDepth() ?? 0; - this.lastTrackingDepth = getTrackingDepth(); + 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 @@ -280,6 +303,68 @@ export class ErrorBoundaryOpcode extends TryOpcode { vm.tryErrorBoundary(this.children, this); } + /** + * 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() { + restoreTrackingTo(this.cachedTrackingDepth); + if (DEBUG) { + debug.resetConsumedTags?.(); + } + } + + /** + * 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!.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 tree = NewTreeBuilder.beginBlock(env, bounds, this.cachedNextSibling); + let vm = this.state.evaluate(tree); + let children = (this.children = []); + let result = vm.executeGuarded((vm) => { + vm.updateWith(this); + vm.pushUpdating(children); + }); + associateDestroyableChild(this, result.drop); + } + + /** + * 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, @@ -287,23 +372,8 @@ export class ErrorBoundaryOpcode extends TryOpcode { } = this; let parent = bounds.parentElement(); - // Restore tracking to the depth captured in evaluate(), BEFORE children - // ran. When vm.throw() is called (e.g. by an Assert opcode), it - // short-circuits the children's frame — any tracking frames opened by - // BeginTrackFrameOpcode will never see their matching EndTrackFrameOpcode. - // Without this, stale frames (and their DEBUG tracking transactions) leak - // onto OPEN_TRACK_FRAMES / TRANSACTION_STACK, keeping CONSUMED_TAGS alive - // and causing spurious backtracking assertions on later mutations. - restoreTrackingTo(this.lastTrackingDepth); - // Discard consumed-tag entries from the popped tracking frames. The - // CONSUMED_TAGS WeakMap can't be selectively pruned, so replace it - // with a fresh one. Without this, stale entries (e.g. a @tracked - // property consumed during the failed render) cause backtracking - // assertions when the property is later mutated outside the render. - if (DEBUG) { - debug.resetConsumedTags?.(); - } - let trackingDepth = this.lastTrackingDepth; + this.restoreTracking(); + let trackingDepth = this.cachedTrackingDepth; destroyChildren(this); @@ -313,53 +383,27 @@ export class ErrorBoundaryOpcode extends TryOpcode { // 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.lastFirstNode) { - let current: SimpleNode | null; - if (this.lastFirstNode.parentNode === parent) { - current = this.lastFirstNode; - } else if (this.lastPreviousSibling) { - current = this.lastPreviousSibling.nextSibling; - } else { - current = parent.firstChild; - } - let stop = this.lastNextSibling; - while (current && current !== stop) { - let next: SimpleNode | null = current.nextSibling; - parent.removeChild(current); - current = next; - } + if (this.cachedFirstNode) { + clearDOMRange(parent, this.resolveCachedStart(parent), this.cachedNextSibling); } // Reset block state without DOM cleanup (already done above). bounds.resetPartial(); try { - // Re-render using beginBlock (not resume, which would call reset/clear). - let tree = NewTreeBuilder.beginBlock(env, bounds, this.lastNextSibling); - let vm = this.state.evaluate(tree); - let children = (this.children = []); - let result = vm.executeGuarded((vm) => { - vm.updateWith(this); - vm.pushUpdating(children); - }); - associateDestroyableChild(this, result.drop); + this.renderIntoBlock(); } catch (error) { // Restore tracking frames opened by the failed re-render attempt. restoreTrackingTo(trackingDepth); // Roll back stale debug render tree entries. - env.debugRenderTree?.rollbackTo(this.lastRenderTreeDepth); + env.debugRenderTree?.rollbackTo(this.cachedRenderTreeDepth); // Remove partial DOM nodes left by the failed re-render. - let cursor: SimpleNode | null = this.lastPreviousSibling - ? this.lastPreviousSibling.nextSibling + let start: SimpleNode | null = this.cachedPreviousSibling + ? this.cachedPreviousSibling.nextSibling : parent.firstChild; - let stop = this.lastNextSibling; - while (cursor && cursor !== stop) { - let next: SimpleNode | null = cursor.nextSibling; - parent.removeChild(cursor); - cursor = next; - } + clearDOMRange(parent, start, this.cachedNextSibling); bounds.resetPartial(); @@ -369,18 +413,8 @@ export class ErrorBoundaryOpcode extends TryOpcode { } this.errorState.setError(error); - let retryTree = NewTreeBuilder.beginBlock(env, bounds, this.lastNextSibling); - let retryVM = this.state.evaluate(retryTree); - let children = (this.children = []); - let result = retryVM.executeGuarded((vm) => { - vm.updateWith(this); - vm.pushUpdating(children); - }); - associateDestroyableChild(this, result.drop); - - this.lastFirstNode = null; - this.lastPreviousSibling = null; - this.lastNextSibling = null; + this.renderIntoBlock(); + this.clearCachedNodes(); } } @@ -389,24 +423,14 @@ export class ErrorBoundaryOpcode extends TryOpcode { * Called directly by the UpdatingVM when a JS exception occurs, * skipping inner TryOpcode handlers that would corrupt block state. */ - handleError(error: unknown) { - // Restore tracking to the depth from evaluate(), before children ran. - // _execute's catch only restores to the depth of the failing opcode, - // which doesn't cover tracking frames opened by earlier opcodes - // (like BeginTrackFrameOpcode) in the children's frame. - restoreTrackingTo(this.lastTrackingDepth); - if (DEBUG) { - debug.resetConsumedTags?.(); - } - this.transitionToError(error); - } - - private transitionToError(error: unknown) { + handleCaughtError(error: unknown) { let { bounds, context: { env }, } = this; + this.restoreTracking(); + destroyChildren(this); if (DEBUG) { @@ -423,51 +447,18 @@ export class ErrorBoundaryOpcode extends TryOpcode { // handles both cases. let parent = bounds.parentElement(); - if (this.lastFirstNode) { - // Determine cleanup start: if lastFirstNode is still in the DOM, start - // there. If it was detached (by an inner TryOpcode's bounds.reset()), - // use the cached previousSibling to find the current start point. - let current: SimpleNode | null; - if (this.lastFirstNode.parentNode === parent) { - current = this.lastFirstNode; - } else if (this.lastPreviousSibling) { - current = this.lastPreviousSibling.nextSibling; - } else { - current = parent.firstChild; - } - let stop = this.lastNextSibling; - - while (current && current !== stop) { - let next: SimpleNode | null = current.nextSibling; - parent.removeChild(current); - current = next; - } + 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.lastRenderTreeDepth); + env.debugRenderTree?.rollbackTo(this.cachedRenderTreeDepth); bounds.resetPartial(); - let tree = NewTreeBuilder.beginBlock(env, bounds, this.lastNextSibling); - - let vm = this.state.evaluate(tree); - - let children = (this.children = []); - - let result = vm.executeGuarded((vm) => { - vm.updateWith(this); - vm.pushUpdating(children); - }); - - associateDestroyableChild(this, result.drop); - - // Clear cached DOM references — they pointed at the pre-error content - // which has been removed. Fresh values are captured in the next evaluate(). - this.lastFirstNode = null; - this.lastPreviousSibling = null; - this.lastNextSibling = null; + this.renderIntoBlock(); + this.clearCachedNodes(); } } @@ -748,9 +739,9 @@ class UpdatingVMFrame { * Returns true if the handler accepted the error, false otherwise. * Only error boundary handlers accept JavaScript errors. */ - handleError(error: unknown): boolean { - if (this.exceptionHandler?.handleError) { - this.exceptionHandler.handleError(error); + handleCaughtError(error: unknown): boolean { + if (this.isErrorBoundary && this.exceptionHandler) { + this.exceptionHandler.handleCaughtError(error); return true; } return false; From 8a3fc4719f8e4cd13711d5c3f2773895f4a4d7ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Mon, 9 Mar 2026 22:16:17 -0300 Subject: [PATCH 18/25] [BUGFIX] Fix ErrorBoundary cleanup of {{#in-element}} remote DOM - Add dropBlocks() to TreeBuilder: pops blocks without finalize() during error cleanup, preventing placeholder comment leaks into remote elements - Destroy sub-VM's destroyable root in executeGuarded catch path so RemoteBlock destructors fire and clear orphaned remote DOM - Guard RemoteBlock destructor against uninitialized bounds (error thrown before any content rendered into the remote element) - Add integration tests for {{#in-element}} inside ErrorBoundary (initial render error + rerender error) --- .../components/error-boundary-test.ts | 47 +++++++++++++++++++ .../interfaces/lib/dom/attributes.d.ts | 1 + packages/@glimmer/runtime/lib/vm/append.ts | 15 +++--- .../runtime/lib/vm/element-builder.ts | 22 ++++++++- 4 files changed, 77 insertions(+), 8 deletions(-) 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 index 4efcae6ee82..bf87cf92c26 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -1295,6 +1295,53 @@ moduleFor( }); } + '@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 = defComponent( + '<:default>{{#in-element (getRemote) insertBefore=null}}{{/in-element}}<:error as |err|>caught', + { scope: { ErrorBoundary, Throwing, getRemote } } + ); + + 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 = defComponent( + '<:default>{{#in-element (getRemote) insertBefore=null}}{{/in-element}}<:error as |err|>caught', + { scope: { ErrorBoundary, MaybeThrow, getRemote, state } } + ); + + 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(); diff --git a/packages/@glimmer/interfaces/lib/dom/attributes.d.ts b/packages/@glimmer/interfaces/lib/dom/attributes.d.ts index e04327d0f6a..b8ec4a2a915 100644 --- a/packages/@glimmer/interfaces/lib/dom/attributes.d.ts +++ b/packages/@glimmer/interfaces/lib/dom/attributes.d.ts @@ -112,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/runtime/lib/vm/append.ts b/packages/@glimmer/runtime/lib/vm/append.ts index dabf7606bf5..180366303ee 100644 --- a/packages/@glimmer/runtime/lib/vm/append.ts +++ b/packages/@glimmer/runtime/lib/vm/append.ts @@ -23,7 +23,7 @@ import type { import type { OpaqueIterationItem, OpaqueIterator, Reference } from '@glimmer/reference'; import type { MachineRegister, Register, SyscallRegister } from '@glimmer/vm'; import { dev, expect, unwrapHandle } from '@glimmer/debug-util'; -import { associateDestroyableChild } from '@glimmer/destroyable'; +import { associateDestroyableChild, destroy } from '@glimmer/destroyable'; import { assertGlobalContextWasSet } from '@glimmer/global-context'; import { LOCAL_DEBUG, LOCAL_TRACE_LOGGING } from '@glimmer/local-debug-flags'; import { createIteratorItemRef, UNDEFINED_REFERENCE } from '@glimmer/reference'; @@ -733,11 +733,14 @@ export class VM { try { return this._execute(initialize); } catch (e) { - // Clean up block stack without resetting tracking (preserve parent's state). - let elements = this.tree(); - while (elements.hasBlocks) { - elements.popBlock(); - } + // 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}}). Without this, + // remote DOM content would leak when the error boundary catches the + // error and re-renders with error state. + destroy(this.#stacks.drop); throw e; } } diff --git a/packages/@glimmer/runtime/lib/vm/element-builder.ts b/packages/@glimmer/runtime/lib/vm/element-builder.ts index bffff6012e6..12e5bb47fdf 100644 --- a/packages/@glimmer/runtime/lib/vm/element-builder.ts +++ b/packages/@glimmer/runtime/lib/vm/element-builder.ts @@ -205,6 +205,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 {} @@ -521,8 +534,13 @@ export class RemoteBlock extends AppendingBlockImpl { // and avoid clearing the node if it was. In most cases this shouldn't happen, // so this might hide bugs where the code clears nested nodes unnecessarily, // so we should eventually try to do the correct fix. - if (this.parentElement() === this.firstNode().parentNode) { - clear(this); + try { + if (this.parentElement() === this.firstNode().parentNode) { + clear(this); + } + } catch { + // Block bounds are not fully initialized (e.g. an error was thrown + // during initial render before content was appended). Nothing to clear. } }); } From 49c179ae046376caf2a686176a922d8cadc71f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 10 Mar 2026 00:17:24 -0300 Subject: [PATCH 19/25] [BUGFIX] Fix test regressions caused by dropBlocks in executeGuarded The previous commit introduced dropBlocks() for ErrorBoundary cleanup, but used it in executeGuarded which is also called by TryOpcode. TryOpcode needs popBlock/finalize to insert placeholders. Split into two methods: executeGuarded (popBlock) for TryOpcode, executeErrorBoundary (dropBlocks + destroy) for ErrorBoundary. --- .../runtime/lib/compiled/opcodes/component.ts | 7 ++--- packages/@glimmer/runtime/lib/vm/append.ts | 26 ++++++++++++++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index a4837935656..6aeaaf83a70 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -921,8 +921,9 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } let children: UpdatingOpcode[] = []; let errorBoundaryOp = new ErrorBoundaryOpcode(closure, vm.context, block, children, errorState); - // Use executeGuarded to avoid resetting the parent VM's tracking state. - let result = subVM.executeGuarded((subVM) => { + // 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); }); @@ -963,7 +964,7 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } let children: UpdatingOpcode[] = []; let errorBoundaryOp = new ErrorBoundaryOpcode(closure, vm.context, block, children, errorState); - let result = retryVM.executeGuarded((retryVM) => { + let result = retryVM.executeErrorBoundary((retryVM) => { retryVM.updateWith(errorBoundaryOp); retryVM.pushUpdating(children); }); diff --git a/packages/@glimmer/runtime/lib/vm/append.ts b/packages/@glimmer/runtime/lib/vm/append.ts index 180366303ee..fde30f51e64 100644 --- a/packages/@glimmer/runtime/lib/vm/append.ts +++ b/packages/@glimmer/runtime/lib/vm/append.ts @@ -729,7 +729,29 @@ export class VM { * 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) { + 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) { @@ -737,9 +759,7 @@ export class VM { // 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}}). Without this, - // remote DOM content would leak when the error boundary catches the - // error and re-renders with error state. + // resources (e.g. RemoteBlocks from {{#in-element}}). destroy(this.#stacks.drop); throw e; } From f752c7e82735bba869132ecf2e2eb6492becc2dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 10 Mar 2026 01:10:57 -0300 Subject: [PATCH 20/25] Fix CI: revert insertItem to executeGuarded, fix eslint and prettier The PERF commit changed ListBlockOpcode.insertItem() from executeGuarded() to execute(), but executeGuarded is needed to clean up blocks/tracking when a list item throws inside an ErrorBoundary. Also fixes eslint non-null assertion and prettier formatting. --- .../integration/components/error-boundary-test.ts | 12 ++++++++++-- packages/@glimmer/runtime/index.ts | 5 +---- packages/@glimmer/runtime/lib/vm/update.ts | 4 ++-- 3 files changed, 13 insertions(+), 8 deletions(-) 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 index bf87cf92c26..fc2338f15a1 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -1311,7 +1311,11 @@ moduleFor( 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'); + this.assert.strictEqual( + remote.innerHTML, + '', + 'remote element should be completely empty after error' + ); } '@test catches rerender error and cleans up {{#in-element}} remote DOM'() { @@ -1339,7 +1343,11 @@ moduleFor( }); // Remote element should be completely empty — no DOM nodes at all. - this.assert.strictEqual(remote.innerHTML, '', 'remote element should be completely empty after error'); + this.assert.strictEqual( + remote.innerHTML, + '', + 'remote element should be completely empty after error' + ); } '@test multiple re-renders of ErrorBoundary content'() { diff --git a/packages/@glimmer/runtime/index.ts b/packages/@glimmer/runtime/index.ts index 9f6c4ba4586..9656a3c432e 100644 --- a/packages/@glimmer/runtime/index.ts +++ b/packages/@glimmer/runtime/index.ts @@ -17,10 +17,7 @@ export { templateOnlyComponent, TemplateOnlyComponentManager, } from './lib/component/template-only'; -export { - ErrorBoundaryStateImpl, - type ErrorBoundaryState, -} from './lib/component/error-boundary'; +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/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index ce38b851277..0a82d1cd9f0 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -324,7 +324,7 @@ export class ErrorBoundaryOpcode extends TryOpcode { * the parent's firstChild. */ private resolveCachedStart(parent: SimpleElement): SimpleNode | null { - if (this.cachedFirstNode!.parentNode === parent) { + if (this.cachedFirstNode && this.cachedFirstNode.parentNode === parent) { return this.cachedFirstNode; } else if (this.cachedPreviousSibling) { return this.cachedPreviousSibling.nextSibling; @@ -651,7 +651,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; From 6d5d7ddd8e6a95ec076d9518e075f709ddcd59f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Wed, 9 Sep 2026 14:30:11 -0300 Subject: [PATCH 21/25] Migrate ErrorBoundary tests off the removed defComponent helper Upstream deleted defComponent and defineComponent from internal-test-helpers when it unified the test infrastructure on precompileTemplate. Rewrite all 72 call sites to the replacement idiom: setComponentTemplate( precompileTemplate(src, { strictMode: true, scope: () => ({ ... }) }), templateOnly() ) Templates that passed no scope stay non-strict, matching what the old helper did with a null scope. --- .../components/error-boundary-test.ts | 635 +++++++++++------- 1 file changed, 410 insertions(+), 225 deletions(-) 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 index fc2338f15a1..538261f24c8 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -3,7 +3,6 @@ import { assertHTML, buildOwner, clickElement, - defComponent, defineSimpleHelper, defineSimpleModifier, moduleFor, @@ -11,6 +10,8 @@ import { } 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'; @@ -18,7 +19,7 @@ import GlimmerishComponent from '../../utils/glimmerish-component'; import { run } from '@ember/runloop'; import { associateDestroyableChild, destroy, registerDestructor } from '@glimmer/destroyable'; -import { componentCapabilities } from '@glimmer/manager'; +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'; @@ -54,24 +55,26 @@ class InnerState { // --- Test helper components --- -const Throwing = defComponent('{{this.boom}}', { - component: class extends GlimmerishComponent { +const Throwing = setComponentTemplate( + precompileTemplate('{{this.boom}}'), + class extends GlimmerishComponent { get boom(): never { throw new Error('render error'); } - }, -}); + } +); -const MaybeThrow = defComponent('{{this.value}}', { - component: class extends GlimmerishComponent { +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 --- @@ -129,18 +132,24 @@ moduleFor( } '@test renders default block when no error'() { - let Root = defComponent( - '<:default>hello<:error as |err|>caught', - { scope: { ErrorBoundary } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>hello<:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'hello' }); } '@test catches error during initial render and shows error block'() { - let Root = defComponent( - '<:default><:error as |err|>caught', - { scope: { ErrorBoundary, Throwing } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'caught' }); @@ -158,9 +167,12 @@ moduleFor( console.error = (...args: unknown[]) => errors.push(args); try { - let Root = defComponent( - '<:default><:error as |err|>caught', - { scope: { ErrorBoundary, Throwing } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'caught' }); @@ -196,9 +208,12 @@ moduleFor( } let state = new State(); - let Root = defComponent( - '<:default><:error as |err|>caught', - { scope: { ErrorBoundary, MaybeThrow, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -230,9 +245,12 @@ moduleFor( /* eslint-enable no-console */ '@test passes error object to error block'() { - let Root = defComponent( - '<:default><:error as |err|>caught: {{err.message}}', - { scope: { ErrorBoundary, Throwing } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'caught: render error' }); @@ -244,9 +262,12 @@ moduleFor( } let state = new State(); - let Root = defComponent( - '<:default><:error as |err|>caught', - { scope: { ErrorBoundary, MaybeThrow, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -263,9 +284,12 @@ moduleFor( } let state = new State(); - let Root = defComponent( - '<:default><:error as |err retry|>', - { scope: { ErrorBoundary, MaybeThrow, state, on } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err retry|>', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: '' }); @@ -279,18 +303,25 @@ moduleFor( } '@test nested boundaries — inner catches, outer unaffected'() { - let Root = defComponent( - '<:default>outer ok <:default><:error as |err|>inner caught<:error as |err|>outer caught', - { scope: { ErrorBoundary, Throwing } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>outer ok <:default><:error as |err|>inner caught<:error 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 = defComponent('', { - scope: { ErrorBoundary, Throwing }, - }); + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ ErrorBoundary, Throwing }), + }), + templateOnly() + ); this.renderComponent(Root, { expect: '' }); } @@ -306,17 +337,20 @@ moduleFor( increment = () => this.count++; } - let Counter = defComponent( - '{{this.count}}', - { - component: CounterComponent, - scope: { on }, - } + let Counter = setComponentTemplate( + precompileTemplate( + '{{this.count}}', + { strictMode: true, scope: () => ({ on }) } + ), + CounterComponent ); - let Root = defComponent( - '<:default><:error as |err retry|>', - { scope: { ErrorBoundary, MaybeThrow, Counter, state, on } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err retry|>', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, Counter, state, on }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: '' }); @@ -335,41 +369,57 @@ moduleFor( } '@test sibling content survives error and recovery round-trip'() { - let Root = defComponent( - 'before<:default><:error as |err|>caughtafter', - { scope: { ErrorBoundary, Throwing } } + let Root = setComponentTemplate( + precompileTemplate( + 'before<:default><:error as |err|>caughtafter', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'beforecaughtafter' }); } '@test catches error from deeply nested grandchild component'() { - let Child = defComponent('', { scope: { Throwing } }); - let Parent = defComponent('', { scope: { Child } }); + let Child = setComponentTemplate( + precompileTemplate('', { strictMode: true, scope: () => ({ Throwing }) }), + templateOnly() + ); + let Parent = setComponentTemplate( + precompileTemplate('', { strictMode: true, scope: () => ({ Child }) }), + templateOnly() + ); - let Root = defComponent( - '<:default><:error as |err|>caught: {{err.message}}', - { scope: { ErrorBoundary, Parent } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error 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 = defComponent('{{this.value}}', { - component: class extends GlimmerishComponent { + 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 = defComponent( - '<:default>{{#each (array "good" "bad" "also-good") as |item|}}{{/each}}<:error as |err|>caught: {{err.message}}', - { scope: { ErrorBoundary, ItemComponent, array } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>{{#each (array "good" "bad" "also-good") as |item|}}{{/each}}<:error as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, ItemComponent, array }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'caught: bad item' }); @@ -386,9 +436,12 @@ moduleFor( return 'helper ok'; }); - let Root = defComponent( - '<:default>{{maybeThrowHelper state.shouldThrow}}<:error as |err|>caught: {{err.message}}', - { scope: { ErrorBoundary, maybeThrowHelper, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>{{maybeThrowHelper state.shouldThrow}}<:error as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, maybeThrowHelper, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'helper ok' }); @@ -404,9 +457,12 @@ moduleFor( throw new Error('helper error'); }); - let Root = defComponent( - '<:default>{{throwingHelper}}<:error as |err|>caught: {{err.message}}', - { scope: { ErrorBoundary, throwingHelper } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>{{throwingHelper}}<:error as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, throwingHelper }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'caught: helper error' }); @@ -418,9 +474,12 @@ moduleFor( element.setAttribute('data-modified', 'true'); }); - let Root = defComponent( - '<:default>
content
<:error as |err|>caught
', - { scope: { ErrorBoundary, trackingModifier } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>
content
<:error as |err|>caught
', + { strictMode: true, scope: () => ({ ErrorBoundary, trackingModifier }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: '
content
' }); @@ -434,17 +493,21 @@ moduleFor( } let state = new State(); - let FullName = defComponent('{{this.fullName}}', { - component: class extends GlimmerishComponent { + let FullName = setComponentTemplate( + precompileTemplate('{{this.fullName}}'), + class extends GlimmerishComponent { get fullName() { return `${(this as any).args.first} ${(this as any).args.last}`; } - }, - }); + } + ); - let Root = defComponent( - '<:default><:error as |err|>caught', - { scope: { ErrorBoundary, FullName, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, FullName, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'Ada Lovelace' }); @@ -461,9 +524,12 @@ moduleFor( } '@test multiple sibling boundaries — one errors, other stays intact'() { - let Root = defComponent( - '<:default><:error as |err|>first caught<:default>second ok<:error as |err|>second caught', - { scope: { ErrorBoundary, Throwing } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>first caught<:default>second ok<:error as |err|>second caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'first caughtsecond ok' }); @@ -475,9 +541,12 @@ moduleFor( } let state = new State(); - let Root = defComponent( - '{{state.counter}}<:default><:error as |err|>caught', - { scope: { ErrorBoundary, Throwing, state } } + let Root = setComponentTemplate( + precompileTemplate( + '{{state.counter}}<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: '0caught' }); @@ -499,9 +568,12 @@ moduleFor( } let state = new State(); - let Root = defComponent( - '{{state.label}}<:default><:error as |err|>caught', - { scope: { ErrorBoundary, Throwing, state } } + let Root = setComponentTemplate( + precompileTemplate( + '{{state.label}}<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'hellocaught' }); @@ -513,9 +585,12 @@ moduleFor( } '@test catches error from conditional branch during initial render'() { - let Root = defComponent( - '<:default>{{#if true}}{{else}}safe{{/if}}<:error as |err|>caught: {{err.message}}', - { scope: { ErrorBoundary, Throwing } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>{{#if true}}{{else}}safe{{/if}}<:error as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'caught: render error' }); @@ -527,20 +602,24 @@ moduleFor( } let state = new State(); - let ItemComponent = defComponent('{{this.value}}', { - component: class extends GlimmerishComponent { + 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 = defComponent( - '<:default>{{#each state.items as |item|}}{{/each}}<:error as |err|>caught: {{err.message}}', - { scope: { ErrorBoundary, ItemComponent, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>{{#each state.items as |item|}}{{/each}}<:error as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, ItemComponent, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ab' }); @@ -559,9 +638,12 @@ moduleFor( throw new Error('modifier error'); }); - let Root = defComponent( - '<:default>
content
<:error as |err|>caught: {{err.message}}
', - { scope: { ErrorBoundary, throwingModifier } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>
content
<:error as |err|>caught: {{err.message}}
', + { strictMode: true, scope: () => ({ ErrorBoundary, throwingModifier }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'caught: modifier error' }); @@ -609,13 +691,17 @@ moduleFor( DestroyableComponent ); - let Tracked = defComponent('{{this.value}}', { - component: DestroyableComponent as any, - }); + let Tracked = setComponentTemplate( + precompileTemplate('{{this.value}}'), + DestroyableComponent as any + ); - let Root = defComponent( - '<:default><:error as |err|>caught', - { scope: { ErrorBoundary, Tracked, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Tracked, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'alive' }); @@ -629,9 +715,12 @@ moduleFor( } '@test retry that still throws shows error block again'() { - let Root = defComponent( - '<:default><:error as |err retry|>caught ', - { scope: { ErrorBoundary, Throwing, on } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, on }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'caught ' }); @@ -648,9 +737,12 @@ moduleFor( } let state = new State(); - let Root = defComponent( - '<:default>{{#if state.showDanger}}{{else}}safe{{/if}}<:error as |err|>caught', - { scope: { ErrorBoundary, Throwing, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>{{#if state.showDanger}}{{else}}safe{{/if}}<:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'safe' }); @@ -669,9 +761,12 @@ moduleFor( } let state = new State(); - let Root = defComponent( - '<:default><:error as |err retry|>caught ', - { scope: { ErrorBoundary, MaybeThrow, state, on } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -696,9 +791,12 @@ moduleFor( } let state = new State(); - let Root = defComponent( - '<:default><:error as |err retry|>caught ', - { scope: { ErrorBoundary, MaybeThrow, state, on } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -724,17 +822,21 @@ moduleFor( } '@test error in error block fallback bubbles to parent boundary'() { - let ThrowingFallback = defComponent('{{this.boom}}', { - component: class extends GlimmerishComponent { + let ThrowingFallback = setComponentTemplate( + precompileTemplate('{{this.boom}}'), + class extends GlimmerishComponent { get boom(): never { throw new Error('fallback error'); } - }, - }); + } + ); - let Root = defComponent( - '<:default><:default><:error as |err|><:error as |err|>outer caught: {{err.message}}', - { scope: { ErrorBoundary, Throwing, ThrowingFallback } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:default><:error as |err|><:error as |err|>outer caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, ThrowingFallback }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'outer caught: fallback error' }); @@ -746,20 +848,24 @@ moduleFor( } let state = new State(); - let ItemComponent = defComponent('{{this.value}}', { - component: class extends GlimmerishComponent { + 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 = defComponent( - '<:default>{{#each state.items as |item|}}{{/each}}<:error as |err retry|>caught ', - { scope: { ErrorBoundary, ItemComponent, state, on } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>{{#each state.items as |item|}}{{/each}}<:error as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, ItemComponent, state, on }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ab' }); @@ -784,20 +890,24 @@ moduleFor( } let state = new State(); - let ItemComponent = defComponent('{{this.value}}', { - component: class extends GlimmerishComponent { + 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 = defComponent( - '<:default>{{#each state.items as |item|}}{{/each}}<:error as |err retry|>caught ', - { scope: { ErrorBoundary, ItemComponent, state, on } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>{{#each state.items as |item|}}{{/each}}<:error as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, ItemComponent, state, on }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ab' }); @@ -827,20 +937,24 @@ moduleFor( '@test @retryWith resets error state when value changes'() { let state = new RetryState(); - let ConditionalThrow = defComponent('{{this.value}}', { - component: class extends GlimmerishComponent { + let ConditionalThrow = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { get value() { if ((this as any).args.shouldThrow) { throw new Error('route error'); } return 'ok'; } - }, - }); + } + ); - let Root = defComponent( - '<:default><:error as |err|>caught: {{err.message}}', - { scope: { ErrorBoundary, ConditionalThrow, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, ConditionalThrow, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -864,20 +978,24 @@ moduleFor( '@test @retryWith does not reset if value unchanged'() { let state = new RetryState(); - let ConditionalThrow = defComponent('{{this.value}}', { - component: class extends GlimmerishComponent { + let ConditionalThrow = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { get value() { if ((this as any).args.shouldThrow) { throw new Error('route error'); } return 'ok'; } - }, - }); + } + ); - let Root = defComponent( - '<:default><:error as |err|>caught', - { scope: { ErrorBoundary, ConditionalThrow, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, ConditionalThrow, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -899,9 +1017,12 @@ moduleFor( let state = new RouteOnlyState(); // Always throws regardless of route - let Root = defComponent( - '<:default><:error as |err|>caught: {{err.message}}', - { scope: { ErrorBoundary, Throwing, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'caught: render error' }); @@ -917,9 +1038,12 @@ moduleFor( let state = new RouteOnlyState(); // Always throws - let Root = defComponent( - '<:default><:error as |err retry|>caught ', - { scope: { ErrorBoundary, Throwing, state, on } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state, on }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'caught ' }); @@ -934,20 +1058,24 @@ moduleFor( '@test @retryWith rerender error then retry without fixing re-catches'() { let state = new RetryState(); - let ConditionalThrow = defComponent('{{this.value}}', { - component: class extends GlimmerishComponent { + let ConditionalThrow = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { get value() { if ((this as any).args.shouldThrow) { throw new Error('route error'); } return 'ok'; } - }, - }); + } + ); - let Root = defComponent( - '<:default><:error as |err retry|>caught ', - { scope: { ErrorBoundary, ConditionalThrow, state, on } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, ConditionalThrow, state, on }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -968,20 +1096,24 @@ moduleFor( '@test @retryWith with retry after fixing state recovers'() { let state = new RetryState(); - let ConditionalThrow = defComponent('{{this.value}}', { - component: class extends GlimmerishComponent { + let ConditionalThrow = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { get value() { if ((this as any).args.shouldThrow) { throw new Error('route error'); } return 'ok'; } - }, - }); + } + ); - let Root = defComponent( - '<:default><:error as |err retry|>caught ', - { scope: { ErrorBoundary, ConditionalThrow, state, on } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, ConditionalThrow, state, on }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -1007,9 +1139,12 @@ moduleFor( } let state = new State(); - let Root = defComponent( - '<:default><:error as |err retry|>caught ', - { scope: { ErrorBoundary, MaybeThrow, state, on } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -1068,20 +1203,24 @@ moduleFor( '@test @retryWith with array value resets when element changes'() { let state = new ArrayRetryState(); - let ConditionalThrow = defComponent('{{this.value}}', { - component: class extends GlimmerishComponent { + let ConditionalThrow = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { get value() { if ((this as any).args.shouldThrow) { throw new Error('route error'); } return 'ok'; } - }, - }); + } + ); - let Root = defComponent( - '<:default><:error as |err|>caught', - { scope: { ErrorBoundary, ConditionalThrow, state, array } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, ConditionalThrow, state, array }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -1105,20 +1244,24 @@ moduleFor( '@test @retryWith with array value does not reset if elements unchanged'() { let state = new ArrayRetryState(); - let ConditionalThrow = defComponent('{{this.value}}', { - component: class extends GlimmerishComponent { + let ConditionalThrow = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { get value() { if ((this as any).args.shouldThrow) { throw new Error('route error'); } return 'ok'; } - }, - }); + } + ); - let Root = defComponent( - '<:default><:error as |err|>caught', - { scope: { ErrorBoundary, ConditionalThrow, state, array } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, ConditionalThrow, state, array }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -1140,9 +1283,12 @@ moduleFor( let state = new ThrowOnlyState(); // @retryWith is not passed — tests that undefined/missing arg is handled - let Root = defComponent( - '<:default><:error as |err|>caught', - { scope: { ErrorBoundary, MaybeThrow, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -1157,9 +1303,12 @@ moduleFor( '@test ErrorBoundary without @retryWith stays in error state'() { let state = new ThrowOnlyState(); - let Root = defComponent( - '<:default><:error as |err|>caught', - { scope: { ErrorBoundary, MaybeThrow, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); @@ -1180,11 +1329,17 @@ moduleFor( '@test sibling content after ErrorBoundary updates correctly'() { let state = new SiblingState(); - let Sibling = defComponent('{{state.label}}', { scope: { state } }); + let Sibling = setComponentTemplate( + precompileTemplate('{{state.label}}', { strictMode: true, scope: () => ({ state }) }), + templateOnly() + ); - let Root = defComponent( - '<:default>content<:error as |err|>caught', - { scope: { ErrorBoundary, Sibling } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>content<:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Sibling }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'contentbefore' }); @@ -1201,11 +1356,17 @@ moduleFor( '@test component inside ErrorBoundary re-renders with sibling content after'() { let state = new InnerState(); let siblingState = new SiblingState(); - let Inner = defComponent('{{state.value}}', { scope: { state } }); + let Inner = setComponentTemplate( + precompileTemplate('{{state.value}}', { strictMode: true, scope: () => ({ state }) }), + templateOnly() + ); - let Root = defComponent( - '<:default><:error as |err|>caught{{siblingState.label}}', - { scope: { ErrorBoundary, Inner, siblingState } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught{{siblingState.label}}', + { strictMode: true, scope: () => ({ ErrorBoundary, Inner, siblingState }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'hellobefore' }); @@ -1227,9 +1388,12 @@ moduleFor( '@test tracked state inside ErrorBoundary content updates'() { let state = new InnerState(); - let Root = defComponent( - '<:default>{{state.value}}<:error as |err|>caught', - { scope: { ErrorBoundary, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>{{state.value}}<:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'hello' }); @@ -1243,9 +1407,12 @@ moduleFor( '@test ErrorBoundary inside conditional that toggles'() { let state = new ConditionalState(); - let Root = defComponent( - '{{#if state.show}}<:default>content<:error as |err|>caught{{/if}}', - { scope: { ErrorBoundary, state } } + let Root = setComponentTemplate( + precompileTemplate( + '{{#if state.show}}<:default>content<:error as |err|>caught{{/if}}', + { strictMode: true, scope: () => ({ ErrorBoundary, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'content' }); @@ -1265,9 +1432,12 @@ moduleFor( let state = new SiblingState(); let cond = new ConditionalState(); - let Root = defComponent( - '{{#if cond.show}}<:default>eb<:error as |err|>caught{{state.label}}{{/if}}', - { scope: { ErrorBoundary, state, cond } } + let Root = setComponentTemplate( + precompileTemplate( + '{{#if cond.show}}<:default>eb<:error as |err|>caught{{state.label}}{{/if}}', + { strictMode: true, scope: () => ({ ErrorBoundary, state, cond }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'ebbefore' }); @@ -1280,11 +1450,17 @@ moduleFor( '@test ErrorBoundary wrapping component with tracked state'() { let state = new InnerState(); - let Inner = defComponent('{{state.value}}', { scope: { state } }); + let Inner = setComponentTemplate( + precompileTemplate('{{state.value}}', { strictMode: true, scope: () => ({ state }) }), + templateOnly() + ); - let Root = defComponent( - '<:default><:error as |err|>caught', - { scope: { ErrorBoundary, Inner } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Inner }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'hello' }); @@ -1303,9 +1479,12 @@ moduleFor( let getRemote = defineSimpleHelper(() => remote); - let Root = defComponent( - '<:default>{{#in-element (getRemote) insertBefore=null}}{{/in-element}}<:error as |err|>caught', - { scope: { ErrorBoundary, Throwing, getRemote } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>{{#in-element (getRemote) insertBefore=null}}{{/in-element}}<:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, getRemote }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'caught' }); @@ -1327,9 +1506,12 @@ moduleFor( let getRemote = defineSimpleHelper(() => remote); let state = new ThrowOnlyState(); - let Root = defComponent( - '<:default>{{#in-element (getRemote) insertBefore=null}}{{/in-element}}<:error as |err|>caught', - { scope: { ErrorBoundary, MaybeThrow, getRemote, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>{{#in-element (getRemote) insertBefore=null}}{{/in-element}}<:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, getRemote, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: '' }); @@ -1353,9 +1535,12 @@ moduleFor( '@test multiple re-renders of ErrorBoundary content'() { let state = new InnerState(); - let Root = defComponent( - '<:default>{{state.value}}<:error as |err|>caught', - { scope: { ErrorBoundary, state } } + let Root = setComponentTemplate( + precompileTemplate( + '<:default>{{state.value}}<:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, state }) } + ), + templateOnly() ); this.renderComponent(Root, { expect: 'hello' }); From 7ffe16661cbba08d69ddace97686706080e60e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 6 Oct 2026 16:02:36 -0300 Subject: [PATCH 22/25] Retry an ErrorBoundary when state its failed render read changes A boundary in error state now re-renders its default block when any tracked state read by the failed render changes. That state is what led to the throw, so a change to it is the signal that a retry might succeed. This replaces `@retryWith`, which is removed. The boundary unwinds the failed render's tracking frames with the new `unwindTrackingTo()`, which keeps the tags they consumed instead of discarding them. `track()` now hands its tag to the parent frame when its block throws. Before, the tag of the computation that threw was lost, so the boundary never saw the state that caused the error. The manual `retry` block param stays, for failures caused by untracked state. --- .../glimmer/lib/components/error-boundary.ts | 36 +- .../components/error-boundary-test.ts | 329 +++++------------- .../runtime/lib/compiled/opcodes/component.ts | 24 +- .../runtime/lib/component/error-boundary.ts | 154 ++++---- packages/@glimmer/runtime/lib/vm/update.ts | 84 ++++- packages/@glimmer/validator/index.ts | 1 + packages/@glimmer/validator/lib/tracking.ts | 30 ++ 7 files changed, 265 insertions(+), 393 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts b/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts index 52fbea1478f..2d0b7e05e19 100644 --- a/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts +++ b/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts @@ -9,12 +9,13 @@ import type { VMArguments, WithCreateInstance, } from '@glimmer/interfaces'; -import type { Reference } from '@glimmer/reference'; -import { setComponentTemplate, setInternalComponentManager } from '@glimmer/manager'; -import { createConstRef, valueForRef } from '@glimmer/reference'; -import { ErrorBoundaryStateImpl } from '@glimmer/runtime'; +import type { Reference } from '@glimmer/reference/lib/reference'; +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'; +import type { ErrorBoundaryState } from '@glimmer/runtime/lib/component/error-boundary'; import ErrorBoundaryTemplate from '../templates/error-boundary'; @@ -22,12 +23,12 @@ const CAPABILITIES: InternalComponentCapabilities = { dynamicLayout: false, dynamicTag: false, prepareArgs: false, - createArgs: true, + createArgs: false, attributeHook: false, elementHook: false, createCaller: false, dynamicScope: false, - updateHook: true, + updateHook: false, createInstance: true, wrapped: false, willDestroy: false, @@ -45,20 +46,13 @@ class ErrorBoundaryManager create( _owner: Owner, _definition: object, - args: Nullable, + _args: Nullable, _env: Environment, _dynamicScope: Nullable, _caller: Nullable, _hasDefaultBlock: boolean ): ErrorBoundaryState { - let state = new ErrorBoundaryStateImpl(); - - if (args && args.named.has('retryWith')) { - let retryWithRef = args.named.get('retryWith'); - state.initRetryWith(retryWithRef, valueForRef(retryWithRef)); - } - - return state; + return new ErrorBoundaryStateImpl(); } didCreate(): void {} @@ -77,16 +71,6 @@ class ErrorBoundaryManager getDestroyable(_instance: ErrorBoundaryState): Nullable { return null; } - - update(instance: ErrorBoundaryState, _dynamicScope: Nullable): void { - // Consume the @retryWith ref's tag at the ROOT tracking level (outside - // the EB component's JumpIfNotModified scope). This ensures the root's - // combined tag includes the retryWith value, so root JumpIfNotModified - // falls through when @retryWith changes — which in turn allows the EB's - // opcodes to run. - // The actual reset logic is in ErrorBoundaryOpcode.evaluate(). - instance.consumeRetryWith(); - } } const MANAGER = new ErrorBoundaryManager(); 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 index 538261f24c8..c084fc2b1a3 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -24,22 +24,10 @@ import { renderComponent, type RenderResult } from '../../../lib/renderer'; import type Owner from '@ember/owner'; import { setOwner } from '@ember/-internals/owner'; -// --- Tracked state helpers for @retryWith tests --- +// --- Tracked state helpers --- // Declared at module level to avoid TS1206 "Decorators are not valid here" // which occurs with decorators inside anonymous class expressions. -class RetryState { - @tracked shouldThrow = false; - @tracked routeName = 'route-a'; -} -class RouteOnlyState { - @tracked routeName = 'route-a'; -} -class ArrayRetryState { - @tracked shouldThrow = false; - @tracked valA = 'a'; - @tracked valB = 'b'; -} class ThrowOnlyState { @tracked shouldThrow = false; } @@ -932,138 +920,117 @@ moduleFor( }); } - // --- @retryWith tests --- - - '@test @retryWith resets error state when value changes'() { - let state = new RetryState(); + // --- automatic retry from tracked state --- - let ConditionalThrow = setComponentTemplate( - precompileTemplate('{{this.value}}'), - class extends GlimmerishComponent { - get value() { - if ((this as any).args.shouldThrow) { - throw new Error('route error'); - } - return 'ok'; - } - } - ); + '@test recovers when state read by the failed initial render changes'() { + class State { + @tracked shouldThrow = true; + } + let state = new State(); let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught: {{err.message}}', - { strictMode: true, scope: () => ({ ErrorBoundary, ConditionalThrow, state }) } + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } ), templateOnly() ); - this.renderComponent(Root, { expect: 'ok' }); - - // Trigger error - this.assertChange({ - change: () => (state.shouldThrow = true), - expect: 'caught: route error', - }); + this.renderComponent(Root, { expect: 'caught' }); - // Change @retryWith value AND fix the error condition — boundary should reset this.assertChange({ - change: () => { - state.shouldThrow = false; - state.routeName = 'route-b'; - }, + change: () => (state.shouldThrow = false), expect: 'ok', }); } - '@test @retryWith does not reset if value unchanged'() { - let state = new RetryState(); - - let ConditionalThrow = setComponentTemplate( - precompileTemplate('{{this.value}}'), - class extends GlimmerishComponent { - get value() { - if ((this as any).args.shouldThrow) { - throw new Error('route error'); - } - return 'ok'; - } - } - ); + '@test recovers when state read by the failed rerender changes'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught', - { strictMode: true, scope: () => ({ ErrorBoundary, ConditionalThrow, state }) } + '<:default><:error as |err|>caught', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } ), templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); - // Trigger error this.assertChange({ change: () => (state.shouldThrow = true), expect: 'caught', }); - // Fix the throw condition but DON'T change @retryWith — should stay in error this.assertChange({ change: () => (state.shouldThrow = false), - expect: 'caught', + expect: 'ok', }); - } - - '@test @retryWith re-catches if new value also causes error'() { - let state = new RouteOnlyState(); - - // Always throws regardless of route - let Root = setComponentTemplate( - precompileTemplate( - '<:default><:error as |err|>caught: {{err.message}}', - { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state }) } - ), - templateOnly() - ); - this.renderComponent(Root, { expect: 'caught: render error' }); - - // Change @retryWith — boundary resets, but immediately catches again this.assertChange({ - change: () => (state.routeName = 'route-b'), - expect: 'caught: render error', + change: () => (state.shouldThrow = true), + expect: 'caught', }); } - '@test @retryWith with retry that still throws re-catches'() { - let state = new RouteOnlyState(); + '@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; - // Always throws + 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( - '<:default><:error as |err retry|>caught ', - { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state, on }) } + '<:default>{{state.label}}<:error>caught {{state.label}}', + { strictMode: true, scope: () => ({ ErrorBoundary, CountedThrow, state }) } ), templateOnly() ); - this.renderComponent(Root, { expect: 'caught ' }); + this.renderComponent(Root, { expect: 'caught a' }); + assert.strictEqual(attempts, 1, 'default block attempted once on initial render'); - // Retry while error still exists — should re-catch this.assertChange({ - change: () => clickElement('button'), - expect: 'caught ', + change: () => (state.label = 'b'), + expect: 'caught b', }); + assert.strictEqual(attempts, 1, 'unrelated state change does not re-attempt'); } - '@test @retryWith rerender error then retry without fixing re-catches'() { - let state = new RetryState(); + '@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 ConditionalThrow = setComponentTemplate( + let FailsWhilePositive = setComponentTemplate( precompileTemplate('{{this.value}}'), class extends GlimmerishComponent { get value() { - if ((this as any).args.shouldThrow) { - throw new Error('route error'); + attempts++; + let count = (this as any).args.count; + if (count > 0) { + throw new Error(`count is ${count}`); } return 'ok'; } @@ -1072,64 +1039,56 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err retry|>caught ', - { strictMode: true, scope: () => ({ ErrorBoundary, ConditionalThrow, state, on }) } + '<:default><:error as |err|>caught: {{err.message}}', + { strictMode: true, scope: () => ({ ErrorBoundary, FailsWhilePositive, state }) } ), templateOnly() ); - this.renderComponent(Root, { expect: 'ok' }); + this.renderComponent(Root, { expect: 'caught: count is 1' }); + assert.strictEqual(attempts, 1); - // Trigger error via tracked state change this.assertChange({ - change: () => (state.shouldThrow = true), - expect: 'caught ', + change: () => (state.count = 2), + expect: 'caught: count is 2', }); + assert.strictEqual(attempts, 2, 'retried once and caught the new error'); - // Retry WITHOUT fixing state — error should be re-caught this.assertChange({ - change: () => clickElement('button'), - expect: 'caught ', + change: () => (state.count = 0), + expect: 'ok', }); + assert.strictEqual(attempts, 3, 'retried once and recovered'); } - '@test @retryWith with retry after fixing state recovers'() { - let state = new RetryState(); + // --- manual retry --- - let ConditionalThrow = setComponentTemplate( - precompileTemplate('{{this.value}}'), - class extends GlimmerishComponent { - get value() { - if ((this as any).args.shouldThrow) { - throw new Error('route error'); - } - return 'ok'; - } - } - ); + '@test rerender error then retry without fixing re-catches'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err retry|>caught ', - { strictMode: true, scope: () => ({ ErrorBoundary, ConditionalThrow, state, on }) } + '<:default><:error as |err retry|>caught ', + { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } ), templateOnly() ); this.renderComponent(Root, { expect: 'ok' }); - // Trigger error + // Trigger error via tracked state change this.assertChange({ change: () => (state.shouldThrow = true), expect: 'caught ', }); - // Fix state and retry — should recover - state.shouldThrow = false; - + // Retry WITHOUT fixing state — error should be re-caught this.assertChange({ change: () => clickElement('button'), - expect: 'ok', + expect: 'caught ', }); } @@ -1200,132 +1159,6 @@ moduleFor( }); } - '@test @retryWith with array value resets when element changes'() { - let state = new ArrayRetryState(); - - let ConditionalThrow = setComponentTemplate( - precompileTemplate('{{this.value}}'), - class extends GlimmerishComponent { - get value() { - if ((this as any).args.shouldThrow) { - throw new Error('route error'); - } - return 'ok'; - } - } - ); - - let Root = setComponentTemplate( - precompileTemplate( - '<:default><:error as |err|>caught', - { strictMode: true, scope: () => ({ ErrorBoundary, ConditionalThrow, state, array }) } - ), - templateOnly() - ); - - this.renderComponent(Root, { expect: 'ok' }); - - // Trigger error - this.assertChange({ - change: () => (state.shouldThrow = true), - expect: 'caught', - }); - - // Change one array element AND fix the error — should reset - this.assertChange({ - change: () => { - state.shouldThrow = false; - state.valA = 'changed'; - }, - expect: 'ok', - }); - } - - '@test @retryWith with array value does not reset if elements unchanged'() { - let state = new ArrayRetryState(); - - let ConditionalThrow = setComponentTemplate( - precompileTemplate('{{this.value}}'), - class extends GlimmerishComponent { - get value() { - if ((this as any).args.shouldThrow) { - throw new Error('route error'); - } - return 'ok'; - } - } - ); - - let Root = setComponentTemplate( - precompileTemplate( - '<:default><:error as |err|>caught', - { strictMode: true, scope: () => ({ ErrorBoundary, ConditionalThrow, state, array }) } - ), - templateOnly() - ); - - this.renderComponent(Root, { expect: 'ok' }); - - // Trigger error - this.assertChange({ - change: () => (state.shouldThrow = true), - expect: 'caught', - }); - - // Fix throw condition but DON'T change array elements — should stay in error - this.assertChange({ - change: () => (state.shouldThrow = false), - expect: 'caught', - }); - } - - '@test @retryWith with undefined value works without error'() { - let state = new ThrowOnlyState(); - - // @retryWith is not passed — tests that undefined/missing arg is handled - let Root = setComponentTemplate( - precompileTemplate( - '<:default><:error as |err|>caught', - { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } - ), - templateOnly() - ); - - this.renderComponent(Root, { expect: 'ok' }); - - // Trigger error — should still catch normally - this.assertChange({ - change: () => (state.shouldThrow = true), - expect: 'caught', - }); - } - - '@test ErrorBoundary without @retryWith stays in error state'() { - let state = new ThrowOnlyState(); - - let Root = setComponentTemplate( - precompileTemplate( - '<:default><:error as |err|>caught', - { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } - ), - templateOnly() - ); - - this.renderComponent(Root, { expect: 'ok' }); - - // Trigger error - this.assertChange({ - change: () => (state.shouldThrow = true), - expect: 'caught', - }); - - // Fix the condition — but without @retryWith, boundary stays in error - this.assertChange({ - change: () => (state.shouldThrow = false), - expect: 'caught', - }); - } - '@test sibling content after ErrorBoundary updates correctly'() { let state = new SiblingState(); diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index 615a4b44a5e..e849b0dea42 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -86,7 +86,13 @@ import type { CurriedValue } from '../../curried-value'; import type { UpdatingVM } from '../../vm'; import type { VM } from '../../vm/append'; import type { BlockArgumentsImpl } from '../../vm/arguments'; -import { getTrackingDepth, restoreTrackingTo } from '@glimmer/validator'; +import { + beginTrackFrame, + consumeTag, + endTrackFrame, + getTrackingDepth, + unwindTrackingTo, +} from '@glimmer/validator/lib/tracking'; import { NewTreeBuilder } from '../../vm/element-builder'; import { clearDOMRange, ErrorBoundaryOpcode } from '../../vm/update'; @@ -934,8 +940,11 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } // 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 discard stale frames from a failed sub-VM. + // 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(); try { // Use beginBlock (not resume) since this is a fresh block with no prior content. @@ -953,6 +962,9 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } subVM.pushUpdating(children); }); + // The attempt succeeded: hand what it consumed to the enclosing frame. + consumeTag(endTrackFrame()); + associateDestroyableChild(errorBoundaryOp, result.drop); vm.associateDestroyable(errorBoundaryOp); vm.updateWith(errorBoundaryOp); @@ -962,8 +974,9 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } console.error('An error was caught by :', error); } - // Roll back stale tracking frames left by the failed sub-VM render. - restoreTrackingTo(trackingDepth); + // 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); @@ -980,7 +993,8 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } block.resetPartial(); // Set error state so the template takes the error branch - errorState.setError(error); + 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); diff --git a/packages/@glimmer/runtime/lib/component/error-boundary.ts b/packages/@glimmer/runtime/lib/component/error-boundary.ts index ef76423e7f3..3efbfe06d9e 100644 --- a/packages/@glimmer/runtime/lib/component/error-boundary.ts +++ b/packages/@glimmer/runtime/lib/component/error-boundary.ts @@ -1,69 +1,35 @@ -import type { Reference } from '@glimmer/reference'; -import type { UpdatableTag } from '@glimmer/validator'; -import { valueForRef } from '@glimmer/reference'; -import { consumeTag, dirtyTag, dirtyTagFor, tagFor } from '@glimmer/validator'; +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): void; + setError(error: unknown, failedTag?: Tag): void; retry(): void; - initRetryWith(ref: Reference, initialValue: unknown): void; - consumeRetryWith(): void; - checkRetryWith(): boolean; -} - -/** - * Shallow-compare two values. Supports primitives (===), arrays (element-wise), - * and plain objects (own-property value-wise). - */ -function shallowEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - // After the === check above, at most one side is null/undefined. - // Treat null and undefined as distinct (null !== undefined). - if (a == null || b == null) return false; - - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; - } - - if (typeof a === 'object' && typeof b === 'object') { - let keysA = Object.keys(a); - let keysB = Object.keys(b); - if (keysA.length !== keysB.length) return false; - for (let key of keysA) { - if ((a as Record)[key] !== (b as Record)[key]) { - return false; - } - } - return true; - } - - return false; -} - -/** - * Snapshot a value for later comparison. Clones arrays and plain objects - * to prevent reference aliasing. - */ -function snapshot(value: unknown): unknown { - if (Array.isArray(value)) return value.slice(); - if (value !== null && typeof value === 'object') return { ...value }; - return value; + consumeFailedTag(): void; + shouldRetry(): boolean; } export class ErrorBoundaryStateImpl implements ErrorBoundaryState { private _error: unknown = null; private _hasError = false; - // @retryWith support: tracks a reference whose value, when changed, - // automatically clears the error state and retries the default block. - private retryWithRef: Reference | null = null; - private lastRetryWithValue: unknown = undefined; + /** + * 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')); @@ -75,7 +41,7 @@ export class ErrorBoundaryStateImpl implements ErrorBoundaryState { return this._hasError; } - setError(error: unknown) { + 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 @@ -84,54 +50,52 @@ export class ErrorBoundaryStateImpl implements ErrorBoundaryState { dirtyTag(tagFor(this, '_error') as UpdatableTag, true); this._hasError = true; dirtyTag(tagFor(this, '_hasError') as UpdatableTag, true); - } - retry = () => { - this._error = null; - dirtyTagFor(this, '_error'); - this._hasError = false; - dirtyTagFor(this, '_hasError'); - }; - - initRetryWith(ref: Reference, initialValue: unknown) { - this.retryWithRef = ref; - this.lastRetryWithValue = snapshot(initialValue); + // 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); } - consumeRetryWith() { - if (this.retryWithRef) { - valueForRef(this.retryWithRef); + /** + * 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); } } /** - * Check if the @retryWith value has changed. If it has and the boundary - * is in error state, clear the error state and return true so the caller - * (ErrorBoundaryOpcode.evaluate()) can re-render via handleException(). - * - * Also consumes the retryWith ref's tag (via valueForRef), keeping it in - * the current tracking frame. This ensures the EB component's - * JumpIfNotModified detects future @retryWith changes. + * 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. */ - checkRetryWith(): boolean { - if (this.retryWithRef === null) return false; - - let current = valueForRef(this.retryWithRef); - - if (!shallowEqual(current, this.lastRetryWithValue)) { - this.lastRetryWithValue = snapshot(current); - - if (this._hasError) { - 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); - return true; - } + shouldRetry(): boolean { + if (!this._hasError || validateTag(this.failedTag, this.failedRevision)) { + return false; } - 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/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 7a65826aa23..52352339cd3 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -12,6 +12,7 @@ import type { Scope, SimpleComment, SimpleNode, + Tag, UpdatingOpcode, UpdatingVM as IUpdatingVM, } from '@glimmer/interfaces'; @@ -25,7 +26,14 @@ 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 { getTrackingDepth, resetTracking, restoreTrackingTo } from '@glimmer/validator/lib/tracking'; +import { + beginTrackFrame, + consumeTag, + endTrackFrame, + getTrackingDepth, + resetTracking, + unwindTrackingTo, +} from '@glimmer/validator/lib/tracking'; import type { SimpleElement } from '@simple-dom/interface'; @@ -83,7 +91,7 @@ export class UpdatingVM implements IUpdatingVM { let opcode = this.frame.nextStatement(); if (opcode === undefined) { - this._popFrame(); + this._popFrame(true); continue; } @@ -94,8 +102,10 @@ export class UpdatingVM implements IUpdatingVM { try { opcode.evaluate(this); } catch (error) { - // Restore tracking frames to the depth before the failed opcode. - restoreTrackingTo(trackingDepth); + // 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. @@ -120,10 +130,13 @@ export class UpdatingVM implements IUpdatingVM { } } - private _popFrame() { + private _popFrame(completed = false) { let frame = this.frameStack.current; if (frame && frame.isErrorBoundary) { this._errorBoundaryDepth--; + if (completed) { + frame.didComplete(); + } } this.frameStack.pop(); } @@ -140,9 +153,13 @@ export class UpdatingVM implements IUpdatingVM { this.frameStack.push(new UpdatingVMFrame(ops, handler)); } - tryErrorBoundary(ops: UpdatingOpcode[], handler: ExceptionHandler) { + /** + * 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, onComplete: () => void) { this._errorBoundaryDepth++; - this.frameStack.push(new UpdatingVMFrame(ops, handler, true)); + this.frameStack.push(new UpdatingVMFrame(ops, handler, true, onComplete)); } throw() { @@ -298,17 +315,26 @@ export class ErrorBoundaryOpcode extends TryOpcode { // eslint-disable-next-line @typescript-eslint/no-unused-expressions this.errorState.hasError; - // Check @retryWith: consumes the retryWith ref's tag (keeping it in the - // EB's tracking frame) and, if the value changed while in error state, - // clears the error and returns true to trigger a re-render. - if (this.errorState.checkRetryWith()) { + // 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; } - vm.tryErrorBoundary(this.children, this); + // 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. + beginTrackFrame(); + vm.tryErrorBoundary(this.children, this, this.didCompleteChildren); } + private didCompleteChildren = () => { + consumeTag(endTrackFrame()); + }; + /** * Restore tracking frames and consumed tags to the state captured in * evaluate(), before children ran. This prevents stale tracking frames @@ -317,10 +343,19 @@ export class ErrorBoundaryOpcode extends TryOpcode { * backtracking assertions on later mutations. */ private restoreTracking() { - restoreTrackingTo(this.cachedTrackingDepth); + 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; } /** @@ -396,11 +431,15 @@ export class ErrorBoundaryOpcode extends TryOpcode { // Reset block state without DOM cleanup (already done above). bounds.resetPartial(); + beginTrackFrame(); + try { this.renderIntoBlock(); + consumeTag(endTrackFrame()); } catch (error) { - // Restore tracking frames opened by the failed re-render attempt. - restoreTrackingTo(trackingDepth); + // Unwind tracking frames opened by the failed re-render attempt, + // keeping what they consumed. + let failedTag = unwindTrackingTo(trackingDepth); // Roll back stale debug render tree entries. env.debugRenderTree?.rollbackTo(this.cachedRenderTreeDepth); @@ -417,7 +456,8 @@ export class ErrorBoundaryOpcode extends TryOpcode { // eslint-disable-next-line no-console console.error('An error was caught by :', error); } - this.errorState.setError(error); + this.errorState.setError(error, failedTag); + this.errorState.consumeFailedTag(); this.renderIntoBlock(); this.clearCachedNodes(); @@ -435,7 +475,7 @@ export class ErrorBoundaryOpcode extends TryOpcode { context: { env }, } = this; - this.restoreTracking(); + let failedTag = this.unwindTracking(); destroyChildren(this); @@ -443,7 +483,8 @@ export class ErrorBoundaryOpcode extends TryOpcode { // eslint-disable-next-line no-console console.error('An error was caught by :', error); } - this.errorState.setError(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 @@ -723,9 +764,14 @@ class UpdatingVMFrame { constructor( private ops: UpdatingOpcode[], private exceptionHandler: Nullable, - readonly isErrorBoundary = false + readonly isErrorBoundary = false, + private onComplete: (() => void) | null = null ) {} + didComplete() { + this.onComplete?.(); + } + goto(index: number) { this.current = index; } diff --git a/packages/@glimmer/validator/index.ts b/packages/@glimmer/validator/index.ts index af7593640af..79b94013c3b 100644 --- a/packages/@glimmer/validator/index.ts +++ b/packages/@glimmer/validator/index.ts @@ -37,6 +37,7 @@ export { isTracking, resetTracking, restoreTrackingTo, + unwindTrackingTo, track, untrack, } from './lib/tracking'; diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index 67d684dc8c5..8cf5f211043 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -116,7 +116,25 @@ export function getTrackingDepth(): number { * 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. @@ -125,6 +143,8 @@ export function restoreTrackingTo(depth: number): void { unwrap(debug.endTrackingTransaction)(); } } + + return combine(tags); } // This function is only for handling errors and resetting to a valid state @@ -266,11 +286,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; From 58787374850a50e1f23d59dcffdb6a9fa21ffb3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 6 Oct 2026 16:03:06 -0300 Subject: [PATCH 23/25] Name ErrorBoundary blocks `<:try>` and `<:catch>` The fallback block is now `<:catch>` and the happy path is `<:try>`. Next to a fallback, `<:default>` described the mechanism rather than the block's role. Implicit content still works when there is no fallback. A DEBUG assertion rejects `<:default>` combined with `<:try>` or `<:catch>`, so each case has one spelling. It runs in the manager's `create()` rather than the layout, because the layout renders inside the boundary and would catch its own assertion. --- .../glimmer/lib/components/error-boundary.ts | 23 ++- .../glimmer/lib/templates/error-boundary.ts | 2 +- .../components/error-boundary-test.ts | 186 +++++++++++++----- 3 files changed, 162 insertions(+), 49 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts b/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts index 2d0b7e05e19..7e0bb070d97 100644 --- a/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts +++ b/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts @@ -10,6 +10,9 @@ import type { 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'; @@ -23,7 +26,8 @@ const CAPABILITIES: InternalComponentCapabilities = { dynamicLayout: false, dynamicTag: false, prepareArgs: false, - createArgs: false, + // Only needed for the DEBUG check of which blocks were passed. + createArgs: true, attributeHook: false, elementHook: false, createCaller: false, @@ -46,12 +50,25 @@ class ErrorBoundaryManager create( _owner: Owner, _definition: object, - _args: Nullable, + args: Nullable, _env: Environment, _dynamicScope: Nullable, _caller: Nullable, - _hasDefaultBlock: boolean + 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(); } diff --git a/packages/@ember/-internals/glimmer/lib/templates/error-boundary.ts b/packages/@ember/-internals/glimmer/lib/templates/error-boundary.ts index 0b7319aaedc..bbd23aa2d85 100644 --- a/packages/@ember/-internals/glimmer/lib/templates/error-boundary.ts +++ b/packages/@ember/-internals/glimmer/lib/templates/error-boundary.ts @@ -1,6 +1,6 @@ import { precompileTemplate } from '@ember/template-compilation'; export default precompileTemplate( - `{{#if this.hasError}}{{yield this.error this.retry to="error"}}{{else}}{{yield}}{{/if}}`, + `{{#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, 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 index c084fc2b1a3..ed1650117eb 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-test.ts @@ -108,6 +108,19 @@ class ErrorBoundaryTestCase extends AbstractStrictTestCase { 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 --- @@ -122,7 +135,21 @@ moduleFor( '@test renders default block when no error'() { let Root = setComponentTemplate( precompileTemplate( - '<:default>hello<:error as |err|>caught', + '<: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() @@ -131,10 +158,79 @@ moduleFor( 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( - '<:default><:error as |err|>caught', + '<:try><:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } ), templateOnly() @@ -157,7 +253,7 @@ moduleFor( try { let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught', + '<:try><:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } ), templateOnly() @@ -198,7 +294,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught', + '<:try><:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } ), templateOnly() @@ -235,7 +331,7 @@ moduleFor( '@test passes error object to error block'() { let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught: {{err.message}}', + '<:try><:catch as |err|>caught: {{err.message}}', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } ), templateOnly() @@ -252,7 +348,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught', + '<:try><:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } ), templateOnly() @@ -274,7 +370,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err retry|>', + '<:try><:catch as |err retry|>', { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } ), templateOnly() @@ -293,7 +389,7 @@ moduleFor( '@test nested boundaries — inner catches, outer unaffected'() { let Root = setComponentTemplate( precompileTemplate( - '<:default>outer ok <:default><:error as |err|>inner caught<:error as |err|>outer caught', + '<:try>outer ok <:try><:catch as |err|>inner caught<:catch as |err|>outer caught', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } ), templateOnly() @@ -335,7 +431,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err retry|>', + '<:try><:catch as |err retry|>', { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, Counter, state, on }) } ), templateOnly() @@ -359,7 +455,7 @@ moduleFor( '@test sibling content survives error and recovery round-trip'() { let Root = setComponentTemplate( precompileTemplate( - 'before<:default><:error as |err|>caughtafter', + 'before<:try><:catch as |err|>caughtafter', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } ), templateOnly() @@ -380,7 +476,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught: {{err.message}}', + '<:try><:catch as |err|>caught: {{err.message}}', { strictMode: true, scope: () => ({ ErrorBoundary, Parent }) } ), templateOnly() @@ -404,7 +500,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>{{#each (array "good" "bad" "also-good") as |item|}}{{/each}}<:error as |err|>caught: {{err.message}}', + '<:try>{{#each (array "good" "bad" "also-good") as |item|}}{{/each}}<:catch as |err|>caught: {{err.message}}', { strictMode: true, scope: () => ({ ErrorBoundary, ItemComponent, array }) } ), templateOnly() @@ -426,7 +522,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>{{maybeThrowHelper state.shouldThrow}}<:error as |err|>caught: {{err.message}}', + '<:try>{{maybeThrowHelper state.shouldThrow}}<:catch as |err|>caught: {{err.message}}', { strictMode: true, scope: () => ({ ErrorBoundary, maybeThrowHelper, state }) } ), templateOnly() @@ -447,7 +543,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>{{throwingHelper}}<:error as |err|>caught: {{err.message}}', + '<:try>{{throwingHelper}}<:catch as |err|>caught: {{err.message}}', { strictMode: true, scope: () => ({ ErrorBoundary, throwingHelper }) } ), templateOnly() @@ -464,7 +560,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>
content
<:error as |err|>caught
', + '<:try>
content
<:catch as |err|>caught
', { strictMode: true, scope: () => ({ ErrorBoundary, trackingModifier }) } ), templateOnly() @@ -492,7 +588,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught', + '<:try><:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, FullName, state }) } ), templateOnly() @@ -514,7 +610,7 @@ moduleFor( '@test multiple sibling boundaries — one errors, other stays intact'() { let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>first caught<:default>second ok<:error as |err|>second caught', + '<:try><:catch as |err|>first caught<:try>second ok<:catch as |err|>second caught', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } ), templateOnly() @@ -531,7 +627,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '{{state.counter}}<:default><:error as |err|>caught', + '{{state.counter}}<:try><:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state }) } ), templateOnly() @@ -558,7 +654,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '{{state.label}}<:default><:error as |err|>caught', + '{{state.label}}<:try><:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state }) } ), templateOnly() @@ -575,7 +671,7 @@ moduleFor( '@test catches error from conditional branch during initial render'() { let Root = setComponentTemplate( precompileTemplate( - '<:default>{{#if true}}{{else}}safe{{/if}}<:error as |err|>caught: {{err.message}}', + '<:try>{{#if true}}{{else}}safe{{/if}}<:catch as |err|>caught: {{err.message}}', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing }) } ), templateOnly() @@ -604,7 +700,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>{{#each state.items as |item|}}{{/each}}<:error as |err|>caught: {{err.message}}', + '<:try>{{#each state.items as |item|}}{{/each}}<:catch as |err|>caught: {{err.message}}', { strictMode: true, scope: () => ({ ErrorBoundary, ItemComponent, state }) } ), templateOnly() @@ -628,7 +724,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>
content
<:error as |err|>caught: {{err.message}}
', + '<:try>
content
<:catch as |err|>caught: {{err.message}}
', { strictMode: true, scope: () => ({ ErrorBoundary, throwingModifier }) } ), templateOnly() @@ -686,7 +782,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught', + '<:try><:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, Tracked, state }) } ), templateOnly() @@ -705,7 +801,7 @@ moduleFor( '@test retry that still throws shows error block again'() { let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err retry|>caught ', + '<:try><:catch as |err retry|>caught ', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, on }) } ), templateOnly() @@ -727,7 +823,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>{{#if state.showDanger}}{{else}}safe{{/if}}<:error as |err|>caught', + '<:try>{{#if state.showDanger}}{{else}}safe{{/if}}<:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, state }) } ), templateOnly() @@ -751,7 +847,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err retry|>caught ', + '<:try><:catch as |err retry|>caught ', { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } ), templateOnly() @@ -781,7 +877,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err retry|>caught ', + '<:try><:catch as |err retry|>caught ', { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } ), templateOnly() @@ -821,7 +917,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:default><:error as |err|><:error as |err|>outer caught: {{err.message}}', + '<:try><:try><:catch as |err|><:catch as |err|>outer caught: {{err.message}}', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, ThrowingFallback }) } ), templateOnly() @@ -850,7 +946,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>{{#each state.items as |item|}}{{/each}}<:error as |err retry|>caught ', + '<:try>{{#each state.items as |item|}}{{/each}}<:catch as |err retry|>caught ', { strictMode: true, scope: () => ({ ErrorBoundary, ItemComponent, state, on }) } ), templateOnly() @@ -892,7 +988,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>{{#each state.items as |item|}}{{/each}}<:error as |err retry|>caught ', + '<:try>{{#each state.items as |item|}}{{/each}}<:catch as |err retry|>caught ', { strictMode: true, scope: () => ({ ErrorBoundary, ItemComponent, state, on }) } ), templateOnly() @@ -930,7 +1026,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught', + '<:try><:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } ), templateOnly() @@ -952,7 +1048,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught', + '<:try><:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state }) } ), templateOnly() @@ -998,7 +1094,7 @@ moduleFor( // without re-attempting the default block. let Root = setComponentTemplate( precompileTemplate( - '<:default>{{state.label}}<:error>caught {{state.label}}', + '<:try>{{state.label}}<:catch>caught {{state.label}}', { strictMode: true, scope: () => ({ ErrorBoundary, CountedThrow, state }) } ), templateOnly() @@ -1039,7 +1135,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught: {{err.message}}', + '<:try><:catch as |err|>caught: {{err.message}}', { strictMode: true, scope: () => ({ ErrorBoundary, FailsWhilePositive, state }) } ), templateOnly() @@ -1071,7 +1167,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err retry|>caught ', + '<:try><:catch as |err retry|>caught ', { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } ), templateOnly() @@ -1100,7 +1196,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err retry|>caught ', + '<:try><:catch as |err retry|>caught ', { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, state, on }) } ), templateOnly() @@ -1169,7 +1265,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>content<:error as |err|>caught', + '<:try>content<:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, Sibling }) } ), templateOnly() @@ -1196,7 +1292,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught{{siblingState.label}}', + '<:try><:catch as |err|>caught{{siblingState.label}}', { strictMode: true, scope: () => ({ ErrorBoundary, Inner, siblingState }) } ), templateOnly() @@ -1223,7 +1319,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>{{state.value}}<:error as |err|>caught', + '<:try>{{state.value}}<:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, state }) } ), templateOnly() @@ -1242,7 +1338,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '{{#if state.show}}<:default>content<:error as |err|>caught{{/if}}', + '{{#if state.show}}<:try>content<:catch as |err|>caught{{/if}}', { strictMode: true, scope: () => ({ ErrorBoundary, state }) } ), templateOnly() @@ -1267,7 +1363,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '{{#if cond.show}}<:default>eb<:error as |err|>caught{{state.label}}{{/if}}', + '{{#if cond.show}}<:try>eb<:catch as |err|>caught{{state.label}}{{/if}}', { strictMode: true, scope: () => ({ ErrorBoundary, state, cond }) } ), templateOnly() @@ -1290,7 +1386,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default><:error as |err|>caught', + '<:try><:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, Inner }) } ), templateOnly() @@ -1314,7 +1410,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>{{#in-element (getRemote) insertBefore=null}}{{/in-element}}<:error as |err|>caught', + '<:try>{{#in-element (getRemote) insertBefore=null}}{{/in-element}}<:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, Throwing, getRemote }) } ), templateOnly() @@ -1341,7 +1437,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>{{#in-element (getRemote) insertBefore=null}}{{/in-element}}<:error as |err|>caught', + '<:try>{{#in-element (getRemote) insertBefore=null}}{{/in-element}}<:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, MaybeThrow, getRemote, state }) } ), templateOnly() @@ -1370,7 +1466,7 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '<:default>{{state.value}}<:error as |err|>caught', + '<:try>{{state.value}}<:catch as |err|>caught', { strictMode: true, scope: () => ({ ErrorBoundary, state }) } ), templateOnly() From fbe0917ff3abd3fcc3630aca3c8dbb9a36493a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 6 Oct 2026 22:30:09 -0300 Subject: [PATCH 24/25] Harden ErrorBoundary aborts and fallback errors A failed attempt drops its queued lifecycle work, unowned modifiers and remote content, and a failing fallback passes its error outward. --- .../error-boundary-hardening-test.ts | 960 ++++++++++++++++++ .../interfaces/lib/runtime/environment.d.ts | 17 + .../interfaces/lib/runtime/render.d.ts | 7 +- .../runtime/lib/compiled/opcodes/component.ts | 56 +- .../runtime/lib/compiled/opcodes/dom.ts | 5 + packages/@glimmer/runtime/lib/environment.ts | 92 +- packages/@glimmer/runtime/lib/render.ts | 13 +- packages/@glimmer/runtime/lib/vm/append.ts | 59 +- .../runtime/lib/vm/element-builder.ts | 92 +- .../@glimmer/runtime/lib/vm/render-result.ts | 2 +- packages/@glimmer/runtime/lib/vm/update.ts | 253 ++++- packages/@glimmer/validator/lib/tracking.ts | 18 + 12 files changed, 1519 insertions(+), 55 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-hardening-test.ts 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..cc745a53945 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-boundary-hardening-test.ts @@ -0,0 +1,960 @@ +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'); + } + } +); 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 f7ae5d1f72b..f813fd42002 100644 --- a/packages/@glimmer/interfaces/lib/runtime/render.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/render.d.ts @@ -6,7 +6,12 @@ import type { Environment } from './environment.js'; export interface ExceptionHandler { handleException(): void; - handleCaughtError(error: unknown): 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/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index e849b0dea42..45e411bb4e2 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -87,8 +87,10 @@ 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, @@ -946,6 +948,14 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } 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. @@ -953,7 +963,7 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } let subVM = closure.evaluate(subTree); let children: UpdatingOpcode[] = []; - let errorBoundaryOp = new ErrorBoundaryOpcode(closure, vm.context, block, children, errorState); + errorBoundaryOp.children = children; // Use executeErrorBoundary to avoid resetting the parent VM's tracking // state and to clean up remote blocks on error. @@ -962,6 +972,10 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } subVM.pushUpdating(children); }); + errorBoundaryOp.exit(); + endErrorBoundary(); + vm.env.commitAttempt(attempt); + // The attempt succeeded: hand what it consumed to the enclosing frame. consumeTag(endTrackFrame()); @@ -969,6 +983,14 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } 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); @@ -1001,12 +1023,34 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } let retryVM = closure.evaluate(retryTree); let children: UpdatingOpcode[] = []; - let errorBoundaryOp = new ErrorBoundaryOpcode(closure, vm.context, block, children, errorState); + errorBoundaryOp.children = children; - let result = retryVM.executeErrorBoundary((retryVM) => { - retryVM.updateWith(errorBoundaryOp); - retryVM.pushUpdating(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); 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/environment.ts b/packages/@glimmer/runtime/lib/environment.ts index 17d7283ffea..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() { @@ -198,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 7720202a32c..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, @@ -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'; @@ -753,6 +760,13 @@ export class VM { 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(); @@ -776,10 +790,53 @@ export class VM { // 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 189606e2768..7a11146f0fa 100644 --- a/packages/@glimmer/runtime/lib/vm/element-builder.ts +++ b/packages/@glimmer/runtime/lib/vm/element-builder.ts @@ -284,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); } @@ -293,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; } @@ -509,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. @@ -539,16 +558,71 @@ export class RemoteBlock extends AppendingBlockImpl { // and avoid clearing the node if it was. In most cases this shouldn't happen, // so this might hide bugs where the code clears nested nodes unnecessarily, // so we should eventually try to do the correct fix. - try { - if (this.parentElement() === this.firstNode().parentNode) { - clear(this); - } - } catch { - // Block bounds are not fully initialized (e.g. an error was thrown - // during initial render before content was appended). Nothing to clear. + if (this.parentElement() === this.firstNode().parentNode) { + clear(this); } }); } + + /** 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 { diff --git a/packages/@glimmer/runtime/lib/vm/render-result.ts b/packages/@glimmer/runtime/lib/vm/render-result.ts index 50bd51e078b..8d92abb39be 100644 --- a/packages/@glimmer/runtime/lib/vm/render-result.ts +++ b/packages/@glimmer/runtime/lib/vm/render-result.ts @@ -48,7 +48,7 @@ export default class RenderResultImpl implements RenderResult { unreachable(`this should never happen`); } - handleCaughtError() { + 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 52352339cd3..bcb6bc72c47 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -8,6 +8,7 @@ import type { ExceptionHandler, GlimmerTreeChanges, Nullable, + RenderAttempt, ResettableBlock, Scope, SimpleComment, @@ -19,7 +20,12 @@ import type { import type { OpaqueIterationItem, OpaqueIterator } from '@glimmer/reference/lib/iterable'; import type { Reference } from '@glimmer/reference/lib/reference'; import { expect, unreachable, unwrap } from '@glimmer/debug-util/lib/platform-utils'; -import { associateDestroyableChild, destroy, destroyChildren } from '@glimmer/destroyable'; +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'; @@ -27,8 +33,10 @@ 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 { + beginErrorBoundary, beginTrackFrame, consumeTag, + endErrorBoundary, endTrackFrame, getTrackingDepth, resetTracking, @@ -42,7 +50,7 @@ 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; @@ -87,6 +95,19 @@ 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(); @@ -110,18 +131,25 @@ export class UpdatingVM implements IUpdatingVM { // 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()) { - if (this.frame.handleCaughtError(error)) { - this._popFrame(); - handled = true; - break; + 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 error; + throw current; } } } else { @@ -134,9 +162,7 @@ export class UpdatingVM implements IUpdatingVM { let frame = this.frameStack.current; if (frame && frame.isErrorBoundary) { this._errorBoundaryDepth--; - if (completed) { - frame.didComplete(); - } + frame.didExit(completed); } this.frameStack.pop(); } @@ -157,9 +183,13 @@ export class UpdatingVM implements IUpdatingVM { * 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, onComplete: () => void) { + tryErrorBoundary( + ops: UpdatingOpcode[], + handler: ExceptionHandler, + onExit: (completed: boolean) => void + ) { this._errorBoundaryDepth++; - this.frameStack.push(new UpdatingVMFrame(ops, handler, true, onComplete)); + this.frameStack.push(new UpdatingVMFrame(ops, handler, true, onExit)); } throw() { @@ -218,7 +248,7 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { vm.try(this.children, this); } - handleCaughtError(_error: unknown) { + handleCaughtError(_error: unknown): boolean { unreachable('handleCaughtError called on TryOpcode'); } @@ -268,6 +298,39 @@ export function clearDOMRange( } } +/** + * 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'; @@ -285,6 +348,22 @@ export class ErrorBoundaryOpcode extends TryOpcode { 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, @@ -327,14 +406,50 @@ export class ErrorBoundaryOpcode extends TryOpcode { // 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(); - vm.tryErrorBoundary(this.children, this, this.didCompleteChildren); + beginErrorBoundary(); + this.enter(); + this.#childrenAttempt = this.context.env.beginAttempt(); + vm.tryErrorBoundary(this.children, this, this.didExitChildren); } - private didCompleteChildren = () => { - consumeTag(endTrackFrame()); + 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 @@ -385,14 +500,35 @@ export class ErrorBoundaryOpcode extends TryOpcode { bounds, context: { env }, } = this; - let tree = NewTreeBuilder.beginBlock(env, bounds, this.cachedNextSibling); - let vm = this.state.evaluate(tree); - let children = (this.children = []); - let result = vm.executeGuarded((vm) => { - vm.updateWith(this); - vm.pushUpdating(children); - }); - associateDestroyableChild(this, result.drop); + 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); + } + } } /** @@ -413,9 +549,11 @@ export class ErrorBoundaryOpcode extends TryOpcode { } = 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 @@ -441,6 +579,16 @@ export class ErrorBoundaryOpcode extends TryOpcode { // 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); @@ -459,24 +607,63 @@ export class ErrorBoundaryOpcode extends TryOpcode { 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) { + 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) { @@ -504,8 +691,8 @@ export class ErrorBoundaryOpcode extends TryOpcode { env.debugRenderTree?.rollbackTo(this.cachedRenderTreeDepth); bounds.resetPartial(); - this.renderIntoBlock(); - this.clearCachedNodes(); + this.renderFallback(); + return true; } } @@ -765,11 +952,14 @@ class UpdatingVMFrame { private ops: UpdatingOpcode[], private exceptionHandler: Nullable, readonly isErrorBoundary = false, - private onComplete: (() => void) | null = null + private onExit: ((completed: boolean) => void) | null = null ) {} - didComplete() { - this.onComplete?.(); + /** 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) { @@ -793,8 +983,7 @@ class UpdatingVMFrame { */ handleCaughtError(error: unknown): boolean { if (this.isErrorBoundary && this.exceptionHandler) { - this.exceptionHandler.handleCaughtError(error); - return true; + return this.exceptionHandler.handleCaughtError(error); } return false; } diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index 8cf5f211043..9496598df06 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -147,6 +147,24 @@ export function unwindTrackingTo(depth: number): Tag { 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) { From 18d9b1442f15cea28ac6ad4bba81a4c85a6ec66b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 6 Oct 2026 22:49:36 -0300 Subject: [PATCH 25/25] Clean up a failed ErrorBoundary inside an inserted row Initial-render cleanup now ends at the insertion point instead of the end of the parent. --- .../error-boundary-hardening-test.ts | 70 +++++++++++++++++++ .../runtime/lib/compiled/opcodes/component.ts | 14 ++-- 2 files changed, 77 insertions(+), 7 deletions(-) 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 index cc745a53945..baf74040ea5 100644 --- 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 @@ -958,3 +958,73 @@ moduleFor( } } ); + +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/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index 45e411bb4e2..22c4114fb93 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -937,7 +937,10 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } // Record insertion point so we can clean up partial DOM on error. let parent = block.parentElement(); - let insertionMarker = parent.lastChild; + // 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; @@ -1003,13 +1006,10 @@ APPEND_OPCODES.add(VM_INVOKE_COMPONENT_LAYOUT_GUARDED_OP, (vm, { op1: register } // 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. - // null end marker is safe here: during initial render, no sibling content - // has been rendered after this component yet, so removing from start to - // the end of the parent won't affect other nodes. + // 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, null); + clearDOMRange(parent, start, parentNextSibling); // Reset block to empty state for the retry render. block.resetPartial();