Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';

class TryTest extends RenderTest {
static suiteName = '{{#try}} keyword';

beforeEach() {
this.registerHelper('throw-if', ([condition, message]) => {
if (condition) {
throw new Error(String(message ?? 'boom'));
}

return '';
});
}

@test
'renders the try branch when nothing throws'() {
this.render(`{{#try}}hello {{this.name}}{{else catch as |e|}}caught {{e.message}}{{/try}}`, {
name: 'world',
});

this.assertHTML('hello world');
this.assertStableRerender();

this.rerender({ name: 'glimmer' });
this.assertHTML('hello glimmer');
}

@test
'renders the catch branch when the try branch throws during initial render'() {
this.render(
`{{#try}}before {{throw-if true "kaboom"}} after{{else catch as |e|}}caught: {{e.message}}{{/try}}`
);

this.assertHTML('caught: kaboom');
this.assertStableRerender();
}

@test
'rolls back partially-rendered DOM, including open elements'() {
this.render(
`<ul>{{#try}}<li>first</li><li>{{throw-if true "mid-element"}}</li>{{else catch as |e|}}<li>error: {{e.message}}</li>{{/try}}</ul>`
);

this.assertHTML('<ul><li>error: mid-element</li></ul>');
this.assertStableRerender();
}

@test
'content around the try region is unaffected'() {
this.render(`before-{{#try}}{{throw-if true "x"}}{{else catch}}fallback{{/try}}-after`);

this.assertHTML('before-fallback-after');
this.assertStableRerender();
}

@test
'a plain else block works as a catch branch'() {
this.render(`{{#try}}{{throw-if true "x"}}{{else}}fallback{{/try}}`);

this.assertHTML('fallback');
this.assertStableRerender();
}

@test
'a try region with no catch branch renders nothing on error'() {
this.render(`a{{#try}}{{throw-if true "x"}}{{/try}}b`);

this.assertHTML('a<!---->b');
this.assertStableRerender();
}

@test
'catches errors thrown during update'() {
this.render(
`{{#try}}value: {{throw-if this.shouldThrow "later"}}ok{{else catch as |e|}}caught: {{e.message}}{{/try}}`,
{ shouldThrow: false }
);

this.assertHTML('value: ok');

this.rerender({ shouldThrow: true });
this.assertHTML('caught: later');
}

@test
'recovers when a dependency of the failed render changes back'() {
this.render(
`{{#try}}value: {{throw-if this.shouldThrow "later"}}ok{{else catch as |e|}}caught: {{e.message}}{{/try}}`,
{ shouldThrow: false }
);

this.assertHTML('value: ok');

this.rerender({ shouldThrow: true });
this.assertHTML('caught: later');

this.rerender({ shouldThrow: false });
this.assertHTML('value: ok');
}

@test
'catches errors thrown during initial render and recovers on change'() {
this.render(
`{{#try}}value: {{throw-if this.shouldThrow "early"}}ok{{else catch as |e|}}caught: {{e.message}}{{/try}}`,
{ shouldThrow: true }
);

this.assertHTML('caught: early');

this.rerender({ shouldThrow: false });
this.assertHTML('value: ok');
}

@test
'nested try regions catch independently'() {
this.render(
`{{#try}}outer-start {{#try}}{{throw-if true "inner"}}{{else catch as |e|}}inner-caught: {{e.message}}{{/try}} outer-end{{else catch}}outer-caught{{/try}}`
);

this.assertHTML('outer-start inner-caught: inner outer-end');
this.assertStableRerender();
}

@test
'an error outside any try region still propagates'() {
this.assert.throws(() => {
this.render(`{{throw-if true "unhandled"}}`);
}, /unhandled/u);
}

@test
'conditionals inside the try branch keep working after recovery'() {
this.render(
`{{#try}}{{throw-if this.shouldThrow "x"}}{{#if this.cond}}yes{{else}}no{{/if}}{{else catch}}caught{{/try}}`,
{ shouldThrow: false, cond: true }
);

this.assertHTML('yes');

this.rerender({ shouldThrow: true });
this.assertHTML('caught');

this.rerender({ shouldThrow: false, cond: false });
this.assertHTML('no');

this.rerender({ cond: true });
this.assertHTML('yes');
}
}

jitSuite(TryTest);
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import { VISIT_STMTS } from '../visitors/statements';
import { keywords } from './impl';
import { assertCurryKeyword } from './utils/curry';

function isCatchInvocation(stmt: ASTv2.ContentNode): stmt is ASTv2.InvokeBlock {
if (stmt.type !== 'InvokeBlock') return false;

let callee = stmt.callee;

return callee.type === 'Path' && callee.ref.type === 'Free' && callee.ref.name === 'catch';
}

