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..e641e13e21a --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/components/error-boundary.ts @@ -0,0 +1,19 @@ +import { setComponentTemplate } from '@glimmer/manager/lib/public/template'; +import { templateOnlyComponent } from '@glimmer/runtime/lib/component/template-only'; + +import ErrorBoundaryTemplate from '../templates/error-boundary'; + +/** + * A template-only `` whose layout is a single `{{#try}}`. Its + * `<:try>` and `<:catch>` are named blocks, so unlike the keyword's clauses + * they can be written in either order. + */ +const ErrorBoundary = setComponentTemplate( + ErrorBoundaryTemplate, + templateOnlyComponent( + 'packages/@ember/-internals/glimmer/lib/components/error-boundary', + 'ErrorBoundary' + ) +); + +export default ErrorBoundary; 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..3f8abafa107 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/templates/error-boundary.ts @@ -0,0 +1,17 @@ +import { precompileTemplate } from '@ember/template-compilation'; + +/** + * The whole of ``: a `{{#try}}` that yields to the `<:try>` and + * `<:catch>` blocks. A boundary with no fallback uses `{{#try}}` directly, so + * there is no default-block form. + */ +export default precompileTemplate( + `{{#try}}{{yield to="try"}}{{catch as |error retry|}}{{yield error retry to="catch"}}{{/try}}`, + { + moduleName: 'packages/@ember/-internals/glimmer/lib/templates/error-boundary.hbs', + strictMode: true, + scope() { + return {}; + }, + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/syntax/try-catch-review-test.ts b/packages/@ember/-internals/glimmer/tests/integration/syntax/try-catch-review-test.ts new file mode 100644 index 00000000000..5eb1f672ab9 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/syntax/try-catch-review-test.ts @@ -0,0 +1,401 @@ +import { defineSimpleHelper, moduleFor, runTask } from 'internal-test-helpers'; +import type { SimpleElement } from '@glimmer/interfaces'; +import { DEBUG } from '@glimmer/env'; +import { tracked } from '@glimmer/tracking'; +import { precompileTemplate } from '@ember/template-compilation'; +import templateOnly from '@ember/component/template-only'; +import { setComponentTemplate } from '@glimmer/manager'; +import { associateDestroyableChild } from '@glimmer/destroyable'; +import { renderComponent as renderGlimmerComponent, renderSync } from '@glimmer/runtime'; +import { + getTrackingDepth, + isInErrorBoundary, + resetTracking, +} from '@glimmer/validator/lib/tracking'; + +import { BaseRenderer, setRenderer } from '../../../lib/renderer'; +import { TryCatchTestCase } from './try-catch-test'; + +moduleFor( + 'Syntax: {{#try}} [try-catch-runtime] review 3 — propagated fallback dependencies', + class extends TryCatchTestCase { + '@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 as |e r|}}{{/try}}{{catch as |e r|}}outer caught {{e.message}}{{/try}}after', + { strictMode: true, scope: () => ({ state, body, Fallback }) } + ), + templateOnly() + ); + this.renderComponent(Root, { + expect: automaticRetry + ? 'beforeprefixinner recoveredafter' + : 'beforebodyafter', + }); + this.assertChange({ + change: () => { + state.phase = automaticRetry ? 2 : 1; + state.fallbackFails = true; + }, + expect: 'beforeouter caught fallback failureafter', + }); + this.assert.false(isInErrorBoundary(), 'propagation balances boundary depth'); + this.assertChange({ + change: () => (state.fallbackFails = false), + expect: 'beforeprefixinner recoveredafter', + }); + this.assertChange({ + change: () => (state.phase = 3), + expect: 'beforeprefixinner recoveredafter', + }); + this.assert.false(isInErrorBoundary(), 'subsequent recovery balances boundary depth'); + } + } +); + +moduleFor( + 'Syntax: {{#try}} [try-catch-runtime] review', + class extends TryCatchTestCase { + /* eslint-disable no-console */ + '@test uncaught append without try retains upstream logging and reset'(assert: Assert) { + let error = new Error('no boundary append'); + let boom = defineSimpleHelper(() => { + throw error; + }); + let Root = setComponentTemplate( + precompileTemplate('{{boom}}', { strictMode: true, scope: () => ({ boom }) }), + templateOnly() + ); + let original = console.error; + let logs: unknown[][] = []; + console.error = (...args: unknown[]) => logs.push(args); + try { + assert.throws( + () => this.renderComponent(Root, { expect: 'unreachable' }), + (caught: unknown) => caught === error, + 'the exact error propagates' + ); + assert.false(isInErrorBoundary(), 'uncaught append leaves no active boundary'); + if (DEBUG) { + assert.ok(logs.some(([message]) => String(message).includes('Error occurred:'))); + assert.strictEqual(getTrackingDepth(), 0, 'upstream resetTracking ran'); + } + } finally { + console.error = original; + } + } + + '@test uncaught update without try retains upstream logging and reset'(assert: Assert) { + class State { + @tracked failing = false; + } + let state = new State(); + let error = new Error('no boundary update'); + let boom = defineSimpleHelper((failing: unknown) => { + if (failing) throw error; + return 'ok'; + }); + let Root = setComponentTemplate( + precompileTemplate('{{boom state.failing}}', { + strictMode: true, + scope: () => ({ boom, state }), + }), + templateOnly() + ); + this.renderComponent(Root, { expect: 'ok' }); + let original = console.error; + let logs: unknown[][] = []; + console.error = (...args: unknown[]) => logs.push(args); + try { + assert.throws( + () => runTask(() => (state.failing = true)), + (caught: unknown) => caught === error, + 'the exact error propagates' + ); + assert.false(isInErrorBoundary(), 'uncaught update leaves no active boundary'); + if (DEBUG) { + assert.ok(logs.some(([message]) => String(message).includes('Error occurred:'))); + assert.strictEqual(getTrackingDepth(), 0, 'upstream resetTracking ran'); + } + } finally { + console.error = original; + } + } + /* eslint-enable no-console */ + + '@test escaped fallback update restores tracking frames in every build'(assert: Assert) { + class State { + @tracked failing = false; + } + let state = new State(); + let body = defineSimpleHelper(() => { + throw new Error('body failure'); + }); + let error = new Error('fallback update failure'); + let fallback = defineSimpleHelper((failing: unknown) => { + if (failing) throw error; + return 'fallback'; + }); + let Root = setComponentTemplate( + precompileTemplate('{{#try}}{{body}}{{catch}}{{fallback state.failing}}{{/try}}', { + strictMode: true, + scope: () => ({ body, fallback, state }), + }), + templateOnly() + ); + this.renderComponent(Root, { expect: 'fallback' }); + let depth = getTrackingDepth(); + try { + assert.throws( + () => runTask(() => (state.failing = true)), + (caught: unknown) => caught === error + ); + assert.false(isInErrorBoundary(), 'boundary depth is restored'); + assert.strictEqual(getTrackingDepth(), depth, 'no abandoned children tracking frame'); + } finally { + // Keep an observed leak from affecting later tests. + resetTracking(); + } + } + + '@test 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{{/try}}{{#in-element remote insertBefore=null}}independent{{/in-element}}', + { strictMode: true, scope: () => ({ 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 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{{/try}}', { + strictMode: true, + scope: () => ({ 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'); + } + } +); + +moduleFor( + 'Syntax: {{#try}} [try-catch-runtime] review 2', + class extends TryCatchTestCase { + '@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{{/try}}', + { strictMode: true, scope: () => ({ 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 escaped fallback update retains the component name in the DEBUG error log'( + assert: Assert + ) { + if (!DEBUG) { + assert.ok(true, 'DEBUG logging is disabled in production'); + return; + } + class State { + @tracked failing = false; + } + let state = new State(); + let error = new Error('review 2 fallback failure'); + let body = defineSimpleHelper(() => { + throw new Error('review 2 body failure'); + }); + let fallback = defineSimpleHelper((failing: unknown) => { + if (failing) throw error; + return 'fallback'; + }); + let ReviewLogChild = setComponentTemplate( + precompileTemplate('{{#try}}{{body}}{{catch}}{{fallback state.failing}}{{/try}}', { + strictMode: true, + scope: () => ({ body, fallback, state }), + }), + templateOnly(undefined, 'ReviewLogChild') + ); + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ ReviewLogChild }), + }), + templateOnly() + ); + this.renderComponent(Root, { expect: 'fallback' }); + /* eslint-disable no-console */ + let original = console.error; + let logs: unknown[][] = []; + console.error = (...args: unknown[]) => logs.push(args); + try { + assert.throws( + () => runTask(() => (state.failing = true)), + (caught: unknown) => caught === error, + 'the exact fallback error propagates' + ); + let log = logs.find(([message]) => String(message).includes('Error occurred:')); + assert.ok(log, 'the upstream error log is emitted'); + assert.true( + String(log?.[0]).includes('ReviewLogChild'), + `the failing component remains in the log: ${String(log?.[0])}` + ); + assert.strictEqual(getTrackingDepth(), 0, 'tracking is still reset'); + } finally { + console.error = original; + resetTracking(); + } + /* eslint-enable no-console */ + } + + '@test 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{{/try}}{{#in-element remote insertBefore=null}}independent{{/in-element}}', + { strictMode: true, scope: () => ({ 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' + ); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/syntax/try-catch-test.ts b/packages/@ember/-internals/glimmer/tests/integration/syntax/try-catch-test.ts new file mode 100644 index 00000000000..29f8274b63d --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/syntax/try-catch-test.ts @@ -0,0 +1,2248 @@ +import { + AbstractStrictTestCase, + assertHTML, + buildOwner, + clickElement, + defineSimpleHelper, + defineSimpleModifier, + moduleFor, + runDestroy, + runTask, + RenderingTestCase, +} from 'internal-test-helpers'; + +import { DEBUG } from '@glimmer/env'; +import { precompileTemplate } from '@ember/template-compilation'; +import templateOnly from '@ember/component/template-only'; +import { setComponentManager } from '@ember/component'; +import { array, on, renderComponent as renderGlimmerComponent, renderSync } from '@glimmer/runtime'; +import { tracked } from '@glimmer/tracking'; +import GlimmerishComponent from '../../utils/glimmerish-component'; + +import { run } from '@ember/runloop'; +import { associateDestroyableChild, destroy, registerDestructor } from '@glimmer/destroyable'; +import { + componentCapabilities, + modifierCapabilities, + setModifierManager, + setComponentTemplate, +} from '@glimmer/manager'; +import { + BaseRenderer, + renderComponent, + setRenderer, + type RenderResult, +} from '../../../lib/renderer'; +import type Owner from '@ember/owner'; +import { setOwner } from '@ember/-internals/owner'; +import type { Arguments, SimpleElement } from '@glimmer/interfaces'; +import { getTrackingDepth, isInErrorBoundary } from '@glimmer/validator/lib/tracking'; + +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++; + }, + }), + {} + ); +} + +class ThrowOnlyState { + @tracked shouldThrow = false; +} +class SiblingState { + @tracked label = 'before'; +} +class ConditionalState { + @tracked show = true; +} +class InnerState { + @tracked value = 'hello'; +} + +const Throwing = setComponentTemplate( + precompileTemplate('{{this.boom}}'), + class extends GlimmerishComponent { + get boom(): never { + throw new Error('render error'); + } + } +); + +const MaybeThrow = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + if ((this as any).args.shouldThrow) { + throw new Error('conditional error'); + } + return 'ok'; + } + } +); + +export class TryCatchTestCase 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( + 'Syntax: {{#try}} [try-catch-runtime] — prototype semantics', + class extends TryCatchTestCase { + afterEach() { + super.afterEach(); + } + + '@test renders default block when no error'() { + let Root = setComponentTemplate( + precompileTemplate('{{#try}}hello{{catch as |err|}}caught{{/try}}', { + strictMode: true, + scope: () => ({}), + }), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hello' }); + } + + '@test renders the try body with a zero-param catch when no error'() { + let Root = setComponentTemplate( + precompileTemplate('{{#try}}hello{{catch}}caught{{/try}}', { + strictMode: true, + scope: () => ({}), + }), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hello' }); + } + + '@test renders catch with error and retry when the try body throws'() { + class State { + @tracked shouldThrow = true; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err retry|}}caught: {{err.message}} {{/try}}', + { strictMode: true, scope: () => ({ 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 catches error during initial render and shows error block'() { + let Root = setComponentTemplate( + precompileTemplate('{{#try}}{{catch as |err|}}caught{{/try}}', { + strictMode: true, + scope: () => ({ Throwing }), + }), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught' }); + } + + /* eslint-disable no-console */ + '@test logs caught error to console.error in DEBUG mode during initial render'(assert: Assert) { + if (!DEBUG) { + assert.expect(0); + return; + } + + let originalConsoleError = console.error; + let errors: unknown[][] = []; + console.error = (...args: unknown[]) => errors.push(args); + + try { + let Root = setComponentTemplate( + precompileTemplate('{{#try}}{{catch as |err|}}caught{{/try}}', { + strictMode: true, + scope: () => ({ Throwing }), + }), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught' }); + + assert.ok(errors.length > 0, 'console.error was called'); + assert.strictEqual( + errors[0]![0], + 'An error was caught by {{#try}}:', + 'logs the expected message' + ); + assert.ok(errors[0]![1] instanceof Error, 'logs the error object'); + assert.strictEqual( + (errors[0]![1] as Error).message, + 'render error', + 'logs the correct error' + ); + } finally { + console.error = originalConsoleError; + } + } + + '@test logs caught error to console.error in DEBUG mode during rerender'(assert: Assert) { + if (!DEBUG) { + assert.expect(0); + return; + } + + let originalConsoleError = console.error; + let errors: unknown[][] = []; + + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err|}}caught{{/try}}', + { strictMode: true, scope: () => ({ MaybeThrow, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Start capturing after initial render + console.error = (...args: unknown[]) => errors.push(args); + + try { + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + + assert.ok(errors.length > 0, 'console.error was called during rerender'); + assert.strictEqual( + errors[0]![0], + 'An error was caught by {{#try}}:', + 'logs the expected message' + ); + assert.strictEqual( + (errors[0]![1] as Error).message, + 'conditional error', + 'logs the correct error' + ); + } finally { + console.error = originalConsoleError; + } + } + /* eslint-enable no-console */ + + '@test passes error object to error block'() { + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err|}}caught: {{err.message}}{{/try}}', + { strictMode: true, scope: () => ({ Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: render error' }); + } + + '@test catches error during rerender'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err|}}caught{{/try}}', + { strictMode: true, scope: () => ({ MaybeThrow, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + } + + '@test retry re-renders default content after error is fixed'() { + class State { + @tracked shouldThrow = true; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err retry|}}{{/try}}', + { strictMode: true, scope: () => ({ MaybeThrow, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: '' }); + + state.shouldThrow = false; + + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + } + + '@test nested boundaries — inner catches, outer unaffected'() { + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}outer ok {{#try}}{{catch as |err|}}inner caught{{/try}}{{catch as |err|}}outer caught{{/try}}', + { strictMode: true, scope: () => ({ Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'outer ok inner caught' }); + } + + '@test renders nothing when no error block provided'() { + let Root = setComponentTemplate( + precompileTemplate('{{#try}}{{/try}}', { + strictMode: true, + scope: () => ({ Throwing }), + }), + templateOnly() + ); + + this.renderComponent(Root, { expect: '' }); + } + + '@test tracked properties work after error recovery via retry'() { + class State { + @tracked shouldThrow = true; + } + let state = new State(); + + class CounterComponent extends GlimmerishComponent { + @tracked count = 0; + increment = () => this.count++; + } + + let Counter = setComponentTemplate( + precompileTemplate( + '{{this.count}}', + { strictMode: true, scope: () => ({ on }) } + ), + CounterComponent + ); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err retry|}}{{/try}}', + { strictMode: true, scope: () => ({ MaybeThrow, Counter, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: '' }); + + state.shouldThrow = false; + + this.assertChange({ + change: () => clickElement('.retry'), + expect: 'ok0', + }); + + this.assertChange({ + change: () => clickElement('button:not(.retry)'), + expect: 'ok1', + }); + } + + '@test sibling content survives error and recovery round-trip'() { + let Root = setComponentTemplate( + precompileTemplate( + 'before{{#try}}{{catch as |err|}}caught{{/try}}after', + { strictMode: true, scope: () => ({ Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'beforecaughtafter' }); + } + + '@test catches error from deeply nested grandchild component'() { + let Child = setComponentTemplate( + precompileTemplate('', { strictMode: true, scope: () => ({ Throwing }) }), + templateOnly() + ); + let Parent = setComponentTemplate( + precompileTemplate('', { strictMode: true, scope: () => ({ Child }) }), + templateOnly() + ); + + let Root = setComponentTemplate( + precompileTemplate('{{#try}}{{catch as |err|}}caught: {{err.message}}{{/try}}', { + strictMode: true, + scope: () => ({ Parent }), + }), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: render error' }); + } + + '@test catches error from item in each loop'() { + let ItemComponent = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + if ((this as any).args.item === 'bad') { + throw new Error('bad item'); + } + return (this as any).args.item; + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{#each (array "good" "bad" "also-good") as |item|}}{{/each}}{{catch as |err|}}caught: {{err.message}}{{/try}}', + { strictMode: true, scope: () => ({ ItemComponent, array }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: bad item' }); + } + + '@test helper throws during rerender when tracked state changes'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let maybeThrowHelper = defineSimpleHelper((shouldThrow: unknown) => { + if (shouldThrow) throw new Error('helper rerender error'); + return 'helper ok'; + }); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{maybeThrowHelper state.shouldThrow}}{{catch as |err|}}caught: {{err.message}}{{/try}}', + { strictMode: true, scope: () => ({ maybeThrowHelper, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'helper ok' }); + + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught: helper rerender error', + }); + } + + '@test catches error thrown by a helper'() { + let throwingHelper = defineSimpleHelper(() => { + throw new Error('helper error'); + }); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{throwingHelper}}{{catch as |err|}}caught: {{err.message}}{{/try}}', + { strictMode: true, scope: () => ({ throwingHelper }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: helper error' }); + } + + '@test modifiers install correctly inside error boundary'(assert: Assert) { + let trackingModifier = defineSimpleModifier((element: Element) => { + assert.step('modifier installed'); + element.setAttribute('data-modified', 'true'); + }); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}
content
{{catch as |err|}}caught{{/try}}', + { strictMode: true, scope: () => ({ trackingModifier }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: '
content
' }); + assert.verifySteps(['modifier installed']); + } + + '@test computed properties and tracked dependencies work inside boundary'() { + class State { + @tracked firstName = 'Ada'; + @tracked lastName = 'Lovelace'; + } + let state = new State(); + + let FullName = setComponentTemplate( + precompileTemplate('{{this.fullName}}'), + class extends GlimmerishComponent { + get fullName() { + return `${(this as any).args.first} ${(this as any).args.last}`; + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err|}}caught{{/try}}', + { strictMode: true, scope: () => ({ FullName, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'Ada Lovelace' }); + + this.assertChange({ + change: () => (state.firstName = 'Grace'), + expect: 'Grace Lovelace', + }); + + this.assertChange({ + change: () => (state.lastName = 'Hopper'), + expect: 'Grace Hopper', + }); + } + + '@test multiple sibling boundaries — one errors, other stays intact'() { + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err|}}first caught{{/try}}{{#try}}second ok{{catch as |err|}}second caught{{/try}}', + { strictMode: true, scope: () => ({ Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'first caughtsecond ok' }); + } + + '@test error boundary preserves error block across unrelated rerenders'() { + class State { + @tracked counter = 0; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{state.counter}}{{#try}}{{catch as |err|}}caught{{/try}}', + { strictMode: true, scope: () => ({ Throwing, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: '0caught' }); + + this.assertChange({ + change: () => state.counter++, + expect: '1caught', + }); + + this.assertChange({ + change: () => state.counter++, + expect: '2caught', + }); + } + + '@test outer tracked state continues to work across boundary error transitions'() { + class State { + @tracked label = 'hello'; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{state.label}}{{#try}}{{catch as |err|}}caught{{/try}}', + { strictMode: true, scope: () => ({ Throwing, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hellocaught' }); + + this.assertChange({ + change: () => (state.label = 'world'), + expect: 'worldcaught', + }); + } + + '@test catches error from conditional branch during initial render'() { + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{#if true}}{{else}}safe{{/if}}{{catch as |err|}}caught: {{err.message}}{{/try}}', + { strictMode: true, scope: () => ({ Throwing }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: render error' }); + } + + '@test catches error when tracked array mutation causes throw during rerender'() { + class State { + @tracked items = ['a', 'b']; + } + let state = new State(); + + let ItemComponent = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + if ((this as any).args.item === 'bomb') { + throw new Error('bomb item'); + } + return (this as any).args.item; + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{#each state.items as |item|}}{{/each}}{{catch as |err|}}caught: {{err.message}}{{/try}}', + { strictMode: true, scope: () => ({ ItemComponent, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ab' }); + + this.assertChange({ + change: () => (state.items = ['a', 'b', 'bomb']), + expect: 'caught: bomb item', + }); + } + + // Modifier install errors are not caught by try because modifiers install + // during transaction.commit(), which runs after VM execution completes. + // try only catches errors during the VM render phase. + '@skip catches error thrown by a modifier'() { + let throwingModifier = defineSimpleModifier(() => { + throw new Error('modifier error'); + }); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}
content
{{catch as |err|}}caught: {{err.message}}{{/try}}', + { strictMode: true, scope: () => ({ throwingModifier }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: modifier error' }); + } + + '@test destructors are called when boundary transitions to error state'(assert: Assert) { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + // Use a component manager with destructor capability so the component + // instance enters the destroyable hierarchy. The default + // GlimmerishComponentManager has no destructor support, so + // registerDestructor on the component instance would never fire. + class DestroyableComponent { + args: any; + constructor(owner: any, args: any) { + setOwner(this, owner); + this.args = args; + registerDestructor(this, () => assert.step('destroyed'), true); + } + + get value() { + if (this.args.shouldThrow) { + throw new Error('conditional error'); + } + return 'alive'; + } + } + + setComponentManager( + () => ({ + capabilities: componentCapabilities('3.13', { destructor: true }), + createComponent(Factory: any, args: any) { + return new Factory(undefined, args.named); + }, + getContext(component: any) { + return component; + }, + destroyComponent(component: any) { + destroy(component); + }, + }), + DestroyableComponent + ); + + let Tracked = setComponentTemplate( + precompileTemplate('{{this.value}}'), + DestroyableComponent as any + ); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err|}}caught{{/try}}', + { strictMode: true, scope: () => ({ Tracked, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'alive' }); + + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + + assert.verifySteps(['destroyed']); + } + + '@test retry that still throws shows error block again'() { + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err retry|}}caught {{/try}}', + { strictMode: true, scope: () => ({ Throwing, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught ' }); + + this.assertChange({ + change: () => clickElement('button'), + expect: 'caught ', + }); + } + + '@test deeply nested components with conditionals — error in conditional branch'() { + class State { + @tracked showDanger = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{#if state.showDanger}}{{else}}safe{{/if}}{{catch as |err|}}caught{{/try}}', + { strictMode: true, scope: () => ({ Throwing, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'safe' }); + + this.assertChange({ + change: () => (state.showDanger = true), + expect: 'caught', + }); + } + + '@test rerender error then fix state and retry recovers'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err retry|}}caught {{/try}}', + { strictMode: true, scope: () => ({ MaybeThrow, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Trigger error via rerender + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Fix state and retry — must not cause backtracking assertion + state.shouldThrow = false; + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + } + + '@test repeated rerender errors do not corrupt tracking state'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err retry|}}caught {{/try}}', + { strictMode: true, scope: () => ({ MaybeThrow, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // First trigger + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Second trigger (same value — still dirties tag, causes revalidation) + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Fix and retry + state.shouldThrow = false; + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + } + + '@test error in error block fallback bubbles to parent boundary'() { + let ThrowingFallback = setComponentTemplate( + precompileTemplate('{{this.boom}}'), + class extends GlimmerishComponent { + get boom(): never { + throw new Error('fallback error'); + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{#try}}{{catch as |err|}}{{/try}}{{catch as |err|}}outer caught: {{err.message}}{{/try}}', + { strictMode: true, scope: () => ({ Throwing, ThrowingFallback }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'outer caught: fallback error' }); + } + + '@test each loop insert error then retry recovers'() { + class State { + @tracked items = ['a', 'b']; + } + let state = new State(); + + let ItemComponent = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + if ((this as any).args.item === 'bomb') { + throw new Error('bomb'); + } + return (this as any).args.item; + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{#each state.items as |item|}}{{/each}}{{catch as |err retry|}}caught {{/try}}', + { strictMode: true, scope: () => ({ ItemComponent, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ab' }); + + // Add bad item — triggers insertItem path + this.assertChange({ + change: () => (state.items = ['a', 'b', 'bomb']), + expect: 'caught ', + }); + + // Fix and retry + state.items = ['a', 'b']; + this.assertChange({ + change: () => clickElement('button'), + expect: 'ab', + }); + } + + '@test each loop repeated insert errors then retry recovers'() { + class State { + @tracked items = ['a', 'b']; + } + let state = new State(); + + let ItemComponent = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + if ((this as any).args.item === 'bomb') { + throw new Error('bomb'); + } + return (this as any).args.item; + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{#each state.items as |item|}}{{/each}}{{catch as |err retry|}}caught {{/try}}', + { strictMode: true, scope: () => ({ ItemComponent, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ab' }); + + // First bad mutation + this.assertChange({ + change: () => (state.items = ['a', 'b', 'bomb']), + expect: 'caught ', + }); + + // Second bad mutation while in error state + this.assertChange({ + change: () => (state.items = ['a', 'bomb', 'c']), + expect: 'caught ', + }); + + // Fix and retry + state.items = ['x', 'y']; + this.assertChange({ + change: () => clickElement('button'), + expect: 'xy', + }); + } + + '@test recovers when state read by the failed initial render changes'() { + class State { + @tracked shouldThrow = true; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err|}}caught{{/try}}', + { strictMode: true, scope: () => ({ MaybeThrow, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught' }); + + this.assertChange({ + change: () => (state.shouldThrow = false), + expect: 'ok', + }); + } + + '@test recovers when state read by the failed rerender changes'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err|}}caught{{/try}}', + { strictMode: true, scope: () => ({ MaybeThrow, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + + this.assertChange({ + change: () => (state.shouldThrow = false), + expect: 'ok', + }); + + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + } + + '@test does not retry when state the failed render never read changes'(assert: Assert) { + class State { + @tracked label = 'a'; + } + let state = new State(); + let attempts = 0; + + let CountedThrow = setComponentTemplate( + precompileTemplate('{{this.boom}}'), + class extends GlimmerishComponent { + get boom(): never { + attempts++; + throw new Error('render error'); + } + } + ); + + // The default block throws before it reaches `state.label`, so only the + // error block depends on it. Changing it must update the fallback + // without re-attempting the default block. + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{state.label}}{{catch}}caught {{state.label}}{{/try}}', + { strictMode: true, scope: () => ({ CountedThrow, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught a' }); + assert.strictEqual(attempts, 1, 'default block attempted once on initial render'); + + this.assertChange({ + change: () => (state.label = 'b'), + expect: 'caught b', + }); + assert.strictEqual(attempts, 1, 'unrelated state change does not re-attempt'); + } + + '@test retries when state read before the throw changes, and re-catches if still failing'( + assert: Assert + ) { + class State { + @tracked count = 1; + } + let state = new State(); + let attempts = 0; + + let FailsWhilePositive = setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends GlimmerishComponent { + get value() { + attempts++; + let count = (this as any).args.count; + if (count > 0) { + throw new Error(`count is ${count}`); + } + return 'ok'; + } + } + ); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err|}}caught: {{err.message}}{{/try}}', + { strictMode: true, scope: () => ({ FailsWhilePositive, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught: count is 1' }); + assert.strictEqual(attempts, 1); + + this.assertChange({ + change: () => (state.count = 2), + expect: 'caught: count is 2', + }); + assert.strictEqual(attempts, 2, 'retried once and caught the new error'); + + this.assertChange({ + change: () => (state.count = 0), + expect: 'ok', + }); + assert.strictEqual(attempts, 3, 'retried once and recovered'); + } + + '@test rerender error then retry without fixing re-catches'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err retry|}}caught {{/try}}', + { strictMode: true, scope: () => ({ MaybeThrow, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // Trigger error via tracked state change + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Retry WITHOUT fixing state — error should be re-caught + this.assertChange({ + change: () => clickElement('button'), + expect: 'caught ', + }); + } + + '@test multiple error-recovery cycles do not require extra retry clicks'() { + class State { + @tracked shouldThrow = false; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err retry|}}caught {{/try}}', + { strictMode: true, scope: () => ({ MaybeThrow, state, on }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ok' }); + + // --- Cycle 1: error → retry without fixing → re-catch → fix → retry → recover --- + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Retry without fixing — should re-catch + this.assertChange({ + change: () => clickElement('button'), + expect: 'caught ', + }); + + // Fix and retry — should recover + state.shouldThrow = false; + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + + // --- Cycle 2: same sequence, should still recover in same number of clicks --- + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + // Retry without fixing — should re-catch + this.assertChange({ + change: () => clickElement('button'), + expect: 'caught ', + }); + + // Fix and retry — should recover (NOT require extra clicks) + state.shouldThrow = false; + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + + // --- Cycle 3: one more to be sure --- + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught ', + }); + + state.shouldThrow = false; + this.assertChange({ + change: () => clickElement('button'), + expect: 'ok', + }); + } + + '@test sibling content after try updates correctly'() { + let state = new SiblingState(); + + let Sibling = setComponentTemplate( + precompileTemplate('{{state.label}}', { strictMode: true, scope: () => ({ state }) }), + templateOnly() + ); + + let Root = setComponentTemplate( + precompileTemplate('{{#try}}content{{catch as |err|}}caught{{/try}}', { + strictMode: true, + scope: () => ({ Sibling }), + }), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'contentbefore' }); + + // Changing tracked state on sibling should not crash. + // Without the block stack fix, the try'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 try re-renders with sibling content after'() { + let state = new InnerState(); + let siblingState = new SiblingState(); + let Inner = setComponentTemplate( + precompileTemplate('{{state.value}}', { strictMode: true, scope: () => ({ state }) }), + templateOnly() + ); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |err|}}caught{{/try}}{{siblingState.label}}', + { strictMode: true, scope: () => ({ Inner, siblingState }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hellobefore' }); + + // Re-render inside EB content — without the cursor fix, this inverts + // the parent block's bounds (firstNode after lastNode in DOM order). + this.assertChange({ + change: () => (state.value = 'world'), + expect: 'worldbefore', + }); + + // Re-render sibling — verifies bounds are correct after EB content changed. + this.assertChange({ + change: () => (siblingState.label = 'after'), + expect: 'worldafter', + }); + } + + '@test tracked state inside try content updates'() { + let state = new InnerState(); + + let Root = setComponentTemplate( + precompileTemplate('{{#try}}{{state.value}}{{catch as |err|}}caught{{/try}}', { + strictMode: true, + scope: () => ({ state }), + }), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hello' }); + + this.assertChange({ + change: () => (state.value = 'world'), + expect: 'world', + }); + } + + '@test try inside conditional that toggles'() { + let state = new ConditionalState(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#if state.show}}{{#try}}content{{catch as |err|}}caught{{/try}}{{/if}}', + { strictMode: true, scope: () => ({ state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'content' }); + + this.assertChange({ + change: () => (state.show = false), + expect: '', + }); + + this.assertChange({ + change: () => (state.show = true), + expect: 'content', + }); + } + + '@test try and sibling in conditional block re-render'() { + let state = new SiblingState(); + let cond = new ConditionalState(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#if cond.show}}{{#try}}eb{{catch as |err|}}caught{{/try}}{{state.label}}{{/if}}', + { strictMode: true, scope: () => ({ state, cond }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'ebbefore' }); + + this.assertChange({ + change: () => (state.label = 'after'), + expect: 'ebafter', + }); + } + + '@test try wrapping component with tracked state'() { + let state = new InnerState(); + let Inner = setComponentTemplate( + precompileTemplate('{{state.value}}', { strictMode: true, scope: () => ({ state }) }), + templateOnly() + ); + + let Root = setComponentTemplate( + precompileTemplate('{{#try}}{{catch as |err|}}caught{{/try}}', { + strictMode: true, + scope: () => ({ Inner }), + }), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hello' }); + + this.assertChange({ + change: () => (state.value = 'world'), + expect: 'world', + }); + } + + '@test catches error inside {{#in-element}} during initial render and cleans up remote DOM'() { + let remote = document.createElement('div'); + remote.id = 'eb-remote-target-1'; + // A detached target keeps remote DOM separate from the local fixture. + + let getRemote = defineSimpleHelper(() => remote); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{#in-element (getRemote) insertBefore=null}}{{/in-element}}{{catch as |err|}}caught{{/try}}', + { strictMode: true, scope: () => ({ Throwing, getRemote }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'caught' }); + + // Remote element should be completely empty — no DOM nodes at all. + this.assert.strictEqual( + remote.innerHTML, + '', + 'remote element should be completely empty after error' + ); + } + + '@test catches rerender error and cleans up {{#in-element}} remote DOM'() { + let remote = document.createElement('div'); + remote.id = 'eb-remote-target-2'; + // A detached target keeps remote DOM separate from the local fixture. + + let getRemote = defineSimpleHelper(() => remote); + let state = new ThrowOnlyState(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{#in-element (getRemote) insertBefore=null}}{{/in-element}}{{catch as |err|}}caught{{/try}}', + { strictMode: true, scope: () => ({ MaybeThrow, getRemote, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root, { expect: '' }); + + // Remote element should have content from successful render + this.assert.strictEqual(remote.textContent, 'ok', 'remote element has content before error'); + + this.assertChange({ + change: () => (state.shouldThrow = true), + expect: 'caught', + }); + + // Remote element should be completely empty — no DOM nodes at all. + this.assert.strictEqual( + remote.innerHTML, + '', + 'remote element should be completely empty after error' + ); + } + + '@test multiple re-renders of try content'() { + let state = new InnerState(); + + let Root = setComponentTemplate( + precompileTemplate('{{#try}}{{state.value}}{{catch as |err|}}caught{{/try}}', { + strictMode: true, + scope: () => ({ state }), + }), + templateOnly() + ); + + this.renderComponent(Root, { expect: 'hello' }); + + this.assertChange({ + change: () => (state.value = 'one'), + expect: 'one', + }); + + this.assertChange({ + change: () => (state.value = 'two'), + expect: 'two', + }); + + this.assertChange({ + change: () => (state.value = 'three'), + expect: 'three', + }); + } + } +); + +moduleFor( + 'Syntax: {{#try}} [try-catch-runtime] — keyword scopes and values', + class extends TryCatchTestCase { + '@test catch accepts zero params and removes partial initial output'() { + let Root = setComponentTemplate( + precompileTemplate( + 'before{{#try}}partial{{catch}}caught{{/try}}after', + { strictMode: true, scope: () => ({ Throwing }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'beforecaughtafter' }); + } + + '@test catch with one param receives the exact thrown value'(assert: Assert) { + // Falsy thrown values must be distinguished from the absence of an error. + for (let thrown of [ + new Error('identity'), + { message: 'plain object' }, + 'string', + 0, + false, + null, + undefined, + ]) { + let received: unknown[] = []; + let boom = defineSimpleHelper(() => { + throw thrown; + }); + let capture = defineSimpleHelper((error: unknown) => { + received.push(error); + return 'caught'; + }); + let Root = setComponentTemplate( + precompileTemplate('{{#try}}{{boom}}{{catch as |error|}}{{capture error}}{{/try}}', { + strictMode: true, + scope: () => ({ boom, capture }), + }), + templateOnly() + ); + this.renderComponent(Root, { expect: 'caught' }); + assert.ok(received.length > 0, 'fallback evaluated'); + for (let value of received) { + assert.strictEqual(value, thrown, 'the thrown value is passed through unchanged'); + } + runTask(() => this.component!.destroy()); + this.element.innerHTML = ''; + } + } + + '@test two catch params provide a retry function usable by on click'(assert: Assert) { + let failing = true; + let attempts = 0; + let boom = defineSimpleHelper(() => { + attempts++; + if (failing) throw new Error(`attempt ${attempts}`); + return 'recovered'; + }); + let receivedRetry: unknown; + let capture = defineSimpleHelper((retry: unknown) => { + receivedRetry = retry; + return ''; + }); + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{boom}}{{catch as |error retry|}}{{error.message}}{{capture retry}}{{/try}}', + { strictMode: true, scope: () => ({ boom, capture, on }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'attempt 1' }); + assert.strictEqual(typeof receivedRetry, 'function'); + this.assertChange({ + change: () => clickElement('button'), + expect: 'attempt 2', + }); + assert.strictEqual(attempts, 2, 'one attempt per click'); + failing = false; + this.assertChange({ change: () => clickElement('button'), expect: 'recovered' }); + assert.strictEqual(attempts, 3, 'manual retry works without dirtying a body dependency'); + } + + '@test a try without catch recovers automatically from an empty fallback'() { + class State { + @tracked failing = true; + } + let state = new State(); + let Root = setComponentTemplate( + precompileTemplate( + 'before{{#try}}partial{{/try}}after', + { strictMode: true, scope: () => ({ state, MaybeThrow }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'beforeafter' }); + this.assertChange({ + change: () => (state.failing = false), + expect: 'beforepartialokafter', + }); + } + + '@test try 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}}{{/try}}
  • {{/each}}
', + { strictMode: true, scope: () => ({ 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
', + }); + } + + '@test try inside a named block retains caller scope and block params'() { + class State { + @tracked failing = true; + @tracked label = 'caller'; + } + let state = new State(); + let Wrapper = setComponentTemplate( + precompileTemplate('
{{yield "named" to="content"}}
'), + templateOnly() + ); + let Root = setComponentTemplate( + precompileTemplate( + '<:content as |name|>{{#try}}{{name}} {{state.label}} {{catch as |error|}}{{name}} {{state.label}} {{error.message}}{{/try}}', + { strictMode: true, scope: () => ({ Wrapper, state, MaybeThrow }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: '
named caller conditional error
' }); + this.assertChange({ + change: () => (state.failing = false), + expect: '
named caller ok
', + }); + } + + '@test a try nested in another try catch catches its own body error'() { + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch as |outerError|}}outer {{outerError.message}} {{#try}}{{catch as |innerError|}}inner {{innerError.message}}{{/try}}{{/try}}', + { strictMode: true, scope: () => ({ Throwing }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'outer render error inner render error' }); + } + + '@test try body yields to the enclosing component block and catches its errors'() { + class State { + @tracked failing = false; + @tracked label = 'hello'; + } + let state = new State(); + let Wrapper = setComponentTemplate( + precompileTemplate( + '{{#try}}{{yield "yielded"}}{{catch as |error|}}{{error.message}}{{/try}}', + { strictMode: true } + ), + templateOnly() + ); + let Root = setComponentTemplate( + precompileTemplate( + '{{value}} {{state.label}} ', + { + strictMode: true, + scope: () => ({ Wrapper, state, MaybeThrow }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'yielded hello ok' }); + this.assertChange({ change: () => (state.failing = true), expect: 'conditional error' }); + this.assertChange({ + change: () => (state.failing = false), + expect: 'yielded hello ok', + }); + this.assertChange({ + change: () => (state.label = 'world'), + expect: 'yielded world ok', + }); + } + + '@test error and retry remain identical while fallback outer tracked state updates'( + assert: Assert + ) { + class State { + @tracked label = 'a'; + } + let state = new State(); + let attempts = 0; + let thrown = new Error('stable'); + let boom = defineSimpleHelper(() => { + attempts++; + throw thrown; + }); + let captures: [unknown, unknown][] = []; + let capture = defineSimpleHelper((error: unknown, retry: unknown, label: unknown) => { + captures.push([error, retry]); + return label; + }); + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{boom}}{{state.label}}{{catch as |error retry|}}{{error.message}} {{capture error retry state.label}}{{/try}}', + { strictMode: true, scope: () => ({ boom, state, capture }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'stable a' }); + let first = captures[0]!; + assert.strictEqual(first[0], thrown); + assert.strictEqual(typeof first[1], 'function'); + for (let label of ['b', 'c', 'd']) { + this.assertChange({ change: () => (state.label = label), expect: `stable ${label}` }); + } + assert.strictEqual(attempts, 1, 'fallback dependencies do not retry the failed body'); + assert.ok(captures.length >= 4, 'capture evaluated for every fallback update'); + for (let [error, retry] of captures) { + assert.strictEqual(error, first[0], 'same thrown value'); + assert.strictEqual(retry, first[1], 'same retry function'); + } + } + + '@test initial fallback error escapes when there is no enclosing try'(assert: Assert) { + let fallback = defineSimpleHelper(() => { + throw new Error('uncaught fallback'); + }); + let Root = setComponentTemplate( + precompileTemplate('{{#try}}{{catch}}{{fallback}}{{/try}}', { + strictMode: true, + scope: () => ({ Throwing, fallback }), + }), + templateOnly() + ); + assert.throws( + () => this.renderComponent(Root, { expect: 'unreachable' }), + /uncaught fallback/ + ); + assert.false(isInErrorBoundary(), 'append fallback escape restores boundary depth'); + if (DEBUG) assert.strictEqual(getTrackingDepth(), 0, 'uncaught append resets tracking'); + } + + '@test a fallback error during rerender escapes to the enclosing try'() { + class State { + @tracked failing = false; + } + let state = new State(); + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}outer{{#try}}{{catch}}inner fallback{{/try}}{{catch as |error|}}outer caught: {{error.message}}{{/try}}', + { strictMode: true, scope: () => ({ Throwing, MaybeThrow, state }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'outerinner fallbackok' }); + this.assertChange({ + change: () => (state.failing = true), + expect: 'outer caught: conditional error', + }); + this.assertChange({ + change: () => (state.failing = false), + expect: 'outerinner fallbackok', + }); + } + + '@test a fallback error during rerender escapes without an enclosing try'(assert: Assert) { + class State { + @tracked failing = false; + } + let state = new State(); + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{catch}}{{/try}}', + { + strictMode: true, + scope: () => ({ Throwing, MaybeThrow, state }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'ok' }); + assert.throws(() => runTask(() => (state.failing = true)), /conditional error/); + assert.false(isInErrorBoundary(), 'update fallback escape restores boundary depth'); + if (DEBUG) assert.strictEqual(getTrackingDepth(), 0, 'uncaught update resets tracking'); + } + } +); + +moduleFor( + 'Syntax: {{#try}} [try-catch-runtime] — abort cleanup and tracking', + class extends TryCatchTestCase { + '@test prepend then throw clears the entire attempted range and preserves siblings'() { + this.assertListAbort(['x', 'bomb', 'a', 'b', 'c']); + } + + '@test middle row insert then throw clears temporary list markers and preserves siblings'() { + this.assertListAbort(['a', 'x', 'bomb', 'b', 'c']); + } + + '@test 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}}{{/try}}{{state.label}}', + { strictMode: true, scope: () => ({ 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{{/try}}', + { + strictMode: true, + scope: () => ({ Child, modifier, Throwing }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'caught' }); + assert.deepEqual(components, { created: 1, installed: 0, updated: 0, destroyed: 1 }); + assert.deepEqual(modifiers, { created: 1, installed: 0, updated: 0, destroyed: 1 }); + 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{{/try}}', + { + strictMode: true, + scope: () => ({ modifier, Throwing }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'caught' }); + assert.deepEqual(counts, { created: 1, installed: 0, updated: 0, destroyed: 1 }); + 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}}{{/try}}', + { + strictMode: true, + scope: () => ({ modifier, throwing }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'modifier create' }); + assert.strictEqual(creates, 1); + assert.deepEqual(counts, { created: 1, installed: 0, updated: 0, destroyed: 1 }); + runTask(() => this.component!.destroy()); + assert.strictEqual(counts.destroyed, 1); + } + + '@test mounted components and modifiers are destroyed across repeated recovery cycles'( + assert: Assert + ) { + class State { + @tracked failing = false; + } + let state = new State(); + 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( + 'before{{#try}}
alive
{{catch}}caught{{/try}}after', + { + strictMode: true, + scope: () => ({ Child, modifier, MaybeThrow, state }), + } + ), + templateOnly() + ); + let alive = 'beforealive
alive
okafter'; + this.renderComponent(Root, { expect: alive }); + for (let cycle = 1; cycle <= 3; cycle++) { + assert.deepEqual(components, { + created: cycle, + installed: cycle, + updated: 0, + destroyed: cycle - 1, + }); + assert.deepEqual(modifiers, { + created: cycle, + installed: cycle, + updated: 0, + destroyed: cycle - 1, + }); + this.assertChange({ + change: () => (state.failing = true), + expect: 'beforecaughtafter', + }); + assert.strictEqual(components.destroyed, cycle); + assert.strictEqual(modifiers.destroyed, cycle); + this.assertChange({ change: () => (state.failing = false), expect: alive }); + } + runTask(() => this.component!.destroy()); + assert.deepEqual(components, { created: 4, installed: 4, updated: 0, destroyed: 4 }); + assert.deepEqual(modifiers, { created: 4, installed: 4, updated: 0, destroyed: 4 }); + } + + '@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}}{{/try}}', + { + strictMode: true, + scope: () => ({ 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); + assert.deepEqual(modifiers, expected); + 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 }); + assert.deepEqual(modifiers, { created: 4, installed: 1, updated: 0, destroyed: 4 }); + } + + '@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{{/try}}', + { + strictMode: true, + scope: () => ({ 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 }); + 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}}{{/try}}', + { strictMode: true, scope: () => ({ getRemote, state, MaybeThrow, inspect }) } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: '' }); + assert.deepEqual(observed, ['existing'], 'abort cleanup precedes fallback execution'); + 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'); + } + + '@test one-shot fallback failure propagates the replacement error to outer catch'( + assert: Assert + ) { + let attempts = 0; + let fallback = defineSimpleHelper(() => { + attempts++; + if (attempts === 1) throw new Error('one-shot fallback'); + return 'would succeed if wrongly retried'; + }); + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{#try}}{{catch}}{{fallback}}{{/try}}{{catch as |error|}}outer {{error.message}}{{/try}}', + { + strictMode: true, + scope: () => ({ Throwing, fallback }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'outer one-shot fallback' }); + assert.strictEqual(attempts, 1, 'a catch does not catch its own error and retry itself'); + } + + '@test nested mounted helper failures automatically retry without reattempting outer body'( + assert: Assert + ) { + class State { + @tracked failing = false; + @tracked label = 'before'; + } + let state = new State(); + let innerAttempts = 0; + let outerAttempts = 0; + let outer = defineSimpleHelper(() => { + outerAttempts++; + return 'outer'; + }); + let helper = defineSimpleHelper((failing: unknown) => { + innerAttempts++; + if (failing) throw new Error('mounted helper'); + return 'ok'; + }); + let Root = setComponentTemplate( + precompileTemplate( + '{{#try}}{{outer}} {{#try}}{{helper state.failing}}{{catch as |error|}}{{error.message}}{{/try}}{{catch}}outer caught{{/try}}{{state.label}}', + { + strictMode: true, + scope: () => ({ outer, helper, state }), + } + ), + templateOnly() + ); + this.renderComponent(Root, { expect: 'outer okbefore' }); + for (let cycle = 1; cycle <= 3; cycle++) { + this.assertChange({ + change: () => (state.failing = true), + expect: `outer mounted helper${state.label}`, + }); + this.assertChange({ + change: () => (state.label = `cycle ${cycle}`), + expect: `outer mounted helpercycle ${cycle}`, + }); + assert.strictEqual(innerAttempts, cycle * 2, 'unrelated sibling state does not retry'); + this.assertChange({ + change: () => (state.failing = false), + expect: `outer ok${state.label}`, + }); + assert.strictEqual(innerAttempts, cycle * 2 + 1); + assert.strictEqual(outerAttempts, 1, 'outer boundary unaffected'); + } + } + } +); + +moduleFor( + 'Syntax: {{#try}} [try-catch-runtime] — loose mode', + class extends RenderingTestCase { + '@test loose mode renders the try body and catches helper errors'() { + this.registerHelper('boom', () => { + throw new Error('loose helper'); + }); + this.render( + 'before{{#try}}partial{{boom}}{{catch as |error|}}{{error.message}}{{/try}}after' + ); + this.assertHTML('beforeloose helperafter'); + this.assertStableRerender(); + } + + '@test loose mode renders a try without a catch'() { + this.render('{{#try}}ok{{/try}}'); + this.assertHTML('ok'); + this.assertStableRerender(); + } + } +); diff --git a/packages/@ember/component/index.ts b/packages/@ember/component/index.ts index 9ce284937b3..a785817c0f7 100644 --- a/packages/@ember/component/index.ts +++ b/packages/@ember/component/index.ts @@ -6,6 +6,7 @@ export { setComponentTemplate, getComponentTemplate } from '@glimmer/manager/lib/public/template'; export { default } from '@ember/-internals/glimmer/lib/component'; +export { default as ErrorBoundary } from '@ember/-internals/glimmer/lib/components/error-boundary'; export { default as Input } from '@ember/-internals/glimmer/lib/components/input'; export { default as Textarea } from '@ember/-internals/glimmer/lib/components/textarea'; export { componentCapabilities as capabilities } from '@glimmer/manager/lib/public/component'; diff --git a/packages/@glimmer-workspace/integration-tests/test/try-catch-review-test.ts b/packages/@glimmer-workspace/integration-tests/test/try-catch-review-test.ts new file mode 100644 index 00000000000..1c8611c98ad --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/try-catch-review-test.ts @@ -0,0 +1,32 @@ +import { castToBrowser } from '@glimmer/debug-util'; +import { + RehydrationDelegate, + RenderTest, + replaceHTML, + suite, + test, +} from '@glimmer-workspace/integration-tests'; + +class TryRehydrationReview extends RenderTest { + static suiteName = '[try-catch-runtime] review: rehydration'; + declare delegate: RehydrationDelegate; + + @test + 'rehydrates a try body and preserves its server-rendered node'() { + let template = 'before{{#try}}body{{catch}}caught{{/try}}after'; + let html = this.delegate.renderServerSide(template, {}, () => {}); + replaceHTML(this.element, html); + let element = castToBrowser(this.element, 'HTML'); + let body = element.querySelector('b'); + this.assert.ok(body, 'server rendered body'); + this.renderResult = this.delegate.renderClientSide(template, {}, this.element); + this.assertHTML('beforebodyafter'); + this.assert.strictEqual(element.querySelector('b'), body, 'server body node is reused'); + this.assert.strictEqual(this.delegate.rehydrationStats.clearedNodes.length, 0); + this.assertStableRerender(); + } +} + +QUnit.module.todo('Rehydration/SSR is unsupported in the {{#try}} spike', () => { + suite(TryRehydrationReview, RehydrationDelegate); +}); diff --git a/packages/@glimmer/compiler/lib/passes/1-normalization/keywords/block.ts b/packages/@glimmer/compiler/lib/passes/1-normalization/keywords/block.ts index 7f0c1b5c12d..88cc21a8c6b 100644 --- a/packages/@glimmer/compiler/lib/passes/1-normalization/keywords/block.ts +++ b/packages/@glimmer/compiler/lib/passes/1-normalization/keywords/block.ts @@ -151,6 +151,59 @@ export const BLOCK_KEYWORDS = keywords('Block') ); }, }) + .kw('try', { + assert(node: ASTv2.InvokeBlock): Result { + let { args, blocks } = node; + + if (!args.isEmpty()) { + return Err(generateSyntaxError(`{{#try}} does not take arguments`, args.loc)); + } + + if (blocks.get('default').block.scope.locals.length > 0) { + return Err(generateSyntaxError(`{{#try}} does not take block params`, node.loc)); + } + + let inverse = blocks.get('else'); + + if (inverse) { + return Err( + generateSyntaxError(`{{#try}} does not support {{else}}; use {{catch}}`, node.loc) + ); + } + + let catchBlock = blocks.get('catch'); + let catchParams = catchBlock ? catchBlock.block.scope.locals.length : 0; + + if (catchBlock && catchParams > 2) { + return Err( + generateSyntaxError( + `{{catch}} accepts at most two block params (error and retry), received ${catchParams}`, + node.loc + ) + ); + } + + return Ok(null); + }, + + translate({ + node, + state, + }: { + node: ASTv2.InvokeBlock; + state: NormalizationState; + }): Result { + let block = node.blocks.get('default'); + let catchBlock = node.blocks.get('catch'); + + let blockResult = VISIT_STMTS.NamedBlock(block, state); + let catchResult = catchBlock ? VISIT_STMTS.NamedBlock(catchBlock, state) : Ok(null); + + return Result.all(blockResult, catchResult).mapOk( + ([block, catchBlock]) => new mir.Try({ loc: node.loc, block, catchBlock }) + ); + }, + }) .kw('unless', { assert(node: ASTv2.InvokeBlock): Result<{ condition: ASTv2.ExpressionNode; diff --git a/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/statements.ts b/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/statements.ts index 1660cacf315..ba4747a4571 100644 --- a/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/statements.ts +++ b/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/statements.ts @@ -1,9 +1,10 @@ import * as ASTv2 from '@glimmer/syntax/lib/v2/api'; +import { generateSyntaxError } from '@glimmer/syntax/lib/syntax-error'; import type { NormalizationState } from '../context'; import { OptionalList } from '../../../shared/list'; -import { Ok, Result, ResultArray } from '../../../shared/result'; +import { Err, Ok, Result, ResultArray } from '../../../shared/result'; import * as mir from '../../2-encoding/mir'; import { BLOCK_KEYWORDS } from '../keywords'; import { APPEND_KEYWORDS } from '../keywords/append'; @@ -42,6 +43,14 @@ class NormalizationStatements { } InvokeBlock(node: ASTv2.InvokeBlock, state: NormalizationState): Result { + let catchBlock = node.blocks.get('catch'); + + // Checked before any keyword translates, so no other block keyword or + // component can quietly take or drop a `{{catch}}`. + if (catchBlock && !isTryKeyword(node.callee)) { + return Err(generateSyntaxError(`{{catch}} is only valid on {{#try}}`, node.loc)); + } + let translated = BLOCK_KEYWORDS.translate(node, state); if (translated !== null) { @@ -143,3 +152,16 @@ class NormalizationStatements { } export const VISIT_STMTS = new NormalizationStatements(); + +/** + * Whether a block's callee is the `try` keyword: a free `try` with no path + * segments, as opposed to a local, lexical or qualified value of that name. + */ +function isTryKeyword(callee: ASTv2.ExpressionNode): boolean { + return ( + callee.type === 'Path' && + callee.ref.type === 'Free' && + callee.ref.name === 'try' && + callee.tail.length === 0 + ); +} diff --git a/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/strict-mode.ts b/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/strict-mode.ts index 2d30ed042c6..7d2732b97a4 100644 --- a/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/strict-mode.ts +++ b/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/strict-mode.ts @@ -90,6 +90,9 @@ export default class StrictModeValidationPass { case 'If': return this.If(statement); + case 'Try': + return this.Try(statement); + case 'Each': return this.Each(statement); @@ -287,6 +290,12 @@ export default class StrictModeValidationPass { }); } + Try(statement: mir.Try): Result { + return this.NamedBlock(statement.block).andThen(() => + statement.catchBlock ? this.NamedBlock(statement.catchBlock) : Ok(null) + ); + } + Each(statement: mir.Each): Result { return this.Expression(statement.value, statement) .andThen(() => { diff --git a/packages/@glimmer/compiler/lib/passes/2-encoding/content.ts b/packages/@glimmer/compiler/lib/passes/2-encoding/content.ts index cdd4c6ad0d1..ee5fa1e7f4c 100644 --- a/packages/@glimmer/compiler/lib/passes/2-encoding/content.ts +++ b/packages/@glimmer/compiler/lib/passes/2-encoding/content.ts @@ -75,6 +75,8 @@ export class ContentEncoder { return this.InvokeBlock(stmt); case 'If': return this.If(stmt); + case 'Try': + return this.Try(stmt); case 'Each': return this.Each(stmt); case 'Let': @@ -201,6 +203,14 @@ export class ContentEncoder { ]; } + Try({ block, catchBlock }: mir.Try): WireFormat.Statements.Try { + return [ + SexpOpcodes.Try, + CONTENT.NamedBlock(block)[1], + catchBlock ? CONTENT.NamedBlock(catchBlock)[1] : null, + ]; + } + Each({ value, key, block, inverse }: mir.Each): WireFormat.Statements.Each { return [ SexpOpcodes.Each, diff --git a/packages/@glimmer/compiler/lib/passes/2-encoding/mir.ts b/packages/@glimmer/compiler/lib/passes/2-encoding/mir.ts index d1d9cf69ce7..867584d421e 100644 --- a/packages/@glimmer/compiler/lib/passes/2-encoding/mir.ts +++ b/packages/@glimmer/compiler/lib/passes/2-encoding/mir.ts @@ -30,6 +30,11 @@ export class If extends node('If').fields<{ inverse: NamedBlock | null; }>() {} +export class Try extends node('Try').fields<{ + block: NamedBlock; + catchBlock: NamedBlock | null; +}>() {} + export class IfInline extends node('IfInline').fields<{ condition: ExpressionNode; truthy: ExpressionNode; @@ -217,6 +222,7 @@ export type Statement = | InvokeBlock | AppendComment | If + | Try | Each | Let | WithDynamicVars diff --git a/packages/@glimmer/compiler/lib/wire-format-debug.ts b/packages/@glimmer/compiler/lib/wire-format-debug.ts index da31da704c7..a65a7b59e04 100644 --- a/packages/@glimmer/compiler/lib/wire-format-debug.ts +++ b/packages/@glimmer/compiler/lib/wire-format-debug.ts @@ -209,6 +209,13 @@ export default class WireFormatDebugger { case Op.Not: return ['not']; + case Op.Try: + return [ + 'try', + this.formatBlock(opcode[1]), + opcode[2] ? this.formatBlock(opcode[2]) : null, + ]; + case Op.Each: return [ 'each', diff --git a/packages/@glimmer/compiler/test/try-keyword-test.ts b/packages/@glimmer/compiler/test/try-keyword-test.ts new file mode 100644 index 00000000000..5979ba6338e --- /dev/null +++ b/packages/@glimmer/compiler/test/try-keyword-test.ts @@ -0,0 +1,505 @@ +import type { PrecompileOptions } from '@glimmer/compiler'; +import type { SerializedTemplateBlock, SerializedTemplateWithLazyBlock } from '@glimmer/interfaces'; +import { precompile } from '@glimmer/compiler'; +import type { GlimmerSyntaxError } from '@glimmer/syntax'; +import { SexpOpcodes } from '@glimmer/wire-format'; + +/** The assigned wire opcode, intentionally independent of the future implementation export. */ +const TRY_OPCODE = 43; + +/** Read the raw block, including when precompile embeds a JavaScript lexical-scope function. */ +function compile( + source: string, + options: PrecompileOptions & { lexicalScope?: (name: string) => boolean } = {} +): SerializedTemplateBlock { + // The scope function is irrelevant to wire assertions. Do not evaluate generated JavaScript. + let json = precompile(source, options).replace(/"scope":\(\)=>\(\{[^}]*\}\)/u, '"scope":null'); + let template = JSON.parse(json) as SerializedTemplateWithLazyBlock; + return JSON.parse(template.block) as SerializedTemplateBlock; +} + +/** Pin keyword upvar allocation against an if control in the same compilation mode. */ +function tryUpvars(options: PrecompileOptions = {}): string[] { + return compile('{{#if @x}}body{{/if}}', options)[2].map((name) => (name === 'if' ? 'try' : name)); +} + +/** Obtain parameter slots and local lookups from an equivalent, supported let block. */ +function letBlock(template: SerializedTemplateBlock) { + let statement = template[0][0]; + if (statement?.[0] !== SexpOpcodes.Let) { + throw new Error('Oracle control template must start with a let opcode'); + } + return statement[2]; +} + +/** Assert a diagnostic substring while permitting the source location suffix. */ +function errorTest( + name: string, + source: string, + message: string, + options: PrecompileOptions & { lexicalScope?: (name: string) => boolean } = {} +) { + QUnit.test(name, (assert) => { + assert.throws( + () => compile(source, options), + (error: unknown) => error instanceof Error && error.message.includes(message), + message + ); + }); +} + +QUnit.module('[try-keyword-compiler] raw wire format', () => { + for (let strictMode of [false, true]) { + let mode = strictMode ? 'strict' : 'loose'; + let options = { strictMode }; + + QUnit.test(`W1 ${mode}: no catch is null and try upvars match if`, (assert) => { + assert.deepEqual(compile('{{#try}}body{{/try}}', options), [ + [[TRY_OPCODE, [compile('body', options)[0], []], null]], + [], + tryUpvars(options), + ]); + }); + + QUnit.test(`W1/W2 ${mode}: bare catch has an inline block with no params`, (assert) => { + assert.deepEqual(compile('{{#try}}body{{catch}}fallback{{/try}}', options), [ + [[TRY_OPCODE, [compile('body', options)[0], []], [compile('fallback', options)[0], []]]], + [], + tryUpvars(options), + ]); + }); + + QUnit.test(`W1 ${mode}: empty catch remains a block rather than null`, (assert) => { + assert.deepEqual(compile('{{#try}}{{catch}}{{/try}}', options), [ + [[TRY_OPCODE, [[], []], [[], []]]], + [], + tryUpvars(options), + ]); + }); + + QUnit.test(`W2 ${mode}: one catch param supplies a local path lookup`, (assert) => { + let control = compile('{{#let null as |e|}}{{e.message}}{{/let}}', options); + let fallback = letBlock(control); + assert.strictEqual(fallback[1].length, 1, 'control has one parameter slot'); + assert.deepEqual( + compile('{{#try}}body{{catch as |e|}}{{e.message}}{{/try}}', options), + [ + [[TRY_OPCODE, [compile('body', options)[0], []], fallback]], + control[1], + tryUpvars(options), + ] + ); + }); + + QUnit.test(`W2 ${mode}: two catch params preserve order and local lookups`, (assert) => { + let control = compile('{{#let null null as |e r|}}{{r}}{{e.message}}{{e}}{{/let}}', options); + let fallback = letBlock(control); + assert.strictEqual(fallback[1].length, 2, 'control has two parameter slots'); + assert.deepEqual( + compile('{{#try}}body{{catch as |e r|}}{{r}}{{e.message}}{{e}}{{/try}}', options), + [ + [[TRY_OPCODE, [compile('body', options)[0], []], fallback]], + control[1], + tryUpvars(options), + ] + ); + }); + + QUnit.test(`W3 ${mode}: if and each compile inside both blocks`, (assert) => { + let body = + '{{#if @ok}}{{#each @items as |item i|}}{{item.name}}{{i}}{{else}}empty{{/each}}{{else}}no{{/if}}'; + let control = compile(`{{#if true}}${body}{{else}}${body}{{/if}}`, options); + let outerIf = control[0][0]; + if (outerIf?.[0] !== SexpOpcodes.If) { + throw new Error('Oracle control template must start with an if opcode'); + } + assert.deepEqual(compile(`{{#try}}${body}{{catch}}${body}{{/try}}`, options), [ + [[TRY_OPCODE, outerIf[2], outerIf[3]]], + control[1], + [...tryUpvars(options), ...control[2]], + ]); + }); + } + + QUnit.test( + 'W2 loose: catch locals do not leak into the try body or following siblings', + (assert) => { + let control = compile( + '{{#if true}}{{/if}}{{e}}{{#let null null as |e r|}}{{e.message}}{{r}}{{/let}}{{e}}' + ); + assert.deepEqual( + compile('{{#try}}{{e}}{{catch as |e r|}}{{e.message}}{{r}}{{/try}}{{e}}'), + [ + [ + [ + TRY_OPCODE, + [[control[0][1]], []], + letBlock([control[0].slice(2), control[1], control[2]]), + ], + control[0][3], + ], + control[1], + control[2].filter((name) => name !== 'let').map((name) => (name === 'if' ? 'try' : name)), + ] + ); + } + ); + + errorTest( + 'W2 strict: catch params are not in scope in the try body', + '{{#try}}{{e}}{{catch as |e r|}}{{e.message}}{{r}}{{/try}}', + 'Attempted to resolve a value in a strict mode template, but that value was not in scope: e', + { strictMode: true } + ); + + QUnit.test('W3: try nests in the try body with a distinct catch', (assert) => { + assert.deepEqual( + compile( + '{{#try}}before{{#try}}inner{{catch}}inner fallback{{/try}}after{{catch}}outer fallback{{/try}}' + ), + [ + [ + [ + TRY_OPCODE, + [ + [ + ...compile('before')[0], + [TRY_OPCODE, [compile('inner')[0], []], [compile('inner fallback')[0], []]], + ...compile('after')[0], + ], + [], + ], + [compile('outer fallback')[0], []], + ], + ], + [], + tryUpvars(), + ] + ); + }); + + QUnit.test( + 'W2/W3 strict: nested catch shadows its parent and restores the outer local', + (assert) => { + let options = { strictMode: true }; + let control = compile( + '{{#let null as |e|}}{{e.message}}{{#let null null as |e r|}}{{e.message}}{{r}}{{/let}}{{e.message}}{{/let}}', + options + ); + let outer = letBlock(control); + let inner = letBlock([outer[0].slice(1), control[1], control[2]]); + assert.deepEqual( + compile( + '{{#try}}body{{catch as |e|}}{{e.message}}{{#try}}{{e.message}}{{catch as |e r|}}{{e.message}}{{r}}{{/try}}{{e.message}}{{/try}}', + options + ), + [ + [ + [ + TRY_OPCODE, + [compile('body')[0], []], + [[outer[0][0], [TRY_OPCODE, [[outer[0][0]], []], inner], outer[0][2]], outer[1]], + ], + ], + control[1], + tryUpvars(options), + ] + ); + } + ); + + QUnit.test('W3: try compiles inside an element', (assert) => { + let control = compile('
body
'); + assert.deepEqual(compile('
{{#try}}body{{catch}}fallback{{/try}}
'), [ + [ + ...control[0].slice(0, 2), + [TRY_OPCODE, [compile('body')[0], []], [compile('fallback')[0], []]], + ...control[0].slice(3), + ], + control[1], + [...control[2], ...tryUpvars()], + ]); + }); + + QUnit.test('W3: try compiles inside a component named block', (assert) => { + let control = compile('<:content>body<:else>other'); + let component = control[0][0]; + if (component?.[0] !== SexpOpcodes.Component || component[4] === null) { + throw new Error('Oracle control template must contain component named blocks'); + } + assert.deepEqual( + compile( + '<:content>{{#try}}body{{catch}}fallback{{/try}}<:else>other' + ), + [ + [ + [ + ...component.slice(0, 4), + [ + component[4][0], + [ + [[[TRY_OPCODE, [compile('body')[0], []], [compile('fallback')[0], []]]], []], + component[4][1][1], + ], + ], + ], + ], + control[1], + compile( + '<:content>{{#if true}}body{{/if}}<:else>other' + )[2].map((name) => (name === 'if' ? 'try' : name)), + ] + ); + }); + + QUnit.test('W2: sibling catches allocate separate local symbols', (assert) => { + let control = compile( + '{{#let null as |e|}}{{e.message}}{{/let}}{{#let null as |e|}}{{e.message}}{{/let}}' + ); + let first = letBlock(control); + let second = letBlock([control[0].slice(1), control[1], control[2]]); + assert.notDeepEqual(first[1], second[1], 'control allocates distinct sibling slots'); + assert.deepEqual( + compile( + '{{#try}}one{{catch as |e|}}{{e.message}}{{/try}}{{#try}}two{{catch as |e|}}{{e.message}}{{/try}}' + ), + [ + [ + [TRY_OPCODE, [compile('one')[0], []], first], + [TRY_OPCODE, [compile('two')[0], []], second], + ], + control[1], + tryUpvars(), + ] + ); + }); +}); + +QUnit.module('[try-keyword-compiler] compile errors', () => { + errorTest( + 'E1: positional arguments are rejected', + '{{#try x}}a{{/try}}', + '{{#try}} does not take arguments' + ); + errorTest( + 'E1: named arguments are rejected', + '{{#try a=1}}a{{/try}}', + '{{#try}} does not take arguments' + ); + errorTest( + 'E2: try block params are rejected', + '{{#try as |x|}}a{{/try}}', + '{{#try}} does not take block params' + ); + errorTest( + 'E3: else requires catch instead', + '{{#try}}a{{else}}b{{/try}}', + '{{#try}} does not support {{else}}; use {{catch}}' + ); + + for (let params of ['e r extra', 'e r extra fourth']) { + errorTest( + `E4: catch rejects ${params.split(' ').length} params`, + `{{#try}}a{{catch as |${params}|}}b{{/try}}`, + '{{catch}} accepts at most two block params (error and retry)' + ); + } + + let invalidCatchMessage = '{{catch}} is only valid on {{#try}}'; + errorTest( + 'E5: catch on a curly component or helper is rejected', + '{{#my-comp}}a{{catch}}b{{/my-comp}}', + invalidCatchMessage + ); + + for (let [keyword, args] of [ + ['if', 'true'], + ['unless', 'false'], + ['each', '@items'], + ['let', 'null as |x|'], + ['in-element', '@target'], + ['-with-dynamic-vars', 'a=1'], + ['component', '@definition'], + ]) { + errorTest( + `E5: catch on ${keyword} is rejected`, + `{{#${keyword} ${args}}}a{{catch}}b{{/${keyword}}}`, + invalidCatchMessage + ); + } + + for (let keyword of ['if', 'unless', 'each', 'let', 'in-element', 'component']) { + errorTest( + `E5: catch rejection precedes ${keyword} argument validation`, + `{{#${keyword}}}a{{catch}}b{{/${keyword}}}`, + invalidCatchMessage + ); + } + + for (let path of ['this.try', '@try']) { + errorTest( + `E5: catch on ${path} is rejected`, + `{{#${path}}}a{{catch}}b{{/${path}}}`, + invalidCatchMessage + ); + } + errorTest( + 'E5: catch on a local named try is rejected', + '{{#let x as |try|}}{{#try}}a{{catch}}b{{/try}}{{/let}}', + invalidCatchMessage + ); + errorTest( + 'E5: a local named catch cannot turn the clause into an append', + '{{#let x as |catch|}}{{catch}}{{/let}}', + invalidCatchMessage + ); + errorTest( + 'E5: catch on a lexical named try is rejected', + '{{#try}}a{{catch}}b{{/try}}', + invalidCatchMessage, + { strictMode: true, lexicalScope: (name) => name === 'try' } + ); + QUnit.test('try.foo: additional keyword path segments are rejected', (assert) => { + for (let strictMode of [false, true]) { + let options = { strictMode }; + let control: Error | undefined; + try { + compile('{{#if.foo}}a{{/if.foo}}', options); + } catch (error) { + if (error instanceof Error) control = error; + } + assert.ok(control, 'if.foo control rejects the qualified callee'); + assert.throws( + () => compile('{{#try.foo}}a{{/try.foo}}', options), + (error: unknown) => + error instanceof Error && error.message === control?.message.replaceAll('if', 'try'), + `${strictMode ? 'strict' : 'loose'} diagnostic matches if.foo with the name swapped` + ); + } + }); +}); + +QUnit.module('[try-keyword-compiler] shadowing and block-only reservation', () => { + QUnit.test('S1 baseline-green guard: local try invokes default and else blocks', (assert) => { + let actual = compile('{{#let x as |try|}}{{#try}}a{{else}}b{{/try}}{{/let}}'); + let control = compile('{{#let x as |zzz|}}{{#zzz}}a{{else}}b{{/zzz}}{{/let}}'); + assert.deepEqual(actual[0], control[0]); + assert.deepEqual( + actual[1], + control[1].map((name) => (name === 'zzz' ? 'try' : name)) + ); + assert.deepEqual(actual[2], control[2]); + assert.strictEqual(letBlock(actual)[0][0]?.[0], SexpOpcodes.Block); + }); + + QUnit.test( + 'S2 baseline-green guard: lexical try invokes its scoped value in strict mode', + (assert) => { + let actual = compile('{{#try}}a{{/try}}', { + strictMode: true, + lexicalScope: (name) => name === 'try', + }); + let control = compile('{{#zzz}}a{{/zzz}}', { + strictMode: true, + lexicalScope: (name) => name === 'zzz', + }); + assert.deepEqual(actual, [ + control[0], + control[1], + control[2].map((name) => (name === 'zzz' ? 'try' : name)), + ]); + assert.strictEqual(actual[0][0]?.[0], SexpOpcodes.Block); + assert.deepEqual(actual[0][0]?.[1], [SexpOpcodes.GetLexicalSymbol, 0]); + } + ); + + errorTest( + 'S3 baseline-green guard: strict append try remains not in scope', + '{{try x}}', + 'Attempted to resolve a component or helper in a strict mode template, but that value was not in scope: try', + { strictMode: true } + ); + errorTest( + 'S3 baseline-green guard: strict subexpression try remains not in scope', + '{{this.foo (try)}}', + 'Attempted to resolve a helper in a strict mode template, but that value was not in scope: try', + { strictMode: true } + ); + + for (let [position, source] of [ + ['append', '{{try x}}'], + ['subexpression', '{{foo (try x)}}'], + ['modifier', '
'], + ] as const) { + QUnit.test(`S4 baseline-green guard: loose ${position} try is an ordinary lookup`, (assert) => { + let actual = compile(source); + let control = compile(source.replace('try', 'zzz')); + assert.deepEqual(actual, [ + control[0], + control[1], + control[2].map((name) => (name === 'zzz' ? 'try' : name)), + ]); + assert.true(actual[2].includes('try'), 'ordinary try is retained in upvars'); + }); + } + + errorTest( + 'S5: strict scope validation visits the try body', + '{{#try}}{{missingBody}}{{catch}}fallback{{/try}}', + 'Attempted to resolve a value in a strict mode template, but that value was not in scope: missingBody', + { strictMode: true } + ); + errorTest( + 'S5: strict scope validation visits the catch body', + '{{#try}}body{{catch as |e|}}{{e.message}}{{missingCatch}}{{/try}}', + 'Attempted to resolve a value in a strict mode template, but that value was not in scope: missingCatch', + { strictMode: true } + ); +}); + +QUnit.module('[try-keyword-compiler] review', () => { + for (let strictMode of [false, true]) { + let mode = strictMode ? 'strict' : 'loose'; + for (let [rule, callee, clause, message] of [ + ['E3', 'try', '{{else}}', '{{#try}} does not support {{else}}; use {{catch}}'], + [ + 'E4', + 'try', + '{{catch as |e r extra|}}', + '{{catch}} accepts at most two block params (error and retry), received 3', + ], + ['E5', 'if true', '{{catch}}', '{{catch}} is only valid on {{#try}}'], + ] as const) { + QUnit.test(`${rule} ${mode}: diagnostic span includes the offending clause`, (assert) => { + for (let fallback of ['\n unrelated fallback', '']) { + let closing = callee.split(' ')[0]; + let source = `{{#${callee}}}\n body\n ${clause}${fallback}{{/${closing}}}`; + let diagnostic: GlimmerSyntaxError | undefined; + assert.throws( + () => compile(source, { strictMode }), + (error: unknown) => { + if (error instanceof Error && error.message.includes(message)) { + diagnostic = error as GlimmerSyntaxError; + return true; + } + return false; + }, + `${fallback ? 'populated' : 'empty'} inverse has the expected diagnostic` + ); + let includesClause = diagnostic?.location?.asString().includes(clause) ?? false; + assert.true( + includesClause, + 'highlighted source includes the clause, not just its fallback body' + ); + let start = diagnostic?.location?.loc.start; + let startsBeforeClause = + start !== undefined && (start.line < 3 || (start.line === 3 && start.column <= 2)); + assert.true( + startsBeforeClause, + 'the diagnostic starts at or before the offending clause' + ); + } + }); + } + } +}); diff --git a/packages/@glimmer/constants/lib/syscall-ops.ts b/packages/@glimmer/constants/lib/syscall-ops.ts index cc259215535..75c82db59ba 100644 --- a/packages/@glimmer/constants/lib/syscall-ops.ts +++ b/packages/@glimmer/constants/lib/syscall-ops.ts @@ -52,6 +52,7 @@ import type { VmJumpUnless, VmLoad, VmLog, + VmTryEnter, VmMain, VmModifier, VmNot, @@ -183,7 +184,8 @@ export const VM_IF_INLINE_OP = 109 satisfies VmIfInline; export const VM_NOT_OP = 110 satisfies VmNot; export const VM_GET_DYNAMIC_VAR_OP = 111 satisfies VmGetDynamicVar; export const VM_LOG_OP = 112 satisfies VmLog; -export const VM_SYSCALL_SIZE = 113 satisfies VmSize; +export const VM_TRY_ENTER_OP = 113 satisfies VmTryEnter; +export const VM_SYSCALL_SIZE = 114 satisfies VmSize; export function isOp(value: number): value is VmOp { return value >= 16; diff --git a/packages/@glimmer/debug/lib/opcode-metadata.ts b/packages/@glimmer/debug/lib/opcode-metadata.ts index e44d37c06bf..a1bd978301d 100644 --- a/packages/@glimmer/debug/lib/opcode-metadata.ts +++ b/packages/@glimmer/debug/lib/opcode-metadata.ts @@ -96,6 +96,7 @@ import { VM_SPREAD_BLOCK_OP, VM_STATIC_ATTR_OP, VM_SYSCALL_SIZE, + VM_TRY_ENTER_OP, VM_TEXT_OP, VM_TO_BOOLEAN_OP, VM_VIRTUAL_ROOT_SCOPE_OP, @@ -531,6 +532,13 @@ if (LOCAL_DEBUG) { ops: ['args:imm/u32'], }; + METADATA[VM_TRY_ENTER_OP] = { + name: 'TryEnter', + mnemonic: 'try_start', + stackChange: 0, + ops: ['to:instruction/relative'], + }; + METADATA[VM_EXIT_OP] = { name: 'Exit', mnemonic: 'blk_end', diff --git a/packages/@glimmer/interfaces/lib/compile/wire-format/api.d.ts b/packages/@glimmer/interfaces/lib/compile/wire-format/api.d.ts index 9146a5ee4ed..5f43b0f7895 100644 --- a/packages/@glimmer/interfaces/lib/compile/wire-format/api.d.ts +++ b/packages/@glimmer/interfaces/lib/compile/wire-format/api.d.ts @@ -42,6 +42,7 @@ import type { TrustingAppendOpcode, TrustingComponentAttrOpcode, TrustingDynamicAttrOpcode, + TryOpcode, UndefinedOpcode, WithDynamicVarsOpcode, YieldOpcode, @@ -282,6 +283,16 @@ export namespace Statements { export type Let = [op: LetOpcode, positional: Core.Params, block: SerializedInlineBlock]; + /** + * `{{#try}} block {{catch as |error retry|}} catchBlock {{/try}}`. The catch + * block is `null` when the template has no `{{catch}}`. + */ + export type Try = [ + op: TryOpcode, + block: SerializedInlineBlock, + catchBlock: Nullable, + ]; + export type WithDynamicVars = [ op: WithDynamicVarsOpcode, args: Core.Hash, @@ -317,6 +328,7 @@ export namespace Statements { | InElement | If | Each + | Try | Let | WithDynamicVars | InvokeComponent; diff --git a/packages/@glimmer/interfaces/lib/compile/wire-format/opcodes.d.ts b/packages/@glimmer/interfaces/lib/compile/wire-format/opcodes.d.ts index d2e21f7fba4..956c8c35137 100644 --- a/packages/@glimmer/interfaces/lib/compile/wire-format/opcodes.d.ts +++ b/packages/@glimmer/interfaces/lib/compile/wire-format/opcodes.d.ts @@ -48,6 +48,7 @@ export type GetFreeAsComponentHeadOpcode = 39; export type InElementOpcode = 40; export type IfOpcode = 41; export type EachOpcode = 42; +export type TryOpcode = 43; export type LetOpcode = 44; export type WithDynamicVarsOpcode = 45; export type InvokeComponentOpcode = 46; diff --git a/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts b/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts index 7f5bcc2c9ec..8c7dc8c8c82 100644 --- a/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/debug-render-tree.d.ts @@ -46,4 +46,8 @@ export interface DebugRenderTree { commit(): void; capture(): CapturedRenderNode[]; + + getDepth(): number; + + rollbackTo(depth: number): void; } diff --git a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts index c47f1c98abb..798f917c5ad 100644 --- a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts @@ -30,6 +30,11 @@ export type ComponentInstanceWithCreate = ComponentInstance< WithCreateInstance >; +/** An opaque handle for one `{{#try}}` attempt's queued lifecycle work. */ +export interface RenderAttempt { + readonly entries: unknown[]; +} + export interface Environment { [TransactionSymbol]: Nullable; @@ -42,6 +47,18 @@ export interface Environment { begin(): void; commit(): void; + /** + * Scopes the lifecycle work queued by one `{{#try}}` attempt, so a failed + * attempt can drop it. Returns `null` outside a transaction. + */ + beginAttempt(): Nullable; + commitAttempt(attempt: Nullable): void; + abortAttempt(attempt: Nullable): void; + + /** Brackets an independent root render, whose work no enclosing attempt owns. */ + beginRootRender(): void; + endRootRender(): void; + getDOM(): GlimmerTreeChanges; getAppendOperations(): GlimmerTreeConstruction; diff --git a/packages/@glimmer/interfaces/lib/vm-opcodes.d.ts b/packages/@glimmer/interfaces/lib/vm-opcodes.d.ts index 47dd693e94d..8c0f608aca8 100644 --- a/packages/@glimmer/interfaces/lib/vm-opcodes.d.ts +++ b/packages/@glimmer/interfaces/lib/vm-opcodes.d.ts @@ -109,7 +109,8 @@ export type VmIfInline = 109; export type VmNot = 110; export type VmGetDynamicVar = 111; export type VmLog = 112; -export type VmSize = 113; +export type VmTryEnter = 113; +export type VmSize = 114; export type VmOp = | VmHelper @@ -202,6 +203,7 @@ export type VmOp = | VmIfInline | VmNot | VmGetDynamicVar - | VmLog; + | VmLog + | VmTryEnter; export type SomeVmOp = VmOp | VmMachineOp; diff --git a/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/conditional.ts b/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/conditional.ts index ac7a8ce196a..31c36ba8cc3 100644 --- a/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/conditional.ts +++ b/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/conditional.ts @@ -4,6 +4,7 @@ import { VM_JUMP_EQ_OP, VM_JUMP_UNLESS_OP, VM_POP_OP, + VM_TRY_ENTER_OP, } from '@glimmer/constants/lib/syscall-ops'; import { VM_JUMP_OP, @@ -225,3 +226,37 @@ export function ReplayableIf( } }); } + +/** + * Compiles `{{#try}}`. `VM_TRY_ENTER_OP` pushes `[error, retry, hasError]`, + * captures those three references together with the code that follows as a + * closure, renders the closure in a guarded sub-VM and jumps to `END`. The + * main VM never runs the body: every attempt (the first render, a retry, the + * fallback) is a sub-VM that starts at the body with the three references on + * its stack and stops at the `RETURN`, because a restored VM has `$ra = -1`. + * + * The sub-VM's caller pushes the block and the updating list that `VM_EXIT_OP` + * pops, mirroring how `TryOpcode` replays a `Replayable` body. + */ +export function TryBlock(op: PushStatementOp, tryBody: () => void, catchBody: () => void): void { + op(HighLevelBuilderOpcodes.StartLabels); + op(VM_TRY_ENTER_OP, labelOperand('END')); + + // `hasError` is not constant, so this installs an `Assert` in the boundary's + // own updating frame: when it flips, the boundary re-renders the whole body. + op(VM_JUMP_UNLESS_OP, labelOperand('TRY')); + + // Stack: `[error, retry]`. + catchBody(); + op(VM_JUMP_OP, labelOperand('FINALLY')); + + op(HighLevelBuilderOpcodes.Label, 'TRY'); + tryBody(); + + op(HighLevelBuilderOpcodes.Label, 'FINALLY'); + op(VM_EXIT_OP); + op(VM_RETURN_OP); + + op(HighLevelBuilderOpcodes.Label, 'END'); + op(HighLevelBuilderOpcodes.StopLabels); +} diff --git a/packages/@glimmer/opcode-compiler/lib/syntax/statements.ts b/packages/@glimmer/opcode-compiler/lib/syntax/statements.ts index cfafe8dd329..0e405f32e39 100644 --- a/packages/@glimmer/opcode-compiler/lib/syntax/statements.ts +++ b/packages/@glimmer/opcode-compiler/lib/syntax/statements.ts @@ -56,7 +56,12 @@ import { InvokeDynamicComponent, InvokeNonStaticComponent, } from '../opcode-builder/helpers/components'; -import { Replayable, ReplayableIf, SwitchCases } from '../opcode-builder/helpers/conditional'; +import { + Replayable, + ReplayableIf, + SwitchCases, + TryBlock, +} from '../opcode-builder/helpers/conditional'; import { expr } from '../opcode-builder/helpers/expr'; import { isGetFreeComponent, @@ -318,6 +323,18 @@ STATEMENTS.add(SexpOpcodes.If, (op, [, condition, block, inverse]) => ) ); +STATEMENTS.add(SexpOpcodes.Try, (op, [, block, catchBlock]) => + TryBlock( + op, + () => InvokeStaticBlock(op, block), + () => { + if (catchBlock) { + InvokeStaticBlockWithStack(op, catchBlock, 2); + } + } + ) +); + STATEMENTS.add(SexpOpcodes.Each, (op, [, value, key, block, inverse]) => Replayable( op, diff --git a/packages/@glimmer/runtime/lib/bootstrap.ts b/packages/@glimmer/runtime/lib/bootstrap.ts index 32b12fec47c..60ca964a83b 100644 --- a/packages/@glimmer/runtime/lib/bootstrap.ts +++ b/packages/@glimmer/runtime/lib/bootstrap.ts @@ -6,3 +6,4 @@ import './compiled/opcodes/debugger'; import './compiled/opcodes/dom'; import './compiled/opcodes/vm'; import './compiled/opcodes/lists'; +import './compiled/opcodes/try'; 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/compiled/opcodes/try.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/try.ts new file mode 100644 index 00000000000..c350145c78d --- /dev/null +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/try.ts @@ -0,0 +1,47 @@ +import { VM_TRY_ENTER_OP } from '@glimmer/constants/lib/syscall-ops'; + +import { APPEND_OPCODES } from '../../opcodes'; +import { ResettableBlockImpl } from '../../vm/element-builder'; +import { TryState } from '../../vm/try-state'; +import { TryBlockOpcode } from '../../vm/update'; + +/** + * Enters a `{{#try}}`. The stack contract, shared with `TryBlock` in the + * opcode compiler: push `[error, retry, hasError]`, capture them with the body + * that follows as the closure, and pop them again, so the main VM's stack is + * unchanged. The body only ever runs in sub-VMs restored from that closure. + * The boundary's block is linked into the parent's bounds but never pushed on + * the parent's tree, so nothing here needs popping, on success or on a throw. + */ +APPEND_OPCODES.add(VM_TRY_ENTER_OP, (vm, { op1: end }) => { + let state = new TryState(); + let { stack } = vm; + + stack.push(state.errorRef); + stack.push(state.retryRef); + stack.push(state.hasErrorRef); + + let closure = vm.capture(3); + + stack.pop(3); + + let tree = vm.tree(); + let block = new ResettableBlockImpl(tree.element); + tree.didAppendBounds(block); + + let opcode = new TryBlockOpcode(closure, vm.context, block, state); + + // Before rendering, so a fallback that throws still leaves the boundary + // reachable from whatever destroys the parent. + vm.associateDestroyable(opcode); + + // A cache group of its own, like a component's: an update skips the whole + // boundary while nothing it read changed. Its `hasError` and failed-attempt + // tags are consumed into the group, so a retry still reaches it. + vm.beginCacheGroup('{{#try}}'); + opcode.renderInitial(tree.nextSibling); + vm.updateWith(opcode); + vm.commitCacheGroup(); + + vm.lowlevel.goto(end); +}); diff --git a/packages/@glimmer/runtime/lib/debug-render-tree.ts b/packages/@glimmer/runtime/lib/debug-render-tree.ts index a1994ffbe27..4635967cf96 100644 --- a/packages/@glimmer/runtime/lib/debug-render-tree.ts +++ b/packages/@glimmer/runtime/lib/debug-render-tree.ts @@ -106,6 +106,17 @@ export default class DebugRenderTreeImpl< return this.captureRefs(this.roots); } + getDepth(): number { + return this.stack.size; + } + + /** Drops nodes a failed `{{#try}}` attempt began but never finished. */ + rollbackTo(depth: number): void { + while (this.stack.size > depth) { + this.stack.pop(); + } + } + private reset(): void { if (this.stack.size !== 0) { // We probably encountered an error during the rendering loop. This will diff --git a/packages/@glimmer/runtime/lib/environment.ts b/packages/@glimmer/runtime/lib/environment.ts index bafbefe2f82..eb9bf8de495 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() { @@ -173,6 +237,26 @@ export class EnvironmentImpl implements Environment { } } + beginAttempt(): Nullable { + return this[TRANSACTION]?.beginAttempt() ?? null; + } + + commitAttempt(attempt: Nullable) { + if (attempt) this[TRANSACTION]?.commitAttempt(attempt as RenderAttemptImpl); + } + + abortAttempt(attempt: Nullable) { + if (attempt) this[TRANSACTION]?.abortAttempt(attempt as RenderAttemptImpl); + } + + beginRootRender() { + this[TRANSACTION]?.beginRootRender(); + } + + endRootRender() { + this[TRANSACTION]?.endRootRender(); + } + commit() { let transaction = this.transaction; this[TRANSACTION] = null; diff --git a/packages/@glimmer/runtime/lib/render.ts b/packages/@glimmer/runtime/lib/render.ts index 7061473ebe7..4f2251ddb49 100644 --- a/packages/@glimmer/runtime/lib/render.ts +++ b/packages/@glimmer/runtime/lib/render.ts @@ -21,6 +21,7 @@ import { debug } from '@glimmer/validator/lib/debug'; import { inTransaction } from './environment'; import { DynamicScopeImpl } from './scope'; import { VM } from './vm/append'; +import { beginRootBoundaryBarrier, endRootBoundaryBarrier } from './vm/update'; class TemplateIteratorImpl implements TemplateIterator { constructor(private vm: VM) {} @@ -41,7 +42,17 @@ class TemplateIteratorImpl implements TemplateIterator { export function renderSync(env: Environment, iterator: TemplateIterator): RenderResult { let result: RenderResult; - inTransaction(env, () => (result = iterator.sync())); + inTransaction(env, () => { + env.beginRootRender(); + beginRootBoundaryBarrier(); + + try { + result = iterator.sync(); + } finally { + endRootBoundaryBarrier(); + env.endRootRender(); + } + }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme return result!; diff --git a/packages/@glimmer/runtime/lib/vm/append.ts b/packages/@glimmer/runtime/lib/vm/append.ts index b27b704785b..cd5631a7207 100644 --- a/packages/@glimmer/runtime/lib/vm/append.ts +++ b/packages/@glimmer/runtime/lib/vm/append.ts @@ -10,6 +10,8 @@ import type { DynamicScope, Environment, EvaluationContext, + ModifierInstance, + Nullable, Owner, Program, ProgramConstants, @@ -25,7 +27,7 @@ import type { Reference } from '@glimmer/reference/lib/reference'; import type { MachineRegister, Register, SyscallRegister } from '@glimmer/vm/lib/registers'; import { dev, expect } from '@glimmer/debug-util/lib/platform-utils'; import { unwrapHandle } from '@glimmer/debug-util/lib/template'; -import { associateDestroyableChild } from '@glimmer/destroyable'; +import { associateDestroyableChild, destroy } from '@glimmer/destroyable'; import { DESTROYABLE_META_KEY } from '@glimmer/util/lib/destroyable-key'; import { assertGlobalContextWasSet } from '@glimmer/global-context'; import { LOCAL_DEBUG, LOCAL_TRACE_LOGGING } from '@glimmer/local-debug-flags'; @@ -34,7 +36,12 @@ import { UNDEFINED_REFERENCE } from '@glimmer/reference/lib/reference'; import { reverse } from '@glimmer/util/lib/array-utils'; import { StackImpl as Stack } from '@glimmer/util/lib/collections'; import { LOCAL_LOGGER } from '@glimmer/util'; -import { beginTrackFrame, endTrackFrame, resetTracking } from '@glimmer/validator/lib/tracking'; +import { + beginTrackFrame, + endTrackFrame, + isInErrorBoundary, + resetTracking, +} from '@glimmer/validator/lib/tracking'; import { $pc, isLowLevelRegister } from '@glimmer/vm/lib/registers'; import type { ScopeOptions } from '../scope'; @@ -738,8 +745,71 @@ export class VM { /// EXECUTION + /** + * Runs one `{{#try}}` attempt. If it throws, the attempt's destroyables are + * destroyed (nothing else will ever reach them) and its open blocks are left + * alone: the boundary removes the attempt's DOM by range. + */ + executeAttempt(initialize?: (vm: this) => void): RenderResult { + let completed = false; + + try { + let result = this._execute(initialize); + completed = true; + return result; + } finally { + if (!completed) { + this.#releaseFailedRender(); + } + } + } + + /** + * Modifiers created by an 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); + } + + /** Releases what a render that threw created, since nothing else will reach it. */ + #releaseFailedRender(): void { + destroy(this.#stacks.drop); + + 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) { + if (DEBUG || isInErrorBoundary()) { let hasErrored = true; try { let value = this._execute(initialize); @@ -750,7 +820,12 @@ export class VM { return value; } finally { - if (hasErrored) { + if (hasErrored && isInErrorBoundary()) { + // A `{{#try}}` around this render recovers from the error: it keeps + // the tracking state and removes the DOM, so only the destroyables + // this render created need releasing here. + this.#releaseFailedRender(); + } else if (hasErrored) { // If any existing blocks are open, due to an error or something like // that, we need to close them all and clean things up properly. let elements = this.tree(); diff --git a/packages/@glimmer/runtime/lib/vm/element-builder.ts b/packages/@glimmer/runtime/lib/vm/element-builder.ts index 718646dd5bc..9113043ff0d 100644 --- a/packages/@glimmer/runtime/lib/vm/element-builder.ts +++ b/packages/@glimmer/runtime/lib/vm/element-builder.ts @@ -108,6 +108,22 @@ export class NewTreeBuilder implements TreeBuilder { return stack; } + /** + * Starts rendering into an empty `block`, inserting before `nextSibling`. + * Unlike `resume`, it never clears the block, so it is safe on a block + * whose previous content was already removed by hand. + */ + static beginBlock( + env: Environment, + block: ResettableBlock, + nextSibling: Nullable + ): NewTreeBuilder { + let stack = new this(env, block.parentElement(), nextSibling).initialize(); + stack.pushBlock(block); + + return stack; + } + constructor(env: Environment, parentNode: SimpleElement, nextSibling: Nullable) { this.pushElement(parentNode, nextSibling); this.env = env; @@ -250,7 +266,7 @@ export class NewTreeBuilder implements TreeBuilder { } } - let block = new RemoteBlock(element); + let block = new RemoteBlock(element, insertBefore ?? null); return this.pushBlock(block, true); } @@ -259,6 +275,7 @@ export class NewTreeBuilder implements TreeBuilder { const block = this.popBlock(); assert(block instanceof RemoteBlock, '[BUG] expecting a RemoteBlock'); this.popElement(); + block.didRender(); return block; } @@ -475,12 +492,30 @@ export class AppendingBlockImpl implements AppendingBlock { } export class RemoteBlock extends AppendingBlockImpl { - constructor(parent: SimpleElement) { + /** + * The nodes just outside this block's content in the destination, kept so a + * `{{#try}}` can remove the content by range even when the bounds of a + * failed render are only partly initialized. `#right` is `null` at the end. + */ + #left: Nullable; + #right: Nullable; + + /** Set once a `{{#try}}` removed the content, so destruction does not repeat it. */ + #cleared = false; + + constructor(parent: SimpleElement, insertBefore: Nullable = null) { super(parent); + this.#right = insertBefore; + this.#left = insertBefore ? insertBefore.previousSibling : parent.lastChild; + setLocalDebugType('block:remote', this); registerDestructor(this, () => { + if (this.#cleared) { + return; + } + // In general, you only need to clear the root of a hierarchy, and should never // need to clear any child nodes. This is an important constraint that gives us // a strong guarantee that clearing a subtree is a single DOM operation. @@ -510,6 +545,66 @@ export class RemoteBlock extends AppendingBlockImpl { } }); } + + /** After a complete render the bounds are valid, so re-anchor on them. */ + didRender(): void { + this.#left = this.firstNode().previousSibling; + this.#right = this.lastNode().nextSibling; + } + + /** + * Removes this block's content synchronously, by range, for a `{{#try}}` + * that is discarding it. Uses the block's own bounds when they are complete, + * so later content in a shared destination survives, and the anchors saved + * at the last complete render when a failed render left them partial. Does + * nothing when the left anchor was itself removed, rather than guess at a + * range that might hold unrelated content. + */ + clearForAbort(): void { + if (this.#cleared) { + return; + } + + let parent = this.parentElement(); + let left = this.#left; + let right = this.#right; + + try { + let first = this.firstNode(); + let last = this.lastNode(); + + // An enclosing remote block's range already took this content with it. + if (last.parentNode !== parent) { + this.#cleared = true; + return; + } + + right = last.nextSibling; + + // A nested `{{#in-element}}` without `insertBefore` empties the + // destination, detaching the front of this block's range: clear from + // the saved left anchor instead. + if (first.parentNode === parent) { + left = first.previousSibling; + } + } catch { + // A failed render left the bounds partial: keep the saved anchors. + } + + if (left !== null && left.parentNode !== parent) { + return; + } + + let node = left ? left.nextSibling : parent.firstChild; + + while (node !== null && node !== right) { + let next = node.nextSibling; + parent.removeChild(node); + node = next; + } + + this.#cleared = true; + } } export class ResettableBlockImpl extends AppendingBlockImpl implements ResettableBlock { @@ -528,6 +623,17 @@ export class ResettableBlockImpl extends AppendingBlockImpl implements Resettabl return nextSibling; } + + /** + * Forgets the block's content without touching the DOM. Used after a failed + * render, whose partial DOM was removed by range because its child bounds + * may never have been initialized. + */ + forget(): void { + 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/try-state.ts b/packages/@glimmer/runtime/lib/vm/try-state.ts new file mode 100644 index 00000000000..9f1f917d137 --- /dev/null +++ b/packages/@glimmer/runtime/lib/vm/try-state.ts @@ -0,0 +1,103 @@ +import type { Tag, UpdatableTag } from '@glimmer/interfaces'; +import type { Reference } from '@glimmer/reference/lib/reference'; +import type { Revision } from '@glimmer/validator/lib/validators'; +import { createComputeRef, createConstRef } from '@glimmer/reference/lib/reference'; +import { consumeTag } from '@glimmer/validator/lib/tracking'; +import { + CONSTANT_TAG, + createUpdatableTag, + DIRTY_TAG, + validateTag, + valueForTag, +} from '@glimmer/validator/lib/validators'; + +/** + * The state behind one `{{#try}}`: whether its body failed, with what error, + * and which tracked state the failed attempt read, so it can retry + * automatically when any of that changes. + */ +export class TryState { + readonly errorRef: Reference; + readonly retryRef: Reference; + readonly hasErrorRef: Reference; + + #error: unknown = null; + #hasError = false; + readonly #errorTag: UpdatableTag = createUpdatableTag(); + readonly #hasErrorTag: UpdatableTag = createUpdatableTag(); + + /** Everything the failed attempt consumed before it threw. */ + #failedTag: Tag = CONSTANT_TAG; + + /** Revision of `#failedTag` when the error was caught. */ + #failedRevision: Revision = 0; + + constructor() { + this.errorRef = createComputeRef(() => this.error, null, 'try:error'); + this.retryRef = createConstRef(this.retry, 'try:retry'); + this.hasErrorRef = createComputeRef(() => this.hasError, null, 'try:hasError'); + } + + get error(): unknown { + consumeTag(this.#errorTag); + return this.#error; + } + + get hasError(): boolean { + consumeTag(this.#hasErrorTag); + return this.#hasError; + } + + /** + * Records a caught error. The failed attempt already consumed these tags in + * the same render, so dirtying them skips the backtracking assertion. + */ + setError(error: unknown, failedTag: Tag): void { + this.#error = error; + this.#hasError = true; + DIRTY_TAG(this.#errorTag, true); + DIRTY_TAG(this.#hasErrorTag, true); + + // Snapshot after dirtying: the failed attempt read `hasError` itself, and + // that change must not count as a reason to retry. + this.#failedTag = failedTag; + this.#failedRevision = valueForTag(failedTag); + } + + /** + * Consumes what the enclosing cache group must revalidate on: the error + * state itself and, while failed, everything the failed attempt read. + */ + consumeTags(): void { + consumeTag(this.#hasErrorTag); + + if (this.#hasError) { + consumeTag(this.#failedTag); + } + } + + /** + * True when failed and state read by the failed attempt has changed since. + * Clears the error so the caller can render the body again. + */ + shouldRetry(): boolean { + if (!this.#hasError || validateTag(this.#failedTag, this.#failedRevision)) { + return false; + } + + this.#clear(true); + return true; + } + + retry = (): void => { + this.#clear(false); + }; + + #clear(duringRender: boolean): void { + this.#error = null; + this.#hasError = false; + this.#failedTag = CONSTANT_TAG; + DIRTY_TAG(this.#errorTag, duringRender); + DIRTY_TAG(this.#hasErrorTag, duringRender); + } +} diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index ca239649fd3..7fcf874c560 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -8,29 +8,47 @@ import type { ExceptionHandler, GlimmerTreeChanges, Nullable, + RenderAttempt, ResettableBlock, Scope, SimpleComment, + SimpleNode, + Tag, UpdatingOpcode, UpdatingVM as IUpdatingVM, } from '@glimmer/interfaces'; import type { OpaqueIterationItem, OpaqueIterator } from '@glimmer/reference/lib/iterable'; import type { Reference } from '@glimmer/reference/lib/reference'; import { expect, unwrap } from '@glimmer/debug-util/lib/platform-utils'; -import { associateDestroyableChild, destroy, destroyChildren } from '@glimmer/destroyable'; +import { + associateDestroyableChild, + destroy, + destroyChildren, + registerDestructor, +} from '@glimmer/destroyable'; import { DESTROYABLE_META_KEY } from '@glimmer/util/lib/destroyable-key'; import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; import { updateRef, valueForRef } from '@glimmer/reference/lib/reference'; import { logStep } from '@glimmer/util/lib/debug-steps'; import { StackImpl as Stack } from '@glimmer/util/lib/collections'; import { debug } from '@glimmer/validator/lib/debug'; -import { resetTracking } from '@glimmer/validator/lib/tracking'; +import { + beginErrorBoundary, + beginTrackFrame, + consumeTag, + endErrorBoundary, + endTrackFrame, + getTrackingDepth, + resetTracking, + unwindTrackingTo, +} from '@glimmer/validator/lib/tracking'; import type { Closure } from './append'; -import type { AppendingBlockList } from './element-builder'; +import type { AppendingBlockList, ResettableBlockImpl } from './element-builder'; +import type { TryState } from './try-state'; 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; @@ -39,6 +57,12 @@ export class UpdatingVM implements IUpdatingVM { private frameStack: Stack = new Stack(); + /** Open `{{#try}}` frames. While there are none, opcodes run unguarded. */ + #boundaryFrames = 0; + + /** Tracking depth when this update started, restored if an error escapes a boundary. */ + #startTrackingDepth = 0; + constructor(env: Environment, { alwaysRevalidate = false }) { this.env = env; this.dom = env.getDOM(); @@ -72,18 +96,100 @@ export class UpdatingVM implements IUpdatingVM { private _execute(opcodes: UpdatingOpcode[], handler: ExceptionHandler) { let { frameStack } = this; + this.#startTrackingDepth = getTrackingDepth(); this.try(opcodes, handler); - while (!frameStack.isEmpty()) { - let opcode = this.frame.nextStatement(); + try { + while (!frameStack.isEmpty()) { + let opcode = this.frame.nextStatement(); + + if (opcode === undefined) { + this.#popFrame(true); + continue; + } + + if (this.#boundaryFrames === 0) { + opcode.evaluate(this); + continue; + } + + let trackingDepth = getTrackingDepth(); + + try { + opcode.evaluate(this); + } catch (error) { + // Keep what the failed opcode read; the boundary that recovers + // collects it from the enclosing frames. + consumeTag(unwindTrackingTo(trackingDepth)); + this.#recover(error); + } + } + } finally { + // An error nobody caught abandons the remaining frames. + while (!frameStack.isEmpty()) { + let { boundary } = this.frame; + this.frameStack.pop(); + + if (boundary) { + this.#boundaryFrames--; + endErrorBoundary(); + boundary.exit(); + boundary.didAbandonChildren(); + } + } + } + } + + /** + * Hands a JavaScript error to the innermost `{{#try}}` rendering its body. + * A boundary showing its fallback does not catch, and one whose fallback + * fails while recovering passes the new error outward. + */ + #recover(error: unknown) { + let { frameStack } = this; - if (opcode === undefined) { - frameStack.pop(); - continue; + while (!frameStack.isEmpty()) { + let boundary = this.frame.boundary; + + if (boundary?.isProtecting) { + try { + boundary.handleCaughtError(error); + this.#popFrame(false); + return; + } catch (fallbackError) { + error = fallbackError; + } } - opcode.evaluate(this); + this.#popFrame(false); + } + + // Nothing caught it. Close the frames the passed boundaries and their + // children left open, as recovery would have. In DEBUG, `execute` resets + // tracking itself and needs the open frames to label its log. + if (!DEBUG) { + unwindTrackingTo(this.#startTrackingDepth); + } + + throw error; + } + + #popFrame(completed: boolean) { + let frame = this.frame; + + if (frame.boundary) { + this.#boundaryFrames--; + endErrorBoundary(); + frame.boundary.exit(); + + if (completed) { + frame.boundary.didCompleteChildren(); + } else { + frame.boundary.didAbandonChildren(); + } } + + this.frameStack.pop(); } private get frame() { @@ -95,12 +201,20 @@ export class UpdatingVM implements IUpdatingVM { } try(ops: UpdatingOpcode[], handler: Nullable) { - this.frameStack.push(new UpdatingVMFrame(ops, handler)); + this.frameStack.push(new UpdatingVMFrame(ops, handler, null)); + } + + /** Pushes the frame for a `{{#try}}`'s children. */ + tryBoundary(boundary: TryBlockOpcode) { + this.#boundaryFrames++; + beginErrorBoundary(); + boundary.enter(); + this.frameStack.push(new UpdatingVMFrame(boundary.children, boundary, boundary)); } throw() { this.frame.handleException(); - this.frameStack.pop(); + this.#popFrame(false); } } @@ -177,6 +291,277 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { } } +/** + * 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)); +} + +/** + * The updating side of a `{{#try}}`. Every attempt, whether the body or the + * fallback, renders in a sub-VM from the same closure, so a throw unwinds only + * the attempt. A failed attempt is removed by DOM range between two nodes that + * lie outside the boundary (`#left` and `#right`), because the attempt's own + * bounds may never have been initialized. + */ +export class TryBlockOpcode extends TryOpcode { + override type = 'try-block'; + + declare protected bounds: ResettableBlockImpl; + + /** The node before the boundary's content, or `null` at the parent's start. */ + #left: Nullable = null; + + /** The node after the boundary's content, or `null` at the parent's end. */ + #right: Nullable = null; + + #trackingDepth = 0; + #renderTreeDepth = 0; + + /** Whether the rendered content is the body, whose errors this boundary catches. */ + #protecting = false; + + /** Lifecycle work queued by the children while they update. */ + #childrenAttempt: Nullable = null; + + /** + * `{{#in-element}}` blocks rendered under this boundary. Their destructors + * run later, after a fallback may already render into the same destination, + * so the boundary removes their content itself when it discards them. + */ + #remoteBlocks = new Set(); + + constructor( + state: Closure, + context: EvaluationContext, + bounds: ResettableBlockImpl, + private tryState: TryState + ) { + super(state, context, bounds, []); + } + + get isProtecting(): boolean { + return this.#protecting; + } + + /** The first render, inserting before `nextSibling` in the parent. */ + renderInitial(nextSibling: Nullable): void { + this.#right = nextSibling; + this.#left = nextSibling ? nextSibling.previousSibling : this.bounds.parentElement().lastChild; + this.#saveDepths(); + this.#render(); + this.tryState.consumeTags(); + } + + override evaluate(vm: UpdatingVM): void { + // Taken before the children run: they may add content at either edge. + this.#left = this.bounds.firstNode().previousSibling; + this.#right = this.bounds.lastNode().nextSibling; + this.#saveDepths(); + this.tryState.consumeTags(); + + if (this.tryState.shouldRetry()) { + this.#rerender(); + return; + } + + this.#protecting = !this.tryState.hasError; + + // The children's frame: if one of them throws, `handleCaughtError` turns + // what they read into the retry tag. + beginTrackFrame(); + this.#childrenAttempt = this.context.env.beginAttempt(); + vm.tryBoundary(this); + } + + didCompleteChildren(): void { + this.context.env.commitAttempt(this.#childrenAttempt); + consumeTag(endTrackFrame()); + } + + /** The children's frame was unwound before they finished. */ + didAbandonChildren(): void { + this.context.env.abortAttempt(this.#childrenAttempt); + } + + /** `hasError` flipped, from `retry()`: render the other branch. */ + override handleException(): void { + this.didAbandonChildren(); + unwindTrackingTo(this.#trackingDepth); + this.#rerender(); + } + + /** A child threw while the body was updating. */ + handleCaughtError(error: unknown): void { + this.didAbandonChildren(); + let failedTag = unwindTrackingTo(this.#trackingDepth); + this.#abort(); + this.#fail(error, failedTag); + this.tryState.consumeTags(); + } + + 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); + } + + #clearRemoteBlocks(): void { + for (let block of this.#remoteBlocks) { + block.clearForAbort(); + } + + this.#remoteBlocks.clear(); + } + + #saveDepths(): void { + this.#trackingDepth = getTrackingDepth(); + this.#renderTreeDepth = this.context.env.debugRenderTree?.getDepth() ?? 0; + } + + #rerender(): void { + this.#clearRemoteBlocks(); + destroyChildren(this); + this.#clear(); + this.bounds.forget(); + this.#render(); + this.tryState.consumeTags(); + } + + /** Renders whichever branch `hasError` selects, falling back if the body throws. */ + #render(): void { + let depth = getTrackingDepth(); + + try { + this.#attempt(); + } catch (error) { + let failedTag = unwindTrackingTo(depth); + this.#abort(); + + if (!this.#protecting) { + // The fallback itself failed: keep what it read for the boundary that + // catches the error, so that boundary retries when any of it changes. + consumeTag(failedTag); + throw error; + } + + this.#fail(error, failedTag); + } + } + + #fail(error: unknown, failedTag: Tag): void { + if (DEBUG) { + // eslint-disable-next-line no-console + console.error('An error was caught by {{#try}}:', error); + } + + this.tryState.setError(error, failedTag); + + let depth = getTrackingDepth(); + + try { + this.#attempt(); + } catch (fallbackError) { + consumeTag(unwindTrackingTo(depth)); + this.#abort(); + throw fallbackError; + } + } + + #attempt(): void { + let { bounds, context } = this; + + this.#protecting = !this.tryState.hasError; + + let attempt = context.env.beginAttempt(); + let completed = false; + + beginErrorBoundary(); + beginTrackFrame(); + this.enter(); + + try { + let tree = NewTreeBuilder.beginBlock(context.env, bounds, this.#right); + let vm = this.state.evaluate(tree); + let children = (this.children = []); + + let result = vm.executeAttempt((vm) => { + vm.updateWith(this); + vm.pushUpdating(children); + }); + + associateDestroyableChild(this, result.drop); + consumeTag(endTrackFrame()); + completed = true; + } finally { + this.exit(); + endErrorBoundary(); + + if (completed) { + context.env.commitAttempt(attempt); + } else { + context.env.abortAttempt(attempt); + } + } + } + + /** Removes a failed attempt: its destroyables, its DOM and its debug nodes. */ + #abort(): void { + this.#clearRemoteBlocks(); + destroyChildren(this); + this.#clear(); + this.context.env.debugRenderTree?.rollbackTo(this.#renderTreeDepth); + this.bounds.forget(); + } + + #clear(): void { + let parent = this.bounds.parentElement(); + let right = this.#right; + let node = this.#left ? this.#left.nextSibling : parent.firstChild; + + while (node !== null && node !== right) { + let next = node.nextSibling; + parent.removeChild(node); + node = next; + } + } +} + export class ListItemOpcode extends TryOpcode { public retained = false; public index = -1; @@ -431,7 +816,8 @@ class UpdatingVMFrame { constructor( private ops: UpdatingOpcode[], - private exceptionHandler: Nullable + private exceptionHandler: Nullable, + readonly boundary: Nullable ) {} goto(index: number) { diff --git a/packages/@glimmer/syntax/lib/generation/printer.ts b/packages/@glimmer/syntax/lib/generation/printer.ts index 3765a55ef8d..b86282ca462 100644 --- a/packages/@glimmer/syntax/lib/generation/printer.ts +++ b/packages/@glimmer/syntax/lib/generation/printer.ts @@ -379,7 +379,14 @@ export default class Printer { if (block.inverse) { if (!block.inverse.chained) { this.buffer += block.inverseStrip.open ? '{{~' : '{{'; - this.buffer += 'else'; + if (block.catch) { + this.buffer += 'catch'; + if (block.inverse.blockParams.length) { + this.BlockParams(block.inverse.blockParams); + } + } else { + this.buffer += 'else'; + } this.buffer += block.inverseStrip.close ? '~}}' : '}}'; } diff --git a/packages/@glimmer/syntax/lib/get-template-locals.ts b/packages/@glimmer/syntax/lib/get-template-locals.ts index b9b2357caf8..3dbc0906c98 100644 --- a/packages/@glimmer/syntax/lib/get-template-locals.ts +++ b/packages/@glimmer/syntax/lib/get-template-locals.ts @@ -1,3 +1,4 @@ +import type WalkerPath from './traversal/path'; import type * as ASTv1 from './v1/api'; import { isKeyword } from './keywords'; @@ -62,6 +63,21 @@ function getPathName( } } +/** + * `try` is only a keyword as the path of a block (`{{#try}}`). Everywhere else + * it is an ordinary name, so the exemption is per occurrence rather than via + * `isKeyword`. + */ +function isTryBlockPath(node: ASTv1.PathExpression, path: WalkerPath) { + return ( + path.parentKey === 'path' && + path.parentNode?.type === 'BlockStatement' && + node.head.type === 'VarHead' && + node.head.name === 'try' && + node.tail.length === 0 + ); +} + /** * Adds tokens to the tokensSet based on their node.type */ @@ -127,7 +143,11 @@ export function getTemplateLocals( }, }, - PathExpression(node) { + PathExpression(node, path) { + if (!options.includeKeywords && isTryBlockPath(node, path)) { + return; + } + addTokens(tokensSet, node, scopedTokens, options); }, }); diff --git a/packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts b/packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts index 89999c57b1b..2a07576cf4e 100644 --- a/packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts +++ b/packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts @@ -206,7 +206,10 @@ export abstract class HandlebarsNodeVisitors extends Parser { } const program = this.Program(repairedBlock.program, blockParams); - const inverse = repairedBlock.inverse ? this.Program(repairedBlock.inverse, []) : null; + const catchParams = + block.catch?.params.map(({ name, loc }) => b.var({ name, loc: this.source.spanFor(loc) })) ?? + []; + const inverse = repairedBlock.inverse ? this.Program(repairedBlock.inverse, catchParams) : null; const node = b.block({ path, @@ -218,6 +221,7 @@ export abstract class HandlebarsNodeVisitors extends Parser { openStrip: block.openStrip, inverseStrip: block.inverseStrip, closeStrip: block.closeStrip, + isCatch: block.catch !== undefined, }); const parentProgram = this.currentElement(); @@ -773,7 +777,11 @@ function repairBlock( let endProgram = source.spanFor(block.program.loc).getEnd(); if (block.inverse && !block.inverse.loc) { - block.inverse.loc = endProgram.collapsed(); + // An empty `{{catch}}` body starts after the catch tag, not where the + // main program ends. + block.inverse.loc = block.catch + ? source.spanFor(block.catch.loc).getEnd().collapsed() + : endProgram.collapsed(); } return block as HBS.BlockStatement; diff --git a/packages/@glimmer/syntax/lib/v1/handlebars-ast.ts b/packages/@glimmer/syntax/lib/v1/handlebars-ast.ts index e06d8048ae7..0aa18592c3e 100644 --- a/packages/@glimmer/syntax/lib/v1/handlebars-ast.ts +++ b/packages/@glimmer/syntax/lib/v1/handlebars-ast.ts @@ -116,6 +116,13 @@ export interface CommonBlock extends CommonNode { export interface BlockStatement extends CommonBlock { type: 'BlockStatement'; + /** Present only when the inverse was written as `{{catch}}` instead of `{{else}}`. */ + catch?: CatchClause; +} + +export interface CatchClause { + loc: SourceLocation; + params: { name: string; loc: SourceLocation }[]; } export interface DecoratorBlock extends CommonBlock { diff --git a/packages/@glimmer/syntax/lib/v1/nodes-v1.ts b/packages/@glimmer/syntax/lib/v1/nodes-v1.ts index baf49864b99..9ebed895977 100644 --- a/packages/@glimmer/syntax/lib/v1/nodes-v1.ts +++ b/packages/@glimmer/syntax/lib/v1/nodes-v1.ts @@ -79,6 +79,13 @@ export interface BlockStatement extends BaseNode { // Printer extension chained?: boolean; + + /** + * Present (and `true`) only when the inverse was written as + * `{{catch as |...|}}` instead of `{{else}}`. Its params live on + * `inverse.params`. + */ + catch?: boolean; } export interface ElementModifierStatement extends BaseNode { diff --git a/packages/@glimmer/syntax/lib/v1/parser-builders.ts b/packages/@glimmer/syntax/lib/v1/parser-builders.ts index b63e817b8c6..cf155d9c507 100644 --- a/packages/@glimmer/syntax/lib/v1/parser-builders.ts +++ b/packages/@glimmer/syntax/lib/v1/parser-builders.ts @@ -105,6 +105,7 @@ class Builders { openStrip = DEFAULT_STRIP, inverseStrip = DEFAULT_STRIP, closeStrip = DEFAULT_STRIP, + isCatch = false, }: { path: ASTv1.PathExpression | ASTv1.SubExpression; params: ASTv1.Expression[]; @@ -115,8 +116,9 @@ class Builders { openStrip?: Optional; inverseStrip?: Optional; closeStrip?: Optional; + isCatch?: Optional; }): ASTv1.BlockStatement { - return { + let node: ASTv1.BlockStatement = { type: 'BlockStatement', path: path, params, @@ -128,6 +130,12 @@ class Builders { inverseStrip, closeStrip, }; + + if (isCatch) { + node.catch = true; + } + + return node; } comment({ value, loc }: { value: string; loc: SourceSpan }): ASTv1.CommentStatement { diff --git a/packages/@glimmer/syntax/lib/v1/public-builders.ts b/packages/@glimmer/syntax/lib/v1/public-builders.ts index b91b75e0de6..8369cc0ad9e 100644 --- a/packages/@glimmer/syntax/lib/v1/public-builders.ts +++ b/packages/@glimmer/syntax/lib/v1/public-builders.ts @@ -61,7 +61,8 @@ function buildBlock( loc?: SourceLocation, openStrip?: ASTv1.StripFlags, inverseStrip?: ASTv1.StripFlags, - closeStrip?: ASTv1.StripFlags + closeStrip?: ASTv1.StripFlags, + isCatch?: boolean ): ASTv1.BlockStatement { let defaultBlock: ASTv1.Block; let elseBlock: Nullable = null; @@ -100,6 +101,7 @@ function buildBlock( openStrip, inverseStrip, closeStrip, + isCatch, }); } diff --git a/packages/@glimmer/syntax/lib/v2/builders.ts b/packages/@glimmer/syntax/lib/v2/builders.ts index 609a3b1cda2..7c7e06db89d 100644 --- a/packages/@glimmer/syntax/lib/v2/builders.ts +++ b/packages/@glimmer/syntax/lib/v2/builders.ts @@ -295,11 +295,13 @@ export class Builder { { program, inverse = null, + inverseName = 'else', ...call }: { symbols: SymbolTable; program: ASTv2.Block; inverse?: ASTv2.Block | null; + inverseName?: 'else' | 'catch'; } & CallParts, loc: SourceSpan ): ASTv2.InvokeBlock { @@ -309,7 +311,7 @@ export class Builder { ]; if (inverse) { blocksLoc = blocksLoc.extend(inverse.loc); - blocks.push(this.namedBlock(SourceSlice.synthetic('else'), inverse, inverse.loc)); + blocks.push(this.namedBlock(SourceSlice.synthetic(inverseName), inverse, inverse.loc)); } return new ASTv2.InvokeBlock({ diff --git a/packages/@glimmer/syntax/lib/v2/normalize.ts b/packages/@glimmer/syntax/lib/v2/normalize.ts index 0b24ecaecc2..cd628b6d3aa 100644 --- a/packages/@glimmer/syntax/lib/v2/normalize.ts +++ b/packages/@glimmer/syntax/lib/v2/normalize.ts @@ -517,6 +517,7 @@ class StatementNormalizer { symbols: this.block.table, program: this.Block(program), inverse: inverse ? this.Block(inverse) : null, + inverseName: block.catch ? ('catch' as const) : ('else' as const), }, callParts ), diff --git a/packages/@glimmer/syntax/test/try-catch-test.ts b/packages/@glimmer/syntax/test/try-catch-test.ts new file mode 100644 index 00000000000..a580924984e --- /dev/null +++ b/packages/@glimmer/syntax/test/try-catch-test.ts @@ -0,0 +1,322 @@ +import type { ASTv1 } from '@glimmer/syntax'; +import { + builders, + getTemplateLocals, + isKeyword, + normalize, + preprocess, + print, + src, + traverse, +} from '@glimmer/syntax'; + +/** Narrow an existing AST node without requiring the new flag in baseline types. */ +function blockFor(source: string): ASTv1.BlockStatement & { catch?: boolean } { + const node = preprocess(source).body[0]; + if (node?.type !== 'BlockStatement') { + throw new Error('Expected a BlockStatement'); + } + return node; +} + +/** Derive expected source coordinates rather than hard-counting columns. */ +function position(source: string, offset: number) { + const lines = source.slice(0, offset).split('\n'); + return { line: lines.length, column: (lines.at(-1) ?? '').length }; +} + +/** Build an expected span from offsets into the original template. */ +function span(source: string, start: number, end: number) { + return { start: position(source, start), end: position(source, end) }; +} + +QUnit.module('[try-catch-syntax] ASTv1 and locations', () => { + QUnit.test('bare catch marks the block and creates an inverse', (assert) => { + const block = blockFor('{{#try}}A{{catch}}B{{/try}}'); + assert.true(block.catch); + assert.true(Object.hasOwn(block, 'catch')); + assert.deepEqual( + block.program.body.map((node) => node.type), + ['TextNode'] + ); + assert.strictEqual(print(block.program), 'A'); + assert.strictEqual(block.inverse?.type, 'Block'); + if (block.inverse) { + assert.strictEqual(print(block.inverse), 'B'); + } + assert.deepEqual(block.inverse?.params, []); + assert.deepEqual(block.inverse?.blockParams, []); + }); + + QUnit.test('catch params are VarHeads with exact multiline identifier spans', (assert) => { + const source = + '{{#try error retry=error}}error retry\n{{~catch as |\n error retry\n|~}}{{error.message}}{{retry}}{{/try}}'; + const block = blockFor(source); + assert.true(block.catch); + assert.deepEqual(block.inverse?.blockParams, ['error', 'retry']); + assert.deepEqual( + block.inverse?.params.map(({ type, name }) => ({ type, name })), + [ + { type: 'VarHead', name: 'error' }, + { type: 'VarHead', name: 'retry' }, + ] + ); + const params = block.inverse?.params ?? []; + assert.strictEqual(params.length, 2); + const paramStart = source.indexOf('|', source.indexOf('{{~catch')) + 1; + for (const param of params) { + const start = source.indexOf(param.name, paramStart); + assert.deepEqual( + param.loc.toJSON(), + span(source, start, start + param.name.length), + param.name + ); + assert.strictEqual(param.loc.asString(), param.name); + } + }); + + for (const [name, source] of [ + ['empty catch body', '{{#try}}x{{catch as |e|}}{{/try}}'], + ['empty try body', '{{#try}}{{catch as |e|}}y{{/try}}'], + ['both bodies empty', '{{#try}}{{catch as |e|}}{{/try}}'], + ] as const) { + QUnit.test(`locates params and inverse with ${name}`, (assert) => { + const block = blockFor(source); + const catchStart = source.indexOf('{{catch'); + const catchEnd = source.indexOf('}}', catchStart) + 2; + const closeStart = source.indexOf('{{/try}}'); + const paramStart = source.indexOf('e', source.indexOf('|', catchStart)); + assert.true(block.catch); + assert.deepEqual( + block.inverse?.params[0]?.loc.toJSON(), + span(source, paramStart, paramStart + 1) + ); + assert.deepEqual(block.inverse?.blockParams, ['e']); + assert.deepEqual(block.inverse?.loc.toJSON(), span(source, catchEnd, closeStart)); + if (block.inverse) { + assert.strictEqual(print(block.inverse), source.slice(catchEnd, closeStart)); + } + assert.strictEqual(block.program.body.length, name === 'empty catch body' ? 1 : 0); + assert.strictEqual(block.inverse?.body.length, name === 'empty try body' ? 1 : 0); + }); + } + + QUnit.test('nested catch flags and params belong to their own blocks', (assert) => { + const ast = preprocess( + '{{#try}}{{#foo}}A{{catch as |inner|}}B{{/foo}}{{catch as |outer|}}C{{/try}}' + ); + const catches: string[][] = []; + traverse(ast, { + BlockStatement(node) { + assert.true((node as ASTv1.BlockStatement & { catch?: boolean }).catch); + assert.ok(node.inverse); + if (node.inverse) { + catches.push(node.inverse.blockParams); + } + }, + }); + assert.deepEqual(catches, [['outer'], ['inner']]); + }); + + QUnit.test('a local named catch is still parsed as an empty catch clause', (assert) => { + const block = blockFor('{{#let x as |catch|}}{{catch}}{{/let}}'); + assert.true(block.catch); + assert.deepEqual(block.program.blockParams, ['catch']); + assert.deepEqual(block.program.body, []); + assert.strictEqual(block.inverse?.type, 'Block'); + assert.deepEqual(block.inverse?.body, []); + assert.deepEqual(block.inverse?.params, []); + }); + + QUnit.test( + 'baseline-green guard: ordinary blocks and else chains have no own catch flag', + (assert) => { + for (const source of [ + '{{#foo}}A{{/foo}}', + '{{#foo}}A{{else}}B{{/foo}}', + '{{#foo}}A{{else if bar}}B{{else}}C{{/foo}}', + ]) { + traverse(preprocess(source), { + BlockStatement(node) { + assert.false(Object.hasOwn(node, 'catch'), source); + }, + }); + assert.strictEqual(print(preprocess(source)), source); + } + } + ); +}); + +QUnit.module('[try-catch-syntax] printer and public builder', () => { + for (const source of [ + '{{#try}}A{{catch}}B{{/try}}', + '{{#try}}A{{catch as |e|}}{{e.message}}{{/try}}', + '{{#try}}A{{catch as |e r|}}{{e}}{{r}}{{/try}}', + '{{#try}}A{{~catch~}}B{{/try}}', + '{{#try}}A{{~catch as |e r|~}}B{{/try}}', + '{{#try}}
\n A\n
{{catch as |e|}}

\n {{e.message}}\n

{{/try}}', + ]) { + QUnit.test(`round-trips ${source}`, (assert) => { + const block = blockFor(source); + assert.true(block.catch, 'round-trip must retain clause semantics'); + assert.strictEqual(print(block), source); + }); + } + + QUnit.test('codemod mode preserves standalone and stripped catch whitespace', (assert) => { + for (const tag of ['{{catch}}', '{{~catch as |e r|~}}']) { + const source = `before\n{{#try}}\n
A
\n ${tag}\n

B

\n{{/try}}\nafter`; + const ast = preprocess(source, { mode: 'codemod' }); + const block = ast.body.find( + (node) => node.type === 'BlockStatement' + ) as ASTv1.BlockStatement & { catch?: boolean }; + assert.true(block.catch); + assert.strictEqual(print(ast), source); + } + }); + + QUnit.test('printing reads renamed inverse blockParams through an AST traversal', (assert) => { + const ast = preprocess('{{#try}}A{{catch as |e r|}}B{{/try}}'); + traverse(ast, { + BlockStatement(node) { + assert.ok(node.inverse); + if (node.inverse) { + node.inverse.blockParams = ['problem', 'again']; + } + }, + }); + assert.strictEqual(print(ast), '{{#try}}A{{catch as |problem again|}}B{{/try}}'); + }); + + QUnit.test('public block builder accepts isCatch as its tenth positional argument', (assert) => { + const buildCatchBlock: ( + ...args: [...Parameters, isCatch?: boolean] + ) => ASTv1.BlockStatement & { catch?: boolean } = builders.block; + const block = buildCatchBlock( + 'try', + [], + builders.hash([]), + builders.blockItself([builders.text('A')]), + builders.blockItself([builders.text('B')], ['e', 'r']), + undefined, + undefined, + { open: true, close: true }, + undefined, + true + ); + assert.true(block.catch); + assert.true(Object.hasOwn(block, 'catch')); + assert.strictEqual(print(block), '{{#try}}A{{~catch as |e r|~}}B{{/try}}'); + }); + + QUnit.test('baseline-green guard: the builder omits catch when isCatch is omitted', (assert) => { + const block = builders.block( + 'foo', + [], + builders.hash([]), + builders.blockItself([builders.text('A')]), + builders.blockItself([builders.text('B')]) + ); + assert.false(Object.hasOwn(block, 'catch')); + assert.strictEqual(print(block), '{{#foo}}A{{else}}B{{/foo}}'); + }); + + QUnit.test('baseline-green guard: a shadowed try with else prints as else', (assert) => { + const source = '{{#let x as |try|}}{{#try}}a{{else}}b{{/try}}{{/let}}'; + const ast = preprocess(source); + traverse(ast, { + BlockStatement(node) { + assert.false(Object.hasOwn(node, 'catch')); + }, + }); + assert.strictEqual(print(ast), source); + }); +}); + +QUnit.module('[try-catch-syntax] ASTv2 normalization', () => { + QUnit.test('normalization names the inverse catch instead of else', (assert) => { + const [ast] = normalize(src.Source.from('{{#try}}A{{catch as |e|}}B{{/try}}')); + const block = ast.body[0]; + assert.strictEqual(block?.type, 'InvokeBlock'); + if (block?.type === 'InvokeBlock') { + assert.deepEqual( + block.blocks.blocks.map(({ name }) => name.chars), + ['default', 'catch'] + ); + assert.strictEqual(block.blocks.get('else'), null); + assert.strictEqual(block.blocks.get('catch')?.block.body[0]?.type, 'HtmlText'); + } + }); + + QUnit.test('baseline-green guard: normalization keeps an ordinary else block', (assert) => { + const [ast] = normalize(src.Source.from('{{#foo}}A{{else}}B{{/foo}}')); + const block = ast.body[0]; + assert.strictEqual(block?.type, 'InvokeBlock'); + if (block?.type === 'InvokeBlock') { + assert.deepEqual( + block.blocks.blocks.map(({ name }) => name.chars), + ['default', 'else'] + ); + assert.strictEqual(block.blocks.get('catch'), null); + } + }); +}); + +QUnit.module('[try-catch-syntax] block-only reservation and locals', () => { + QUnit.test( + 'catch params are scoped to the fallback and the try block path is omitted', + (assert) => { + assert.deepEqual(getTemplateLocals('{{#try}}{{foo}}{{catch as |e|}}{{e.message}}{{/try}}'), [ + 'foo', + ]); + assert.deepEqual( + getTemplateLocals('{{#try}}{{foo}}{{catch as |e r|}}{{e.message}}{{r}}{{/try}}{{e}}{{r}}'), + ['foo', 'e', 'r'] + ); + assert.deepEqual( + getTemplateLocals('{{#try}}{{e}}{{catch as |e r|}}{{e.message}}{{r}}{{/try}}{{e}}{{r}}'), + ['e', 'r'] + ); + } + ); + + QUnit.test('try exclusion applies per occurrence rather than per name', (assert) => { + assert.deepEqual(getTemplateLocals('{{#try}}{{try x}}{{/try}}'), ['try', 'x']); + assert.deepEqual(getTemplateLocals('{{#try}}{{foo}}{{/try}}{{try x}}'), ['foo', 'try', 'x']); + assert.deepEqual(getTemplateLocals('{{#try}}{{try}}{{/try}}'), ['try']); + }); + + QUnit.test('only block paths named try are excluded from locals', (assert) => { + assert.deepEqual(getTemplateLocals('{{#try}}{{foo}}{{/try}}'), ['foo']); + assert.deepEqual(getTemplateLocals('{{#foo try}}{{/foo}}'), ['foo', 'try']); + assert.deepEqual(getTemplateLocals('{{#foo value=try}}{{/foo}}'), ['foo', 'try']); + assert.deepEqual(getTemplateLocals('{{#try}}{{foo}}{{/try}}', { includeKeywords: true }), [ + 'try', + 'foo', + ]); + }); + + QUnit.test( + 'baseline-green guard: try is not a global keyword or reserved outside blocks', + (assert) => { + assert.false(isKeyword('try')); + for (const source of [ + '{{try}}', + '{{(try)}}', + '
', + '{{try x}}', + '{{foo (try x)}}', + ]) { + assert.deepEqual( + getTemplateLocals(source), + source.includes(' x') + ? source.startsWith('{{foo') + ? ['foo', 'try', 'x'] + : ['try', 'x'] + : ['try'], + source + ); + } + } + ); +}); diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index d94ae456e6b..6a9c2ff3edf 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -95,6 +95,54 @@ export function endUntrackFrame(): void { CURRENT_TRACKER = OPEN_TRACK_FRAMES.pop() || null; } +/** + * How many `{{#try}}` boundaries are currently rendering. While one is, a + * failed render is recovered from rather than torn down, so tracking state is + * unwound with `unwindTrackingTo` instead of being reset. + */ +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; +} + +export function getTrackingDepth(): number { + return OPEN_TRACK_FRAMES.length; +} + +/** + * Pops tracking frames left open by a render that threw, back to `depth`, and + * returns a tag combining everything they consumed. An error boundary uses it + * to learn which tracked state the failed render read, so it can retry when + * any of it changes. + */ +export function unwindTrackingTo(depth: number): Tag { + let tags: Tag[] = []; + + while (OPEN_TRACK_FRAMES.length > depth) { + // An untrack frame has no tracker and never opened a debug transaction. + if (CURRENT_TRACKER !== null) { + tags.push(CURRENT_TRACKER.combine()); + + if (DEBUG) { + unwrap(debug.endTrackingTransaction)(); + } + } + + CURRENT_TRACKER = OPEN_TRACK_FRAMES.pop() ?? null; + } + + return combine(tags); +} + // This function is only for handling errors and resetting to a valid state export function resetTracking(): string | void { while (OPEN_TRACK_FRAMES.length > 0) { @@ -234,11 +282,21 @@ export function track(block: () => void, debugLabel?: string | false): Tag { beginTrackFrame(debugLabel); let tag; + let threw = true; try { block(); + threw = false; } finally { tag = endTrackFrame(); + + // Inside an error boundary, a block that throws still depended on what it + // read first; hand that to the parent frame so the boundary can retry when + // it changes. A flag rather than catch + rethrow keeps "pause on uncaught + // exceptions" pointing at the original throw. + if (threw && ERROR_BOUNDARY_DEPTH > 0) { + consumeTag(tag); + } } return tag; diff --git a/packages/@glimmer/validator/test/error-boundary-tracking-test.ts b/packages/@glimmer/validator/test/error-boundary-tracking-test.ts new file mode 100644 index 00000000000..0a7e4654cf4 --- /dev/null +++ b/packages/@glimmer/validator/test/error-boundary-tracking-test.ts @@ -0,0 +1,60 @@ +import { + consumeTag, + createTag, + dirtyTag, + track, + validateTag, + valueForTag, +} from '@glimmer/validator'; +import { + beginErrorBoundary, + endErrorBoundary, + getTrackingDepth, + isInErrorBoundary, +} from '@glimmer/validator/lib/tracking'; + +import { module, test } from './-utils'; + +module('@glimmer/validator [try-catch-runtime] review: boundary tracking scope', () => { + for (let active of [false, true]) { + test(`caught inner track exception ${active ? 'inside' : 'outside'} a boundary`, (assert) => { + let outerTag = createTag(); + let innerTag = createTag(); + let error = new Error('inner track'); + let depth = getTrackingDepth(); + assert.false(isInErrorBoundary(), 'starts outside a boundary'); + + if (active) beginErrorBoundary(); + let combined; + try { + combined = track(() => { + consumeTag(outerTag); + assert.throws( + () => { + track(() => { + consumeTag(innerTag); + throw error; + }); + }, + (caught: unknown) => caught === error + ); + }); + } finally { + if (active) endErrorBoundary(); + } + + assert.strictEqual(getTrackingDepth(), depth, 'inner exception balances tracking frames'); + assert.false(isInErrorBoundary(), 'restores boundary scope'); + let snapshot = valueForTag(combined); + dirtyTag(innerTag); + assert.strictEqual( + validateTag(combined, snapshot), + !active, + 'inner tag is forwarded only inside a boundary' + ); + snapshot = valueForTag(combined); + dirtyTag(outerTag); + assert.false(validateTag(combined, snapshot), 'outer frame still consumes its own tag'); + }); + } +}); diff --git a/packages/@glimmer/wire-format/lib/opcodes.ts b/packages/@glimmer/wire-format/lib/opcodes.ts index 0faf86f0dc5..ad8fcd5a726 100644 --- a/packages/@glimmer/wire-format/lib/opcodes.ts +++ b/packages/@glimmer/wire-format/lib/opcodes.ts @@ -38,6 +38,7 @@ import type { TrustingAppendOpcode, TrustingComponentAttrOpcode, TrustingDynamicAttrOpcode, + TryOpcode, UndefinedOpcode, WithDynamicVarsOpcode, YieldOpcode, @@ -76,6 +77,7 @@ export const opcodes = { InElement: 40 satisfies InElementOpcode, If: 41 satisfies IfOpcode, Each: 42 satisfies EachOpcode, + Try: 43 satisfies TryOpcode, Let: 44 satisfies LetOpcode, WithDynamicVars: 45 satisfies WithDynamicVarsOpcode, InvokeComponent: 46 satisfies InvokeComponentOpcode, diff --git a/packages/@handlebars/parser/lib/helpers.js b/packages/@handlebars/parser/lib/helpers.js index fb2998e2ebe..b7793ad2364 100644 --- a/packages/@handlebars/parser/lib/helpers.js +++ b/packages/@handlebars/parser/lib/helpers.js @@ -144,7 +144,7 @@ export function prepareBlock(openBlock, program, inverseAndProgram, close, inver program.blockParams = openBlock.blockParams; - let inverse, inverseStrip; + let inverse, inverseStrip, catchClause; if (inverseAndProgram) { if (decorator) { @@ -157,6 +157,14 @@ export function prepareBlock(openBlock, program, inverseAndProgram, close, inver inverseStrip = inverseAndProgram.strip; inverse = inverseAndProgram.program; + + if (inverseAndProgram.catch) { + catchClause = inverseAndProgram.catch; + + if (catchClause.params.length) { + inverse.blockParams = catchClause.params.map((param) => param.name); + } + } } if (inverted) { @@ -165,7 +173,7 @@ export function prepareBlock(openBlock, program, inverseAndProgram, close, inver program = inverted; } - return { + let node = { type: decorator ? 'DecoratorBlock' : 'BlockStatement', path: openBlock.path, params: openBlock.params, @@ -177,6 +185,44 @@ export function prepareBlock(openBlock, program, inverseAndProgram, close, inver closeStrip: close && close.strip, loc: this.locInfo(locInfo), }; + + if (catchClause) { + node.catch = catchClause; + } + + return node; +} + +const CATCH_TOKENS = ['CATCH', 'OPEN_CATCH_PARAMS']; + +/** + * Replaces jison's generic "Expecting ..." line for a misplaced `{{catch}}` or + * block params on a non-block mustache, and defers to jison's own handler for + * everything else so other messages stay byte-identical. + */ +export function parseError(str, hash) { + let message; + + if (hash && CATCH_TOKENS.includes(hash.token)) { + message = hash.expected.includes("'OPEN_ENDBLOCK'") + ? 'Unexpected {{catch}}: it must directly follow the body of a {{#...}} block, and cannot appear after {{else}}, twice, or in an inverse {{^...}} block' + : 'Unexpected {{catch}} outside of a block'; + } else if ( + hash && + hash.token === 'OPEN_BLOCK_PARAMS' && + // Only `CLOSE` is expected right after a block header's own params, so a + // second `as |...|` group there keeps jison's message. + !(hash.expected.length === 1 && hash.expected[0] === "'CLOSE'") + ) { + message = 'Unexpected block params: "as |...|" is only allowed on block statements'; + } + + if (message) { + let index = str.lastIndexOf('\nExpecting '); + str = (index === -1 ? str : str.slice(0, index)) + '\n' + message; + } + + return Object.getPrototypeOf(this).parseError.call(this, str, hash); } export function prepareProgram(statements, loc) { diff --git a/packages/@handlebars/parser/lib/parser.js b/packages/@handlebars/parser/lib/parser.js index 136e73cc256..ef9a5c4cc9c 100644 --- a/packages/@handlebars/parser/lib/parser.js +++ b/packages/@handlebars/parser/lib/parser.js @@ -77,9 +77,9 @@ var parser = (function () { for (o = o || {}, l = k.length; l--; o[k[l]] = v); return o; }, - $V0 = [2, 52], + $V0 = [2, 57], $V1 = [1, 20], - $V2 = [5, 14, 15, 19, 29, 34, 39, 44, 47, 48, 53, 57, 61], + $V2 = [5, 14, 15, 19, 30, 35, 40, 45, 48, 49, 54, 58, 62, 95, 96], $V3 = [1, 44], $V4 = [1, 40], $V5 = [1, 43], @@ -90,29 +90,30 @@ var parser = (function () { $Va = [1, 37], $Vb = [1, 42], $Vc = [1, 46], - $Vd = [14, 15, 19, 29, 34, 39, 44, 47, 48, 53, 57, 61], - $Ve = [14, 15, 19, 29, 34, 44, 47, 48, 53, 57, 61], + $Vd = [14, 15, 19, 30, 35, 40, 45, 48, 49, 54, 58, 62, 95, 96], + $Ve = [14, 15, 19, 30, 35, 45, 48, 49, 54, 58, 62], $Vf = [15, 18], - $Vg = [14, 15, 19, 29, 34, 47, 48, 53, 57, 61], - $Vh = [33, 67, 73, 75, 84, 85, 86, 87, 88, 89], - $Vi = [23, 33, 56, 67, 68, 73, 75, 77, 79, 84, 85, 86, 87, 88, 89], + $Vg = [14, 15, 19, 30, 35, 48, 49, 54, 58, 62], + $Vh = [34, 68, 74, 76, 85, 86, 87, 88, 89, 90], + $Vi = [23, 34, 57, 68, 69, 74, 76, 78, 80, 85, 86, 87, 88, 89, 90], $Vj = [1, 62], $Vk = [1, 63], - $Vl = [23, 33, 56, 68, 73, 79], - $Vm = [23, 33, 56, 67, 68, 73, 75, 77, 79, 84, 85, 86, 87, 88, 89, 92, 93], - $Vn = [2, 51], + $Vl = [23, 34, 57, 69, 74, 80], + $Vm = [23, 34, 57, 68, 69, 74, 76, 78, 80, 85, 86, 87, 88, 89, 90, 93, 94], + $Vn = [2, 52], $Vo = [1, 64], - $Vp = [67, 73, 75, 77, 84, 85, 86, 87, 88, 89], - $Vq = [56, 67, 73, 75, 84, 85, 86, 87, 88, 89], - $Vr = [1, 75], - $Vs = [1, 76], - $Vt = [1, 83], - $Vu = [33, 67, 73, 75, 79, 84, 85, 86, 87, 88, 89], - $Vv = [23, 67, 73, 75, 84, 85, 86, 87, 88, 89], - $Vw = [67, 68, 73, 75, 84, 85, 86, 87, 88, 89], - $Vx = [33, 79], - $Vy = [1, 134], - $Vz = [73, 81]; + $Vp = [68, 74, 76, 78, 85, 86, 87, 88, 89, 90], + $Vq = [57, 68, 74, 76, 85, 86, 87, 88, 89, 90], + $Vr = [1, 78], + $Vs = [1, 79], + $Vt = [1, 86], + $Vu = [34, 68, 74, 76, 80, 85, 86, 87, 88, 89, 90], + $Vv = [23, 68, 74, 76, 85, 86, 87, 88, 89, 90], + $Vw = [68, 69, 74, 76, 85, 86, 87, 88, 89, 90], + $Vx = [14, 15, 19, 30, 35, 40, 45, 48, 49, 54, 58, 62], + $Vy = [34, 80], + $Vz = [74, 82], + $VA = [1, 143]; var parser = { trace: function trace() {}, yy: {}, @@ -142,73 +143,77 @@ var parser = (function () { openBlock: 24, block_option0: 25, closeBlock: 26, - openInverse: 27, - block_option1: 28, - OPEN_BLOCK: 29, - openBlock_repetition0: 30, - openBlock_option0: 31, - openBlock_option1: 32, - CLOSE: 33, - OPEN_INVERSE: 34, - openInverse_repetition0: 35, - openInverse_option0: 36, - openInverse_option1: 37, - openInverseChain: 38, - OPEN_INVERSE_CHAIN: 39, - openInverseChain_repetition0: 40, - openInverseChain_option0: 41, - openInverseChain_option1: 42, - inverseAndProgram: 43, - INVERSE: 44, - inverseChain: 45, - inverseChain_option0: 46, - OPEN_ENDBLOCK: 47, - OPEN: 48, - hash: 49, - expr: 50, - mustache_repetition0: 51, - mustache_option0: 52, - OPEN_UNESCAPED: 53, - mustache_repetition1: 54, - mustache_option1: 55, - CLOSE_UNESCAPED: 56, - OPEN_PARTIAL: 57, - partial_repetition0: 58, - partial_option0: 59, - openPartialBlock: 60, - OPEN_PARTIAL_BLOCK: 61, - openPartialBlock_repetition0: 62, - openPartialBlock_option0: 63, - exprHead: 64, - arrayLiteral: 65, - sexpr: 66, - OPEN_SEXPR: 67, - CLOSE_SEXPR: 68, - sexpr_repetition0: 69, - sexpr_option0: 70, - hash_repetition_plus0: 71, - hashSegment: 72, - ID: 73, - EQUALS: 74, - OPEN_ARRAY: 75, - arrayLiteral_repetition0: 76, - CLOSE_ARRAY: 77, - blockParams: 78, - OPEN_BLOCK_PARAMS: 79, - blockParams_repetition_plus0: 80, - CLOSE_BLOCK_PARAMS: 81, - path: 82, - dataName: 83, - STRING: 84, - NUMBER: 85, - BOOLEAN: 86, - UNDEFINED: 87, - NULL: 88, - DATA: 89, - pathSegments: 90, - sep: 91, - SEP: 92, - PRIVATE_SEP: 93, + catchClause: 27, + openInverse: 28, + block_option1: 29, + OPEN_BLOCK: 30, + openBlock_repetition0: 31, + openBlock_option0: 32, + openBlock_option1: 33, + CLOSE: 34, + OPEN_INVERSE: 35, + openInverse_repetition0: 36, + openInverse_option0: 37, + openInverse_option1: 38, + openInverseChain: 39, + OPEN_INVERSE_CHAIN: 40, + openInverseChain_repetition0: 41, + openInverseChain_option0: 42, + openInverseChain_option1: 43, + inverseAndProgram: 44, + INVERSE: 45, + inverseChain: 46, + inverseChain_option0: 47, + OPEN_ENDBLOCK: 48, + OPEN: 49, + hash: 50, + expr: 51, + mustache_repetition0: 52, + mustache_option0: 53, + OPEN_UNESCAPED: 54, + mustache_repetition1: 55, + mustache_option1: 56, + CLOSE_UNESCAPED: 57, + OPEN_PARTIAL: 58, + partial_repetition0: 59, + partial_option0: 60, + openPartialBlock: 61, + OPEN_PARTIAL_BLOCK: 62, + openPartialBlock_repetition0: 63, + openPartialBlock_option0: 64, + exprHead: 65, + arrayLiteral: 66, + sexpr: 67, + OPEN_SEXPR: 68, + CLOSE_SEXPR: 69, + sexpr_repetition0: 70, + sexpr_option0: 71, + hash_repetition_plus0: 72, + hashSegment: 73, + ID: 74, + EQUALS: 75, + OPEN_ARRAY: 76, + arrayLiteral_repetition0: 77, + CLOSE_ARRAY: 78, + blockParams: 79, + OPEN_BLOCK_PARAMS: 80, + blockParams_repetition_plus0: 81, + CLOSE_BLOCK_PARAMS: 82, + path: 83, + dataName: 84, + STRING: 85, + NUMBER: 86, + BOOLEAN: 87, + UNDEFINED: 88, + NULL: 89, + DATA: 90, + pathSegments: 91, + sep: 92, + SEP: 93, + PRIVATE_SEP: 94, + CATCH: 95, + OPEN_CATCH_PARAMS: 96, + catchParams: 97, $accept: 0, $end: 1, }, @@ -220,33 +225,35 @@ var parser = (function () { 18: 'END_RAW_BLOCK', 19: 'OPEN_RAW_BLOCK', 23: 'CLOSE_RAW_BLOCK', - 29: 'OPEN_BLOCK', - 33: 'CLOSE', - 34: 'OPEN_INVERSE', - 39: 'OPEN_INVERSE_CHAIN', - 44: 'INVERSE', - 47: 'OPEN_ENDBLOCK', - 48: 'OPEN', - 53: 'OPEN_UNESCAPED', - 56: 'CLOSE_UNESCAPED', - 57: 'OPEN_PARTIAL', - 61: 'OPEN_PARTIAL_BLOCK', - 67: 'OPEN_SEXPR', - 68: 'CLOSE_SEXPR', - 73: 'ID', - 74: 'EQUALS', - 75: 'OPEN_ARRAY', - 77: 'CLOSE_ARRAY', - 79: 'OPEN_BLOCK_PARAMS', - 81: 'CLOSE_BLOCK_PARAMS', - 84: 'STRING', - 85: 'NUMBER', - 86: 'BOOLEAN', - 87: 'UNDEFINED', - 88: 'NULL', - 89: 'DATA', - 92: 'SEP', - 93: 'PRIVATE_SEP', + 30: 'OPEN_BLOCK', + 34: 'CLOSE', + 35: 'OPEN_INVERSE', + 40: 'OPEN_INVERSE_CHAIN', + 45: 'INVERSE', + 48: 'OPEN_ENDBLOCK', + 49: 'OPEN', + 54: 'OPEN_UNESCAPED', + 57: 'CLOSE_UNESCAPED', + 58: 'OPEN_PARTIAL', + 62: 'OPEN_PARTIAL_BLOCK', + 68: 'OPEN_SEXPR', + 69: 'CLOSE_SEXPR', + 74: 'ID', + 75: 'EQUALS', + 76: 'OPEN_ARRAY', + 78: 'CLOSE_ARRAY', + 80: 'OPEN_BLOCK_PARAMS', + 82: 'CLOSE_BLOCK_PARAMS', + 85: 'STRING', + 86: 'NUMBER', + 87: 'BOOLEAN', + 88: 'UNDEFINED', + 89: 'NULL', + 90: 'DATA', + 93: 'SEP', + 94: 'PRIVATE_SEP', + 95: 'CATCH', + 96: 'OPEN_CATCH_PARAMS', }, productions_: [ 0, @@ -264,29 +271,30 @@ var parser = (function () { [16, 5], [9, 4], [9, 4], + [9, 4], [24, 6], - [27, 6], - [38, 6], - [43, 2], - [45, 3], - [45, 1], + [28, 6], + [39, 6], + [44, 2], + [46, 3], + [46, 1], [26, 3], [8, 3], [8, 5], [8, 5], [11, 5], [12, 3], - [60, 5], - [50, 1], + [61, 5], + [51, 1], + [51, 1], + [65, 1], + [65, 1], + [67, 3], + [67, 5], [50, 1], - [64, 1], - [64, 1], + [73, 3], [66, 3], - [66, 5], - [49, 1], - [72, 3], - [65, 3], - [78, 3], + [79, 3], [20, 1], [20, 1], [20, 1], @@ -294,13 +302,17 @@ var parser = (function () { [20, 1], [20, 1], [20, 1], - [83, 2], - [91, 1], + [84, 2], + [92, 1], + [92, 1], + [83, 3], + [83, 1], + [91, 3], [91, 1], - [82, 3], - [82, 1], - [90, 3], - [90, 1], + [27, 2], + [27, 5], + [97, 2], + [97, 1], [6, 0], [6, 2], [17, 0], @@ -311,54 +323,54 @@ var parser = (function () { [22, 1], [25, 0], [25, 1], - [28, 0], - [28, 1], - [30, 0], - [30, 2], + [29, 0], + [29, 1], [31, 0], - [31, 1], + [31, 2], [32, 0], [32, 1], - [35, 0], - [35, 2], + [33, 0], + [33, 1], [36, 0], - [36, 1], + [36, 2], [37, 0], [37, 1], - [40, 0], - [40, 2], + [38, 0], + [38, 1], [41, 0], - [41, 1], + [41, 2], [42, 0], [42, 1], - [46, 0], - [46, 1], - [51, 0], - [51, 2], + [43, 0], + [43, 1], + [47, 0], + [47, 1], [52, 0], - [52, 1], - [54, 0], - [54, 2], + [52, 2], + [53, 0], + [53, 1], [55, 0], - [55, 1], - [58, 0], - [58, 2], + [55, 2], + [56, 0], + [56, 1], [59, 0], - [59, 1], - [62, 0], - [62, 2], + [59, 2], + [60, 0], + [60, 1], [63, 0], - [63, 1], - [69, 0], - [69, 2], + [63, 2], + [64, 0], + [64, 1], [70, 0], - [70, 1], + [70, 2], + [71, 0], [71, 1], - [71, 2], - [76, 0], - [76, 2], - [80, 1], - [80, 2], + [72, 1], + [72, 2], + [77, 0], + [77, 2], + [81, 1], + [81, 2], ], performAction: function anonymous( yytext, @@ -385,15 +397,15 @@ var parser = (function () { case 6: case 7: case 8: - case 20: - case 28: + case 21: case 29: case 30: case 31: - case 38: + case 32: case 39: - case 46: + case 40: case 47: + case 48: this.$ = $$[$0]; break; case 9: @@ -421,12 +433,13 @@ var parser = (function () { this.$ = { path: $$[$0 - 3], params: $$[$0 - 2], hash: $$[$0 - 1] }; break; case 13: + case 14: this.$ = yy.prepareBlock($$[$0 - 3], $$[$0 - 2], $$[$0 - 1], $$[$0], false, this._$); break; - case 14: + case 15: this.$ = yy.prepareBlock($$[$0 - 3], $$[$0 - 2], $$[$0 - 1], $$[$0], true, this._$); break; - case 15: + case 16: this.$ = { open: $$[$0 - 5], path: $$[$0 - 4], @@ -436,8 +449,8 @@ var parser = (function () { strip: yy.stripFlags($$[$0 - 5], $$[$0]), }; break; - case 16: case 17: + case 18: this.$ = { path: $$[$0 - 4], params: $$[$0 - 3], @@ -446,10 +459,10 @@ var parser = (function () { strip: yy.stripFlags($$[$0 - 5], $$[$0]), }; break; - case 18: + case 19: this.$ = { strip: yy.stripFlags($$[$0 - 1], $$[$0 - 1]), program: $$[$0] }; break; - case 19: + case 20: var inverse = yy.prepareBlock($$[$0 - 2], $$[$0 - 1], $$[$0], $$[$0], false, this._$), program = yy.prepareProgram([inverse], $$[$0 - 1].loc); program.chained = true; @@ -457,10 +470,10 @@ var parser = (function () { this.$ = { strip: $$[$0 - 2].strip, program: program, chain: true }; break; - case 21: + case 22: this.$ = { path: $$[$0 - 1], strip: yy.stripFlags($$[$0 - 2], $$[$0]) }; break; - case 22: + case 23: this.$ = yy.prepareMustache( yy.syntax.hash($$[$0 - 1], yy.locInfo(this._$), { yy, syntax: 'expr' }), [], @@ -470,8 +483,8 @@ var parser = (function () { this._$ ); break; - case 23: case 24: + case 25: this.$ = yy.prepareMustache( $$[$0 - 3], $$[$0 - 2], @@ -481,7 +494,7 @@ var parser = (function () { this._$ ); break; - case 25: + case 26: this.$ = { type: 'PartialStatement', name: $$[$0 - 3], @@ -493,10 +506,10 @@ var parser = (function () { }; break; - case 26: + case 27: this.$ = yy.preparePartialBlock($$[$0 - 2], $$[$0 - 1], $$[$0], this._$); break; - case 27: + case 28: this.$ = { path: $$[$0 - 3], params: $$[$0 - 2], @@ -504,10 +517,10 @@ var parser = (function () { strip: yy.stripFlags($$[$0 - 4], $$[$0]), }; break; - case 32: + case 33: this.$ = yy.syntax.hash($$[$0 - 1], yy.locInfo(this._$), { yy, syntax: 'expr' }); break; - case 33: + case 34: this.$ = { type: 'SubExpression', path: $$[$0 - 3], @@ -517,10 +530,10 @@ var parser = (function () { }; break; - case 34: + case 35: this.$ = { type: 'Hash', pairs: $$[$0], loc: yy.locInfo(this._$) }; break; - case 35: + case 36: this.$ = { type: 'HashPair', key: yy.id($$[$0 - 2]), @@ -528,13 +541,13 @@ var parser = (function () { loc: yy.locInfo(this._$), }; break; - case 36: + case 37: this.$ = yy.syntax.square($$[$0 - 1], yy.locInfo(this._$), { yy, syntax: 'expr' }); break; - case 37: + case 38: this.$ = yy.id($$[$0 - 1]); break; - case 40: + case 41: this.$ = { type: 'StringLiteral', value: $$[$0], @@ -542,7 +555,7 @@ var parser = (function () { loc: yy.locInfo(this._$), }; break; - case 41: + case 42: this.$ = { type: 'NumberLiteral', value: Number($$[$0]), @@ -550,7 +563,7 @@ var parser = (function () { loc: yy.locInfo(this._$), }; break; - case 42: + case 43: this.$ = { type: 'BooleanLiteral', value: $$[$0] === 'true', @@ -558,7 +571,7 @@ var parser = (function () { loc: yy.locInfo(this._$), }; break; - case 43: + case 44: this.$ = { type: 'UndefinedLiteral', original: undefined, @@ -566,66 +579,96 @@ var parser = (function () { loc: yy.locInfo(this._$), }; break; - case 44: + case 45: this.$ = { type: 'NullLiteral', original: null, value: null, loc: yy.locInfo(this._$) }; break; - case 45: + case 46: this.$ = yy.preparePath(true, false, $$[$0], this._$); break; - case 48: + case 49: this.$ = yy.preparePath(false, $$[$0 - 2], $$[$0], this._$); break; - case 49: + case 50: this.$ = yy.preparePath(false, false, $$[$0], this._$); break; - case 50: + case 51: $$[$0 - 2].push({ part: yy.id($$[$0]), original: $$[$0], separator: $$[$0 - 1] }); this.$ = $$[$0 - 2]; break; - case 51: + case 52: this.$ = [{ part: yy.id($$[$0]), original: $$[$0] }]; break; - case 52: + case 53: + this.$ = { + strip: yy.stripFlags($$[$0 - 1], $$[$0 - 1]), + program: $$[$0], + catch: { loc: yy.locInfo(_$[$0 - 1]), params: [] }, + }; + break; case 54: - case 56: - case 64: - case 70: - case 76: - case 84: - case 88: - case 92: - case 96: - case 100: - case 106: - this.$ = []; + this.$ = { + strip: yy.stripFlags($$[$0 - 4], $$[$0 - 1]), + program: $$[$0], + catch: { + loc: yy.locInfo({ + first_line: _$[$0 - 4].first_line, + first_column: _$[$0 - 4].first_column, + last_line: _$[$0 - 1].last_line, + last_column: _$[$0 - 1].last_column, + }), + params: $$[$0 - 3], + }, + }; + break; - case 53: case 55: + $$[$0 - 1].push({ name: yy.id($$[$0]), loc: yy.locInfo(_$[$0]) }); + this.$ = $$[$0 - 1]; + break; + case 56: + this.$ = [{ name: yy.id($$[$0]), loc: yy.locInfo(_$[$0]) }]; + break; case 57: - case 65: - case 71: - case 77: - case 85: + case 59: + case 61: + case 69: + case 75: + case 81: case 89: case 93: case 97: case 101: case 105: - case 107: - case 109: + case 111: + this.$ = []; + break; + case 58: + case 60: + case 62: + case 70: + case 76: + case 82: + case 90: + case 94: + case 98: + case 102: + case 106: + case 110: + case 112: + case 114: $$[$0 - 1].push($$[$0]); break; - case 104: - case 108: + case 109: + case 113: this.$ = [$$[$0]]; break; } }, table: [ - o([5, 14, 15, 19, 29, 34, 48, 53, 57, 61], $V0, { 3: 1, 4: 2, 6: 3 }), + o([5, 14, 15, 19, 30, 35, 49, 54, 58, 62], $V0, { 3: 1, 4: 2, 6: 3 }), { 1: [3] }, { 5: [1, 4] }, - o([5, 39, 44, 47], [2, 2], { + o([5, 40, 45, 48, 95, 96], [2, 2], { 7: 5, 8: 6, 9: 7, @@ -634,21 +677,21 @@ var parser = (function () { 12: 10, 13: 11, 24: 15, - 27: 16, + 28: 16, 16: 17, - 60: 19, + 61: 19, 14: [1, 12], 15: $V1, 19: [1, 23], - 29: [1, 21], - 34: [1, 22], - 48: [1, 13], - 53: [1, 14], - 57: [1, 18], - 61: [1, 24], + 30: [1, 21], + 35: [1, 22], + 49: [1, 13], + 54: [1, 14], + 58: [1, 18], + 62: [1, 24], }), { 1: [2, 1] }, - o($V2, [2, 53]), + o($V2, [2, 58]), o($V2, [2, 3]), o($V2, [2, 4]), o($V2, [2, 5]), @@ -658,586 +701,610 @@ var parser = (function () { o($V2, [2, 9]), { 20: 28, - 49: 25, - 50: 26, - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 71: 27, - 72: 30, - 73: $V4, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 50: 25, + 51: 26, + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 72: 27, + 73: 30, + 74: $V4, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, { 20: 28, - 50: 45, - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 73: $Vc, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 51: 45, + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 74: $Vc, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, o($Vd, $V0, { 6: 3, 4: 47 }), o($Ve, $V0, { 6: 3, 4: 48 }), - o($Vf, [2, 54], { 17: 49 }), + o($Vf, [2, 59], { 17: 49 }), { 20: 28, - 50: 50, - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 73: $Vc, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 51: 50, + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 74: $Vc, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, o($Vg, $V0, { 6: 3, 4: 51 }), - o([5, 14, 15, 18, 19, 29, 34, 39, 44, 47, 48, 53, 57, 61], [2, 10]), + o([5, 14, 15, 18, 19, 30, 35, 40, 45, 48, 49, 54, 58, 62, 95, 96], [2, 10]), { 20: 52, - 64: 53, - 65: 38, - 66: 39, - 67: $V3, - 73: $Vc, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 65: 53, + 66: 38, + 67: 39, + 68: $V3, + 74: $Vc, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, { 20: 54, - 64: 53, - 65: 38, - 66: 39, - 67: $V3, - 73: $Vc, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 65: 53, + 66: 38, + 67: 39, + 68: $V3, + 74: $Vc, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, { 20: 55, - 64: 53, - 65: 38, - 66: 39, - 67: $V3, - 73: $Vc, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 65: 53, + 66: 38, + 67: 39, + 68: $V3, + 74: $Vc, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, { 20: 28, - 50: 56, - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 73: $Vc, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 51: 56, + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 74: $Vc, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, - { 33: [1, 57] }, - o($Vh, [2, 84], { 51: 58 }), - o([23, 33, 56, 68, 79], [2, 34], { 72: 59, 73: [1, 60] }), - o($Vi, [2, 28]), - o($Vi, [2, 29], { 91: 61, 92: $Vj, 93: $Vk }), - o($Vl, [2, 104]), - o($Vi, [2, 38]), + { 34: [1, 57] }, + o($Vh, [2, 89], { 52: 58 }), + o([23, 34, 57, 69, 80], [2, 35], { 73: 59, 74: [1, 60] }), + o($Vi, [2, 29]), + o($Vi, [2, 30], { 92: 61, 93: $Vj, 94: $Vk }), + o($Vl, [2, 109]), o($Vi, [2, 39]), o($Vi, [2, 40]), o($Vi, [2, 41]), o($Vi, [2, 42]), o($Vi, [2, 43]), o($Vi, [2, 44]), - o($Vm, [2, 30]), + o($Vi, [2, 45]), o($Vm, [2, 31]), - o([23, 33, 56, 67, 68, 73, 75, 79, 84, 85, 86, 87, 88, 89, 92, 93], $Vn, { 74: $Vo }), - o($Vi, [2, 49], { 91: 65, 92: $Vj, 93: $Vk }), - { 73: $Vc, 90: 66 }, - o($Vp, [2, 106], { 76: 67 }), + o($Vm, [2, 32]), + o([23, 34, 57, 68, 69, 74, 76, 80, 85, 86, 87, 88, 89, 90, 93, 94], $Vn, { 75: $Vo }), + o($Vi, [2, 50], { 92: 65, 93: $Vj, 94: $Vk }), + { 74: $Vc, 91: 66 }, + o($Vp, [2, 111], { 77: 67 }), { 20: 28, - 49: 68, - 50: 69, - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 71: 27, - 72: 30, - 73: $V4, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 50: 68, + 51: 69, + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 72: 27, + 73: 30, + 74: $V4, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, - o($Vq, [2, 88], { 54: 70 }), + o($Vq, [2, 93], { 55: 70 }), o($Vm, $Vn), - { 25: 71, 38: 73, 39: $Vr, 43: 74, 44: $Vs, 45: 72, 47: [2, 60] }, - { 28: 77, 43: 78, 44: $Vs, 47: [2, 62] }, - { 13: 80, 15: $V1, 18: [1, 79] }, - o($Vh, [2, 92], { 58: 81 }), - { 26: 82, 47: $Vt }, - o($Vu, [2, 64], { 30: 84 }), - { 91: 61, 92: $Vj, 93: $Vk }, - o($Vu, [2, 70], { 35: 85 }), - o($Vv, [2, 56], { 21: 86 }), - o($Vh, [2, 96], { 62: 87 }), - o($V2, [2, 22]), + { + 25: 71, + 27: 72, + 39: 76, + 40: $Vr, + 44: 77, + 45: $Vs, + 46: 73, + 48: [2, 65], + 95: [1, 74], + 96: [1, 75], + }, + { 29: 80, 44: 81, 45: $Vs, 48: [2, 67] }, + { 13: 83, 15: $V1, 18: [1, 82] }, + o($Vh, [2, 97], { 59: 84 }), + { 26: 85, 48: $Vt }, + o($Vu, [2, 69], { 31: 87 }), + { 92: 61, 93: $Vj, 94: $Vk }, + o($Vu, [2, 75], { 36: 88 }), + o($Vv, [2, 61], { 21: 89 }), + o($Vh, [2, 101], { 63: 90 }), + o($V2, [2, 23]), { 20: 28, - 33: [2, 86], - 49: 90, - 50: 89, - 52: 88, - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 71: 27, - 72: 30, - 73: $V4, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 34: [2, 91], + 50: 93, + 51: 92, + 53: 91, + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 72: 27, + 73: 30, + 74: $V4, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, - o($Vl, [2, 105]), - { 74: $Vo }, - { 73: $Vc, 90: 91 }, - { 73: [2, 46] }, - { 73: [2, 47] }, + o($Vl, [2, 110]), + { 75: $Vo }, + { 74: $Vc, 91: 94 }, + { 74: [2, 47] }, + { 74: [2, 48] }, { 20: 28, - 50: 92, - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 73: $Vc, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 51: 95, + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 74: $Vc, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, - { 73: [1, 93] }, - o($Vi, [2, 45], { 91: 65, 92: $Vj, 93: $Vk }), + { 74: [1, 96] }, + o($Vi, [2, 46], { 92: 65, 93: $Vj, 94: $Vk }), { 20: 28, - 50: 95, - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 73: $Vc, - 75: $V5, - 77: [1, 94], - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 51: 98, + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 74: $Vc, + 76: $V5, + 78: [1, 97], + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, - { 68: [1, 96] }, - o($Vw, [2, 100], { 69: 97 }), + { 69: [1, 99] }, + o($Vw, [2, 105], { 70: 100 }), { 20: 28, - 49: 100, - 50: 99, - 55: 98, - 56: [2, 90], - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 71: 27, - 72: 30, - 73: $V4, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 50: 103, + 51: 102, + 56: 101, + 57: [2, 95], + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 72: 27, + 73: 30, + 74: $V4, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, - { 26: 101, 47: $Vt }, - { 47: [2, 61] }, - o($Vd, $V0, { 6: 3, 4: 102 }), - { 47: [2, 20] }, + { 26: 104, 48: $Vt }, + { 26: 105, 48: $Vt }, + { 48: [2, 66] }, + o($Vg, $V0, { 6: 3, 4: 106 }), + { 74: [1, 108], 97: 107 }, + o($Vx, $V0, { 6: 3, 4: 109 }), + { 48: [2, 21] }, { - 20: 103, - 64: 53, - 65: 38, - 66: 39, - 67: $V3, - 73: $Vc, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 20: 110, + 65: 53, + 66: 38, + 67: 39, + 68: $V3, + 74: $Vc, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, - o($Vg, $V0, { 6: 3, 4: 104 }), - { 26: 105, 47: $Vt }, - { 47: [2, 63] }, + o($Vg, $V0, { 6: 3, 4: 111 }), + { 26: 112, 48: $Vt }, + { 48: [2, 68] }, o($V2, [2, 11]), - o($Vf, [2, 55]), + o($Vf, [2, 60]), { 20: 28, - 33: [2, 94], - 49: 108, - 50: 107, - 59: 106, - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 71: 27, - 72: 30, - 73: $V4, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 34: [2, 99], + 50: 115, + 51: 114, + 60: 113, + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 72: 27, + 73: 30, + 74: $V4, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, - o($V2, [2, 26]), + o($V2, [2, 27]), { - 20: 109, - 64: 53, - 65: 38, - 66: 39, - 67: $V3, - 73: $Vc, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 20: 116, + 65: 53, + 66: 38, + 67: 39, + 68: $V3, + 74: $Vc, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, - o($Vx, [2, 66], { - 71: 27, + o($Vy, [2, 71], { + 72: 27, 20: 28, - 64: 29, - 72: 30, - 82: 31, - 83: 32, - 65: 38, - 66: 39, - 90: 41, - 31: 110, - 50: 111, - 49: 112, - 67: $V3, - 73: $V4, - 75: $V5, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, + 65: 29, + 73: 30, + 83: 31, + 84: 32, + 66: 38, + 67: 39, + 91: 41, + 32: 117, + 51: 118, + 50: 119, + 68: $V3, + 74: $V4, + 76: $V5, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, }), - o($Vx, [2, 72], { - 71: 27, + o($Vy, [2, 77], { + 72: 27, 20: 28, - 64: 29, - 72: 30, - 82: 31, - 83: 32, - 65: 38, - 66: 39, - 90: 41, - 36: 113, - 50: 114, - 49: 115, - 67: $V3, - 73: $V4, - 75: $V5, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, + 65: 29, + 73: 30, + 83: 31, + 84: 32, + 66: 38, + 67: 39, + 91: 41, + 37: 120, + 51: 121, + 50: 122, + 68: $V3, + 74: $V4, + 76: $V5, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, }), { 20: 28, - 22: 116, - 23: [2, 58], - 49: 118, - 50: 117, - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 71: 27, - 72: 30, - 73: $V4, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 22: 123, + 23: [2, 63], + 50: 125, + 51: 124, + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 72: 27, + 73: 30, + 74: $V4, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, { 20: 28, - 33: [2, 98], - 49: 121, - 50: 120, - 63: 119, - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 71: 27, - 72: 30, - 73: $V4, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 34: [2, 103], + 50: 128, + 51: 127, + 64: 126, + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 72: 27, + 73: 30, + 74: $V4, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, - { 33: [1, 122] }, - o($Vh, [2, 85]), - { 33: [2, 87] }, - o($Vi, [2, 48], { 91: 65, 92: $Vj, 93: $Vk }), - o($Vl, [2, 35]), - o($Vm, [2, 50]), - o($Vm, [2, 36]), - o($Vp, [2, 107]), - o($Vm, [2, 32]), + { 34: [1, 129] }, + o($Vh, [2, 90]), + { 34: [2, 92] }, + o($Vi, [2, 49], { 92: 65, 93: $Vj, 94: $Vk }), + o($Vl, [2, 36]), + o($Vm, [2, 51]), + o($Vm, [2, 37]), + o($Vp, [2, 112]), + o($Vm, [2, 33]), { 20: 28, - 49: 125, - 50: 124, - 64: 29, - 65: 38, - 66: 39, - 67: $V3, - 68: [2, 102], - 70: 123, - 71: 27, - 72: 30, - 73: $V4, - 75: $V5, - 82: 31, - 83: 32, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, - 90: 41, + 50: 132, + 51: 131, + 65: 29, + 66: 38, + 67: 39, + 68: $V3, + 69: [2, 107], + 71: 130, + 72: 27, + 73: 30, + 74: $V4, + 76: $V5, + 83: 31, + 84: 32, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, + 91: 41, }, - { 56: [1, 126] }, - o($Vq, [2, 89]), - { 56: [2, 91] }, + { 57: [1, 133] }, + o($Vq, [2, 94]), + { 57: [2, 96] }, o($V2, [2, 13]), - { 38: 73, 39: $Vr, 43: 74, 44: $Vs, 45: 128, 46: 127, 47: [2, 82] }, - o($Vu, [2, 76], { 40: 129 }), - { 47: [2, 18] }, o($V2, [2, 14]), - { 33: [1, 130] }, - o($Vh, [2, 93]), - { 33: [2, 95] }, - { 33: [1, 131] }, - { 32: 132, 33: [2, 68], 78: 133, 79: $Vy }, - o($Vu, [2, 65]), - o($Vx, [2, 67]), - { 33: [2, 74], 37: 135, 78: 136, 79: $Vy }, - o($Vu, [2, 71]), - o($Vx, [2, 73]), - { 23: [1, 137] }, - o($Vv, [2, 57]), - { 23: [2, 59] }, - { 33: [1, 138] }, - o($Vh, [2, 97]), - { 33: [2, 99] }, - o($V2, [2, 23]), - { 68: [1, 139] }, - o($Vw, [2, 101]), - { 68: [2, 103] }, + { 48: [2, 53] }, + { 74: [1, 135], 82: [1, 134] }, + o($Vz, [2, 56]), + { 39: 76, 40: $Vr, 44: 77, 45: $Vs, 46: 137, 47: 136, 48: [2, 87] }, + o($Vu, [2, 81], { 41: 138 }), + { 48: [2, 19] }, + o($V2, [2, 15]), + { 34: [1, 139] }, + o($Vh, [2, 98]), + { 34: [2, 100] }, + { 34: [1, 140] }, + { 33: 141, 34: [2, 73], 79: 142, 80: $VA }, + o($Vu, [2, 70]), + o($Vy, [2, 72]), + { 34: [2, 79], 38: 144, 79: 145, 80: $VA }, + o($Vu, [2, 76]), + o($Vy, [2, 78]), + { 23: [1, 146] }, + o($Vv, [2, 62]), + { 23: [2, 64] }, + { 34: [1, 147] }, + o($Vh, [2, 102]), + { 34: [2, 104] }, o($V2, [2, 24]), - { 47: [2, 19] }, - { 47: [2, 83] }, - o($Vx, [2, 78], { - 71: 27, + { 69: [1, 148] }, + o($Vw, [2, 106]), + { 69: [2, 108] }, + o($V2, [2, 25]), + { 34: [1, 149] }, + o($Vz, [2, 55]), + { 48: [2, 20] }, + { 48: [2, 88] }, + o($Vy, [2, 83], { + 72: 27, 20: 28, - 64: 29, - 72: 30, - 82: 31, - 83: 32, - 65: 38, - 66: 39, - 90: 41, - 41: 140, - 50: 141, - 49: 142, - 67: $V3, - 73: $V4, - 75: $V5, - 84: $V6, - 85: $V7, - 86: $V8, - 87: $V9, - 88: $Va, - 89: $Vb, + 65: 29, + 73: 30, + 83: 31, + 84: 32, + 66: 38, + 67: 39, + 91: 41, + 42: 150, + 51: 151, + 50: 152, + 68: $V3, + 74: $V4, + 76: $V5, + 85: $V6, + 86: $V7, + 87: $V8, + 88: $V9, + 89: $Va, + 90: $Vb, }), - o($V2, [2, 25]), - o($V2, [2, 21]), - { 33: [1, 143] }, - { 33: [2, 69] }, - { 73: [1, 145], 80: 144 }, - { 33: [1, 146] }, - { 33: [2, 75] }, + o($V2, [2, 26]), + o($V2, [2, 22]), + { 34: [1, 153] }, + { 34: [2, 74] }, + { 74: [1, 155], 81: 154 }, + { 34: [1, 156] }, + { 34: [2, 80] }, o($Vf, [2, 12]), - o($Vg, [2, 27]), - o($Vm, [2, 33]), - { 33: [2, 80], 42: 147, 78: 148, 79: $Vy }, - o($Vu, [2, 77]), - o($Vx, [2, 79]), - o($Vd, [2, 15]), - { 73: [1, 150], 81: [1, 149] }, - o($Vz, [2, 108]), - o($Ve, [2, 16]), - { 33: [1, 151] }, - { 33: [2, 81] }, - { 33: [2, 37] }, - o($Vz, [2, 109]), - o($Vd, [2, 17]), + o($Vg, [2, 28]), + o($Vm, [2, 34]), + o($Vg, $V0, { 6: 3, 4: 157 }), + { 34: [2, 85], 43: 158, 79: 159, 80: $VA }, + o($Vu, [2, 82]), + o($Vy, [2, 84]), + o($Vd, [2, 16]), + { 74: [1, 161], 82: [1, 160] }, + o($Vz, [2, 113]), + o($Ve, [2, 17]), + { 48: [2, 54] }, + { 34: [1, 162] }, + { 34: [2, 86] }, + { 34: [2, 38] }, + o($Vz, [2, 114]), + o($Vx, [2, 18]), ], defaultActions: { 4: [2, 1], - 62: [2, 46], - 63: [2, 47], - 72: [2, 61], - 74: [2, 20], - 78: [2, 63], - 90: [2, 87], - 100: [2, 91], - 104: [2, 18], - 108: [2, 95], - 118: [2, 59], - 121: [2, 99], - 125: [2, 103], - 127: [2, 19], - 128: [2, 83], - 133: [2, 69], - 136: [2, 75], - 148: [2, 81], - 149: [2, 37], + 62: [2, 47], + 63: [2, 48], + 73: [2, 66], + 77: [2, 21], + 81: [2, 68], + 93: [2, 92], + 103: [2, 96], + 106: [2, 53], + 111: [2, 19], + 115: [2, 100], + 125: [2, 64], + 128: [2, 104], + 132: [2, 108], + 136: [2, 20], + 137: [2, 88], + 142: [2, 74], + 145: [2, 80], + 157: [2, 54], + 159: [2, 86], + 160: [2, 38], }, parseError: function parseError(str, hash) { if (hash.recoverable) { @@ -1807,10 +1874,10 @@ var parser = (function () { break; case 7: - return 67; + return 68; break; case 8: - return 68; + return 69; break; case 9: if (yy.syntax.square === 'string') { @@ -1818,12 +1885,12 @@ var parser = (function () { // escaped literal this.begin('escl'); } else { - return 75; + return 76; } break; case 10: - return 77; + return 78; break; case 11: return 19; @@ -1835,121 +1902,128 @@ var parser = (function () { break; case 13: - return 57; + return 58; break; case 14: - return 61; + return 62; break; case 15: - return 29; + return 30; break; case 16: - return 47; + return 48; break; case 17: this.popState(); - return 44; + return 45; break; case 18: this.popState(); - return 44; + return 45; break; case 19: - return 34; + this.popState(); + return 95; break; case 20: - return 39; + return 96; break; case 21: - return 53; + return 35; break; case 22: - return 48; + return 40; break; case 23: + return 54; + break; + case 24: + return 49; + break; + case 25: this.unput(yy_.yytext); this.popState(); this.begin('com'); break; - case 24: + case 26: this.popState(); return 14; - break; - case 25: - return 48; - break; - case 26: - return 74; break; case 27: - return 73; + return 49; break; case 28: - return 73; + return 75; break; case 29: - return 93; + return 74; break; case 30: - return 92; + return 74; break; - case 31: // ignore whitespace + case 31: + return 94; break; case 32: - this.popState(); - return 56; + return 93; break; - case 33: - this.popState(); - return 33; + case 33: // ignore whitespace break; case 34: - yy_.yytext = strip(1, 2).replace(/\\"/g, '"'); - return 84; + this.popState(); + return 57; break; case 35: - yy_.yytext = strip(1, 2).replace(/\\'/g, "'"); - return 84; + this.popState(); + return 34; break; case 36: - return 89; + yy_.yytext = strip(1, 2).replace(/\\"/g, '"'); + return 85; break; case 37: - return 86; + yy_.yytext = strip(1, 2).replace(/\\'/g, "'"); + return 85; break; case 38: - return 86; + return 90; break; case 39: return 87; break; case 40: - return 88; + return 87; break; case 41: - return 85; + return 88; break; case 42: - return 79; + return 89; break; case 43: - return 81; + return 86; break; case 44: - return 73; + return 80; break; case 45: + return 82; + break; + case 46: + return 74; + break; + case 47: yy_.yytext = yy_.yytext.replace(/\\([\\\]])/g, '$1'); this.popState(); - return 73; + return 74; break; - case 46: + case 48: return 'INVALID'; break; - case 47: + case 49: return 5; break; } @@ -1974,6 +2048,8 @@ var parser = (function () { /^(?:\{\{(~)?\/)/, /^(?:\{\{(~)?\^\s*(~)?\}\})/, /^(?:\{\{(~)?\s*else\s*(~)?\}\})/, + /^(?:\{\{(~)?\s*catch\s*(~)?\}\})/, + /^(?:\{\{(~)?\s*catch\s+as\s+\|)/, /^(?:\{\{(~)?\^)/, /^(?:\{\{(~)?\s*else\b)/, /^(?:\{\{(~)?\{)/, @@ -2008,15 +2084,15 @@ var parser = (function () { mu: { rules: [ 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, - 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 46, 47, + 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 48, 49, ], inclusive: false, }, emu: { rules: [2], inclusive: false }, com: { rules: [6], inclusive: false }, raw: { rules: [3, 4, 5], inclusive: false }, - escl: { rules: [45], inclusive: false }, - INITIAL: { rules: [0, 1, 47], inclusive: true }, + escl: { rules: [47], inclusive: false }, + INITIAL: { rules: [0, 1, 49], inclusive: true }, }, }; return lexer; diff --git a/packages/@handlebars/parser/lib/printer.js b/packages/@handlebars/parser/lib/printer.js index bea44d3a6bf..3c5ed6ce1fc 100644 --- a/packages/@handlebars/parser/lib/printer.js +++ b/packages/@handlebars/parser/lib/printer.js @@ -72,7 +72,7 @@ PrintVisitor.prototype.BlockStatement = PrintVisitor.prototype.DecoratorBlock = if (block.program) { this.padding++; } - out += this.pad('{{^}}'); + out += this.pad(block.catch ? '{{catch}}' : '{{^}}'); this.padding++; out += this.accept(block.inverse); this.padding--; diff --git a/packages/@handlebars/parser/spec/catch.js b/packages/@handlebars/parser/spec/catch.js new file mode 100644 index 00000000000..e2c08e326fa --- /dev/null +++ b/packages/@handlebars/parser/spec/catch.js @@ -0,0 +1,342 @@ +import assert from 'node:assert/strict'; + +import { parse, parseWithoutProcessing, print } from '../lib/index.js'; +import { equalsAst } from './utils.js'; + +const MISPLACED = + 'Unexpected {{catch}}: it must directly follow the body of a {{#...}} block, and cannot appear after {{else}}, twice, or in an inverse {{^...}} block'; +const OUTSIDE = 'Unexpected {{catch}} outside of a block'; +const BLOCK_PARAMS = 'Unexpected block params: "as |...|" is only allowed on block statements'; + +/** Derive a Jison position from a source offset, including multiline tags. */ +function position(source, offset) { + const prefix = source.slice(0, offset).split('\n'); + return { line: prefix.length, column: prefix.at(-1).length }; +} + +/** Compare only the source span, leaving the SourceLocation prototype intact. */ +function assertSpan(loc, source, start, end) { + assert.deepEqual(loc.start, position(source, start)); + assert.deepEqual(loc.end, position(source, end)); +} + +/** Require the delegated diagnostic and Jison's original position display. */ +function assertDiagnostic(source, finalLine, clause) { + assert.throws( + () => parseWithoutProcessing(source), + (error) => { + const lines = error.message.split('\n'); + const offset = clause ? source.lastIndexOf(clause) : source.indexOf('as |'); + assert.equal(lines[0], `Parse error on line ${position(source, offset).line}:`); + assert.ok( + lines.slice(1, -1).some((line) => line.includes('^')), + 'position caret' + ); + assert.ok( + lines.slice(1, -1).some((line) => line.includes('{{')), + 'source display' + ); + assert.equal(lines.at(-1), finalLine); + return true; + } + ); +} + +describe('catch clauses', function () { + for (const tag of ['{{catch}}', '{{~catch}}', '{{catch~}}', '{{~ catch ~}}']) { + it(`parses bare ${tag} on an arbitrary block`, function () { + const source = `{{#ordinary x key=y}}A${tag}B{{/ordinary}}`; + const block = parseWithoutProcessing(source).body[0]; + assert.ok(Object.hasOwn(block, 'catch'), 'own catch marker'); + assert.equal(block.path.original, 'ordinary'); + assert.equal(block.params[0].original, 'x'); + assert.equal(block.hash.pairs[0].key, 'key'); + assert.deepEqual( + block.program.body.map((node) => node.value), + ['A'] + ); + assert.equal(block.inverse.type, 'Program'); + assert.deepEqual( + block.inverse.body.map((node) => node.value), + ['B'] + ); + assert.deepEqual(block.catch.params, []); + assert.equal(block.inverse.blockParams, undefined); + const start = source.indexOf(tag); + assertSpan(block.catch.loc, source, start, start + tag.length); + assert.deepEqual( + block.inverseStrip, + parseWithoutProcessing(source.replace(tag, tag.replace('catch', 'else'))).body[0] + .inverseStrip + ); + }); + } + + for (const [tag, names] of [ + ['{{catch as |e|}}', ['e']], + ['{{catch as |e r|}}', ['e', 'r']], + ['{{~catch as |e r|~}}', ['e', 'r']], + ]) { + it(`parses params in ${tag}`, function () { + const source = `{{#foo}}A${tag}B{{/foo}}`; + const block = parseWithoutProcessing(source, { srcName: 'catch.hbs' }).body[0]; + assert.deepEqual( + block.program.body.map((node) => node.value), + ['A'] + ); + assert.equal(block.inverse.type, 'Program'); + assert.deepEqual( + block.inverse.body.map((node) => node.value), + ['B'] + ); + assert.deepEqual(block.inverse.blockParams, names); + assert.deepEqual( + block.catch.params.map((param) => param.name), + names + ); + const start = source.indexOf(tag); + assertSpan(block.catch.loc, source, start, start + tag.length); + assert.equal(block.catch.loc.source, 'catch.hbs'); + let cursor = source.indexOf('|', start) + 1; + for (const param of block.catch.params) { + const offset = source.indexOf(param.name, cursor); + assertSpan(param.loc, source, offset, offset + param.name.length); + assert.equal(param.loc.source, 'catch.hbs'); + cursor = offset + param.name.length; + } + assert.deepEqual(block.inverseStrip, { + open: tag.startsWith('{{~'), + close: tag.endsWith('~}}'), + }); + }); + } + + it('locates multiline params after identical names in the main body', function () { + const source = + '{{#foo error retry=error}}\nerror retry\n{{~catch as |\n error retry\n|~}}B{{/foo}}'; + const block = parseWithoutProcessing(source).body[0]; + const tagStart = source.indexOf('{{~catch'); + const tagEnd = source.indexOf('}}', tagStart) + 2; + assertSpan(block.catch.loc, source, tagStart, tagEnd); + assert.deepEqual(block.inverse.blockParams, ['error', 'retry']); + for (const param of block.catch.params) { + const offset = source.indexOf(param.name, source.indexOf('|', tagStart)); + assertSpan(param.loc, source, offset, offset + param.name.length); + } + }); + + it('keeps nested catches attached to their own blocks', function () { + const source = '{{#foo}}{{#bar}}A{{catch}}B{{/bar}}{{catch as |e|}}C{{/foo}}'; + const outer = parseWithoutProcessing(source).body[0]; + const inner = outer.program.body[0]; + assert.equal(inner.path.original, 'bar'); + assert.deepEqual(inner.catch.params, []); + assert.equal(inner.inverse.body[0].value, 'B'); + assert.deepEqual( + outer.catch.params.map((param) => param.name), + ['e'] + ); + assert.equal(outer.inverse.body[0].value, 'C'); + }); + + for (const tag of ['{{catch}}', '{{catch as |e r|}}']) { + it(`strips standalone ${tag} exactly like else`, function () { + const source = `before\n{{#foo}}\n A\n ${tag}\n B\n{{/foo}}\nafter`; + const actual = parse(source); + const expected = parse(source.replace(tag, '{{else}}')); + assert.deepEqual(actual.body[1].program.body, expected.body[1].program.body); + assert.deepEqual( + actual.body[1].inverse.body.map(({ value }) => value), + expected.body[1].inverse.body.map(({ value }) => value) + ); + assert.equal(actual.body[0].value, expected.body[0].value); + assert.equal(actual.body[2].value, expected.body[2].value); + assert.equal(actual.body[1].program.body[0].value, ' A\n'); + assert.equal(actual.body[1].inverse.body[0].value, ' B\n'); + const raw = parseWithoutProcessing(source); + assert.equal(raw.body[1].program.body[0].value, '\n A\n '); + assert.equal(raw.body[1].inverse.body[0].value, '\n B\n'); + }); + } + + for (const tag of ['{{~catch}}', '{{catch~}}', '{{~catch~}}', '{{~catch as |e r|~}}']) { + it(`applies whitespace control for ${tag}`, function () { + const source = `{{#foo}} A \n ${tag} \n B {{/foo}}`; + const block = parse(source).body[0]; + const elseTag = tag.includes('as |') ? '{{~else~}}' : tag.replace('catch', 'else'); + const expected = parse(source.replace(tag, elseTag)).body[0]; + assert.deepEqual(block.inverseStrip, expected.inverseStrip); + assert.deepEqual( + block.program.body.map(({ value }) => value), + expected.program.body.map(({ value }) => value) + ); + assert.deepEqual( + block.inverse.body.map(({ value }) => value), + expected.inverse.body.map(({ value }) => value) + ); + }); + } + + it('prints the catch header and inverse block params', function () { + equalsAst( + '{{#foo}} bar {{catch}} baz {{/foo}}', + "BLOCK:\n p%foo\n PROGRAM:\n CONTENT[ ' bar ' ]\n {{catch}}\n CONTENT[ ' baz ' ]" + ); + equalsAst( + '{{#foo}} bar {{catch as |e r|}} baz {{/foo}}', + "BLOCK:\n p%foo\n PROGRAM:\n CONTENT[ ' bar ' ]\n {{catch}}\n BLOCK PARAMS: [ e r ]\n CONTENT[ ' baz ' ]" + ); + equalsAst('{{#foo}}{{catch}}{{/foo}}', 'BLOCK:\n p%foo\n PROGRAM:\n {{catch}}'); + }); +}); + +describe('catch diagnostics', function () { + it('rejects an else or else-if chain following catch', function () { + for (const tag of ['{{else}}', '{{else if bar}}']) { + assert.throws( + () => parseWithoutProcessing(`{{#foo}}A{{catch}}B${tag}C{{/foo}}`), + /^Error: Parse error on line 1:/ + ); + } + }); + + const misplaced = [ + ['after else', '{{#foo}}A{{else}}B', '{{/foo}}'], + ['after else if', '{{#foo}}A{{else if bar}}B', '{{/foo}}'], + ['after an else-if chain', '{{#foo}}A{{else if bar}}B{{else}}C', '{{/foo}}'], + ['a second catch', '{{#foo}}A{{catch}}B', '{{/foo}}'], + ['in an inverse block', '{{^foo}}A', '{{/foo}}'], + ['in a partial block', '{{#> partial}}A', '{{/partial}}'], + ]; + for (const [name, before, after] of misplaced) { + for (const tag of ['{{catch}}', '{{catch as |e r|}}']) { + it(`rejects ${tag} ${name} with M1`, function () { + assertDiagnostic(`${before}\n${tag}${after}`, MISPLACED, tag); + }); + } + } + for (const [name, before, after] of [ + ['at top level', '', ''], + ['in an HTML comment', ''], + ['in an attribute', '
'], + ]) { + for (const tag of ['{{catch}}', '{{catch as |e r|}}']) { + it(`rejects ${tag} ${name} with M2`, function () { + assertDiagnostic(`${before}${tag}${after}`, OUTSIDE, tag); + }); + } + } + for (const source of ['{{foo as |x|}}', '{{foo bar as |x|}}']) { + it(`rejects non-block params in ${source} with M3`, function () { + assertDiagnostic(source, BLOCK_PARAMS); + }); + } +}); + +describe('catch baseline-green guards', function () { + it('keeps catch-prefixed expressions as plain mustaches everywhere', function () { + const sources = [ + '{{catchAll}}', + '{{catch-me}}', + '{{catch x}}', + '{{catch.x}}', + '{{this.catch}}', + '{{catch as}}', + ]; + for (const source of sources) { + const top = parseWithoutProcessing(source).body[0]; + const block = parseWithoutProcessing(`{{#foo}}${source}{{/foo}}`).body[0]; + assert.equal(top.type, 'MustacheStatement'); + const [path, ...params] = source.slice(2, -2).split(' '); + assert.equal(top.path.original, path); + assert.deepEqual( + top.params.map(({ original }) => original), + params + ); + assert.equal(block.program.body[0].type, 'MustacheStatement'); + assert.equal(block.program.body[0].path.original, top.path.original); + assert.deepEqual( + block.program.body[0].params.map(({ original }) => original), + top.params.map(({ original }) => original) + ); + assert.equal(block.inverse, undefined); + assert.equal(Object.hasOwn(block, 'catch'), false); + } + }); + + it('leaves ordinary blocks and else chains unmarked and printing unchanged', function () { + for (const source of [ + '{{#foo}}A{{/foo}}', + '{{#foo}}A{{else}}B{{/foo}}', + '{{#foo}}A{{else if bar}}B{{else}}C{{/foo}}', + '{{^foo}}A{{/foo}}', + ]) { + const block = parse(source).body[0]; + assert.equal(Object.hasOwn(block, 'catch'), false); + if (block.inverse?.chained) { + assert.equal(Object.hasOwn(block.inverse.body[0], 'catch'), false); + } + assert.equal(print(parseWithoutProcessing(source)), print(parse(source))); + } + equalsAst( + '{{#foo}}A{{else}}B{{/foo}}', + "BLOCK:\n p%foo\n PROGRAM:\n CONTENT[ 'A' ]\n {{^}}\n CONTENT[ 'B' ]" + ); + equalsAst( + '{{#foo}} bar {{else if bar}}{{else}} baz {{/foo}}', + "BLOCK:\n p%foo\n PROGRAM:\n CONTENT[ ' bar ' ]\n {{^}}\n BLOCK:\n p%if [p%bar]\n PROGRAM:\n {{^}}\n CONTENT[ ' baz ' ]" + ); + }); + + it('keeps catch inside a Handlebars comment inert', function () { + const ast = parse('{{!-- {{catch}} --}}'); + assert.equal(ast.body[0].type, 'CommentStatement'); + assert.equal(ast.body[0].value, ' {{catch}} '); + }); + + it('preserves the baseline lexer error byte-for-byte', function () { + assert.throws( + () => parseWithoutProcessing('\u0000'), + (error) => { + assert.equal(error.message, 'Lexical error on line 1. Unrecognized text.\n\u0000\n^'); + return true; + } + ); + }); + + it('preserves the baseline hash-value parse error byte-for-byte', function () { + assert.throws( + () => parseWithoutProcessing('{{foo bar=}}'), + (error) => { + assert.equal( + error.message, + "Parse error on line 1:\n{{foo bar=}}\n----------^\nExpecting 'OPEN_SEXPR', 'ID', 'OPEN_ARRAY', 'STRING', 'NUMBER', 'BOOLEAN', 'UNDEFINED', 'NULL', 'DATA', got 'CLOSE'" + ); + return true; + } + ); + }); +}); + +describe('catch review: diagnostic delegation', function () { + for (const [name, source] of [ + ['ordinary block header', '{{#foo x=1 as |a| as |b|}}{{/foo}}'], + ['catch header', '{{#try}}{{catch as |e| as |r|}}{{/try}}'], + ]) { + it(`delegates duplicate block-param errors in the ${name}`, function () { + assert.throws( + () => parseWithoutProcessing(source), + (error) => { + assert.equal(error.hash.token, 'OPEN_BLOCK_PARAMS'); + assert.deepEqual(error.hash.expected, ["'CLOSE'"]); + assert.equal( + error.message.split('\n').at(-1), + "Expecting 'CLOSE', got 'OPEN_BLOCK_PARAMS'", + 'a duplicate param group inside a block header must retain the Jison diagnostic' + ); + return true; + } + ); + }); + } +}); diff --git a/packages/@handlebars/parser/src/handlebars.l b/packages/@handlebars/parser/src/handlebars.l index c23971f2968..9484f423d37 100644 --- a/packages/@handlebars/parser/src/handlebars.l +++ b/packages/@handlebars/parser/src/handlebars.l @@ -97,6 +97,8 @@ ID [^\s!"#%-,\.\/;->@\[-\^`\{-~]+/{LOOKAHEAD} "{{"{LEFT_STRIP}?"/" return 'OPEN_ENDBLOCK'; "{{"{LEFT_STRIP}?"^"\s*{RIGHT_STRIP}?"}}" this.popState(); return 'INVERSE'; "{{"{LEFT_STRIP}?\s*"else"\s*{RIGHT_STRIP}?"}}" this.popState(); return 'INVERSE'; +"{{"{LEFT_STRIP}?\s*"catch"\s*{RIGHT_STRIP}?"}}" this.popState(); return 'CATCH'; +"{{"{LEFT_STRIP}?\s*"catch"\s+"as"\s+"|" return 'OPEN_CATCH_PARAMS'; "{{"{LEFT_STRIP}?"^" return 'OPEN_INVERSE'; "{{"{LEFT_STRIP}?\s*"else" return 'OPEN_INVERSE_CHAIN'; "{{"{LEFT_STRIP}?"{" return 'OPEN_UNESCAPED'; diff --git a/packages/@handlebars/parser/src/handlebars.yy b/packages/@handlebars/parser/src/handlebars.yy index ed26c2c5026..f48f9da5c0b 100644 --- a/packages/@handlebars/parser/src/handlebars.yy +++ b/packages/@handlebars/parser/src/handlebars.yy @@ -48,6 +48,7 @@ openRawBlock block : openBlock program inverseChain? closeBlock -> yy.prepareBlock($1, $2, $3, $4, false, @$) + | openBlock program catchClause closeBlock -> yy.prepareBlock($1, $2, $3, $4, false, @$) | openInverse program inverseAndProgram? closeBlock -> yy.prepareBlock($1, $2, $3, $4, true, @$) ; @@ -177,3 +178,19 @@ pathSegments : pathSegments sep ID { $1.push({part: yy.id($3), original: $3, separator: $2}); $$ = $1; } | ID -> [{part: yy.id($1), original: $1}] ; + +catchClause + : CATCH program -> { strip: yy.stripFlags($1, $1), program: $2, catch: { loc: yy.locInfo(@1), params: [] } } + | OPEN_CATCH_PARAMS catchParams CLOSE_BLOCK_PARAMS CLOSE program { + $$ = { + strip: yy.stripFlags($1, $4), + program: $5, + catch: { loc: yy.locInfo({ first_line: @1.first_line, first_column: @1.first_column, last_line: @4.last_line, last_column: @4.last_column }), params: $2 } + }; + } + ; + +catchParams + : catchParams ID { $1.push({ name: yy.id($2), loc: yy.locInfo(@2) }); $$ = $1; } + | ID -> [{ name: yy.id($1), loc: yy.locInfo(@1) }] + ; diff --git a/packages/@handlebars/parser/types/ast.d.ts b/packages/@handlebars/parser/types/ast.d.ts index b9bb2b6c3e7..f72c92445d0 100644 --- a/packages/@handlebars/parser/types/ast.d.ts +++ b/packages/@handlebars/parser/types/ast.d.ts @@ -42,6 +42,18 @@ export interface BlockStatement extends Statement { openStrip: StripFlags; inverseStrip: StripFlags; closeStrip: StripFlags; + /** Present only when the inverse was written as `{{catch}}` instead of `{{else}}`. */ + catch?: CatchClause; +} + +export interface CatchClause { + loc: SourceLocation; + params: CatchParam[]; +} + +export interface CatchParam { + name: string; + loc: SourceLocation; } export interface DecoratorBlock extends BlockStatement { }