export const BLOCK_KEYWORDS = keywords('Block')
.kw('in-element', {
assert(node: ASTv2.InvokeBlock): Result<{
Expand Down Expand Up @@ -213,6 +221,76 @@ export const BLOCK_KEYWORDS = keywords('Block')
);
},
})
.kw('try', {
assert(node: ASTv2.InvokeBlock): Result<{
catchBlock: ASTv2.NamedBlock | null;
}> {
let { args } = node;

if (!args.named.isEmpty() || args.positional.size > 0) {
return Err(generateSyntaxError(`{{#try}} does not accept any parameters`, node.loc));
}

let inverse = node.blocks.get('else');

if (inverse === null) {
return Ok({ catchBlock: null });
}

// `{{#try}}...{{else catch as |error|}}...{{/try}}` parses as an `else`
// block whose only statement is an invocation of the `catch` block
// keyword. Unwrap it so the catch body (and its `|error|` block param)
// becomes the handler block. A plain `{{else}}` block is also allowed
// as a handler that ignores the error value.
let body = inverse.block.body;
let first = body[0];

if (body.length === 1 && first !== undefined && isCatchInvocation(first)) {
let stmt = first;

if (!stmt.args.named.isEmpty() || stmt.args.positional.size > 0) {
return Err(generateSyntaxError(`{{catch}} does not accept any parameters`, stmt.loc));
}

return Ok({ catchBlock: stmt.blocks.get('default') });
}

return Ok({ catchBlock: inverse });
},

translate(
{ node, state }: { node: ASTv2.InvokeBlock; state: NormalizationState },
{ catchBlock }: { catchBlock: ASTv2.NamedBlock | null }
): Result<mir.TryCatch> {
let block = node.blocks.get('default');

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.TryCatch({
loc: node.loc,
block,
catchBlock,
})
);
},
})
.kw('catch', {
assert(node: ASTv2.InvokeBlock): Result<never> {
return Err(
generateSyntaxError(
`{{catch}} can only be used as \`{{else catch as |error|}}\` inside {{#try}}`,
node.loc
)
);
},

translate(): Result<never> {
throw new Error(`unreachable: {{catch}} always fails to assert`);
},
})
.kw('each', {
assert(node: ASTv2.InvokeBlock): Result<{
value: ASTv2.ExpressionNode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ export default class StrictModeValidationPass {
case 'If':
return this.If(statement);

case 'TryCatch':
return this.TryCatch(statement);

case 'Each':
return this.Each(statement);

Expand Down Expand Up @@ -287,6 +290,16 @@ export default class StrictModeValidationPass {
});
}

TryCatch(statement: mir.TryCatch): Result<null> {
return this.NamedBlock(statement.block).andThen(() => {
if (statement.catchBlock) {
return this.NamedBlock(statement.catchBlock);
} else {
return Ok(null);
}
});
}

Each(statement: mir.Each): Result<null> {
return this.Expression(statement.value, statement)
.andThen(() => {
Expand Down
10 changes: 10 additions & 0 deletions packages/@glimmer/compiler/lib/passes/2-encoding/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export class ContentEncoder {
return this.InvokeBlock(stmt);
case 'If':
return this.If(stmt);
case 'TryCatch':
return this.TryCatch(stmt);
case 'Each':
return this.Each(stmt);
case 'Let':
Expand Down Expand Up @@ -201,6 +203,14 @@ export class ContentEncoder {
];
}

TryCatch({ block, catchBlock }: mir.TryCatch): WireFormat.Statements.TryCatch {
return [
SexpOpcodes.TryCatch,
CONTENT.NamedBlock(block)[1],
catchBlock ? CONTENT.NamedBlock(catchBlock)[1] : null,
];
}

Each({ value, key, block, inverse }: mir.Each): WireFormat.Statements.Each {
return [
SexpOpcodes.Each,
Expand Down
8 changes: 7 additions & 1 deletion packages/@glimmer/compiler/lib/passes/2-encoding/mir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export class IfInline extends node('IfInline').fields<{
falsy: ExpressionNode | null;
}>() {}

export class TryCatch extends node('TryCatch').fields<{
block: NamedBlock;
catchBlock: NamedBlock | null;
}>() {}

export class Each extends node('Each').fields<{
value: ExpressionNode;
key: ExpressionNode | null;
Expand Down Expand Up @@ -220,4 +225,5 @@ export type Statement =
| Each
| Let
| WithDynamicVars
| InvokeComponent;
| InvokeComponent
| TryCatch;
7 changes: 7 additions & 0 deletions packages/@glimmer/compiler/lib/wire-format-debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ export default class WireFormatDebugger {
opcode[3] ? this.formatBlock(opcode[3]) : null,
];

case Op.TryCatch:
return [
'try',
this.formatBlock(opcode[1]),
opcode[2] ? this.formatBlock(opcode[2]) : null,
];

case Op.IfInline:
return ['if-inline'];

Expand Down
8 changes: 7 additions & 1 deletion packages/@glimmer/constants/lib/syscall-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
VmDynamicModifier,
VmEnter,
VmEnterList,
VmEnterTry,
VmExit,
VmExitList,
VmFetch,
Expand Down Expand Up @@ -61,6 +62,7 @@ import type {
VmPop,
VmPopDynamicScope,
VmPopRemoteElement,
VmPopTryFrame,
VmPopScope,
VmPopulateLayout,
VmPrepareArgs,
Expand All @@ -72,6 +74,7 @@ import type {
VmPushDynamicComponentInstance,
VmPushDynamicScope,
VmPushEmptyArgs,
VmPushTryFrame,
VmPushRemoteElement,
VmPushSymbolTable,
VmPutComponentOperations,
Expand Down Expand Up @@ -183,7 +186,10 @@ 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_ENTER_TRY_OP = 113 satisfies VmEnterTry;
export const VM_PUSH_TRY_FRAME_OP = 114 satisfies VmPushTryFrame;
export const VM_POP_TRY_FRAME_OP = 115 satisfies VmPopTryFrame;
export const VM_SYSCALL_SIZE = 116 satisfies VmSize;

export function isOp(value: number): value is VmOp {
return value >= 16;
Expand Down
Loading
Loading