Skip to content

Commit 6e4b947

Browse files
committed
refactor(core): implement @boundary runtime primitives and AST nodes
Add the runtime primitives `ɵɵboundaryCreate` and `ɵɵboundaryUpdate` to the core instructions, which handle synchronous view destruction and provide the `ON_ERROR` interceptor hooks. Also include the initial compiler AST representations for the new syntax including the Lexer tokenization and HTML Parser integration. This lays the foundational structure for `@boundary` prior to code generation.
1 parent e71b35d commit 6e4b947

35 files changed

Lines changed: 1643 additions & 29 deletions
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing';
10+
import {NgtscTestEnvironment} from './env';
11+
import {loadStandardTestFiles, getSourceCodeForDiagnostic} from '../../src/ngtsc/testing';
12+
13+
const testFiles = loadStandardTestFiles({fakeCommon: true});
14+
15+
runInEachFileSystem(() => {
16+
describe('ngtsc @boundary type checking', () => {
17+
let env!: NgtscTestEnvironment;
18+
19+
beforeEach(() => {
20+
env = NgtscTestEnvironment.setup(testFiles);
21+
env.tsconfig({fullTemplateTypeCheck: true, strictTemplates: true});
22+
});
23+
24+
it('should type check error alias as Error', () => {
25+
env.write(
26+
'test.ts',
27+
`
28+
import {Component} from '@angular/core';
29+
30+
@Component({
31+
selector: 'test-cmp',
32+
template: \`
33+
@boundary {
34+
<div>Normal</div>
35+
} @error (let err) {
36+
<div>{{ err.message }}</div>
37+
}
38+
\`,
39+
standalone: true,
40+
})
41+
export class TestCmp {}
42+
`,
43+
);
44+
45+
const diags = env.driveDiagnostics();
46+
if (diags.length > 0) {
47+
console.log(
48+
'DIAGS for should type check error alias as Error:',
49+
JSON.stringify(
50+
diags.map((d) =>
51+
typeof d.messageText === 'string' ? d.messageText : d.messageText.messageText,
52+
),
53+
),
54+
);
55+
}
56+
expect(diags.length).toBe(0);
57+
});
58+
59+
it('should error when accessing non-existent properties on error alias', () => {
60+
env.write(
61+
'test.ts',
62+
`
63+
import {Component} from '@angular/core';
64+
65+
@Component({
66+
selector: 'test-cmp',
67+
template: \`
68+
@boundary {
69+
<div>Normal</div>
70+
} @error (let err) {
71+
<div>{{ err.nonExistentProperty }}</div>
72+
}
73+
\`,
74+
standalone: true,
75+
})
76+
export class TestCmp {}
77+
`,
78+
);
79+
80+
const diags = env.driveDiagnostics();
81+
expect(diags.length).toBe(1);
82+
expect(diags[0].messageText).toContain(
83+
"Property 'nonExistentProperty' does not exist on type 'Error'",
84+
);
85+
});
86+
87+
it('should narrowing type using condition when available', () => {
88+
env.write(
89+
'test.ts',
90+
`
91+
import {Component} from '@angular/core';
92+
93+
class CustomError extends Error {
94+
customField = 'test';
95+
}
96+
97+
@Component({
98+
selector: 'test-cmp',
99+
template: \`
100+
@boundary {
101+
<div>Normal</div>
102+
} @error (let err; when err instanceof CustomError) {
103+
<div>{{ err.customField }}</div>
104+
} @error (let err) {
105+
<div>Fallback</div>
106+
}
107+
\`,
108+
standalone: true,
109+
})
110+
export class TestCmp {
111+
CustomError = CustomError; // Expose to template if needed, though with control flow it uses standard TS scope in some regards
112+
}
113+
`,
114+
);
115+
116+
// Wait, instanceof uses component scope resolution or typescript scope?
117+
// In @error context, instanceof check condition inside of `if` clause uses standard TS AST!
118+
// So condition `err instanceof CustomError` IS evaluated in the TCB!
119+
const diags = env.driveDiagnostics();
120+
if (diags.length > 0) {
121+
console.log(
122+
'DIAGS for should narrowing type using condition when available:',
123+
JSON.stringify(
124+
diags.map((d) =>
125+
typeof d.messageText === 'string' ? d.messageText : d.messageText.messageText,
126+
),
127+
),
128+
);
129+
}
130+
expect(diags.length).toBe(0);
131+
});
132+
});
133+
});

packages/compiler/src/combined_visitor.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,17 @@ export class CombinedRecursiveAstVisitor extends RecursiveAstVisitor implements
125125
this.visitAllTemplateNodes(block.children);
126126
}
127127

128+
visitBoundaryBlock(block: t.BoundaryBlock): void {
129+
this.visitAllTemplateNodes(block.children);
130+
this.visitAllTemplateNodes(block.errorBlocks);
131+
}
132+
133+
visitBoundaryErrorBlock(block: t.BoundaryErrorBlock): void {
134+
this.visitAllTemplateNodes(block.contextVariables);
135+
block.expression && this.visit(block.expression);
136+
this.visitAllTemplateNodes(block.children);
137+
}
138+
128139
visitLetDeclaration(decl: t.LetDeclaration): void {
129140
this.visit(decl.value);
130141
}

packages/compiler/src/compiler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ export {
153153
BoundDeferredTrigger as TmplAstBoundDeferredTrigger,
154154
BoundEvent as TmplAstBoundEvent,
155155
BoundText as TmplAstBoundText,
156+
BoundaryBlock as TmplAstBoundaryBlock,
157+
BoundaryErrorBlock as TmplAstBoundaryErrorBlock,
156158
Content as TmplAstContent,
157159
DeferredBlock as TmplAstDeferredBlock,
158160
DeferredBlockError as TmplAstDeferredBlockError,

packages/compiler/src/ml_parser/lexer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ const SUPPORTED_BLOCKS = [
152152
'@defer',
153153
'@placeholder',
154154
'@loading',
155+
'@boundary',
155156
'@error',
156157
] as const;
157158

packages/compiler/src/render3/r3_ast.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,48 @@ export class DeferredBlockError extends BlockNode implements Node {
339339
}
340340
}
341341

342+
export class BoundaryBlock extends BlockNode implements Node {
343+
constructor(
344+
public children: Node[],
345+
public errorBlocks: BoundaryErrorBlock[],
346+
nameSpan: ParseSourceSpan,
347+
sourceSpan: ParseSourceSpan,
348+
startSourceSpan: ParseSourceSpan,
349+
endSourceSpan: ParseSourceSpan | null,
350+
public i18n?: I18nMeta,
351+
) {
352+
super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan);
353+
}
354+
355+
visit<Result>(visitor: Visitor<Result>): Result {
356+
return visitor.visitBoundaryBlock(this);
357+
}
358+
359+
visitAll(visitor: Visitor<unknown>): void {
360+
visitAll(visitor, this.children);
361+
visitAll(visitor, this.errorBlocks);
362+
}
363+
}
364+
365+
export class BoundaryErrorBlock extends BlockNode implements Node {
366+
constructor(
367+
public children: Node[],
368+
public contextVariables: Variable[],
369+
public expression: AST | null,
370+
nameSpan: ParseSourceSpan,
371+
sourceSpan: ParseSourceSpan,
372+
startSourceSpan: ParseSourceSpan,
373+
endSourceSpan: ParseSourceSpan | null,
374+
public i18n?: I18nMeta,
375+
) {
376+
super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan);
377+
}
378+
379+
visit<Result>(visitor: Visitor<Result>): Result {
380+
return visitor.visitBoundaryErrorBlock(this);
381+
}
382+
}
383+
342384
export interface DeferredBlockTriggers {
343385
when?: BoundDeferredTrigger;
344386
idle?: IdleDeferredTrigger;
@@ -758,6 +800,8 @@ export interface Visitor<Result = any> {
758800
visitForLoopBlockEmpty(block: ForLoopBlockEmpty): Result;
759801
visitIfBlock(block: IfBlock): Result;
760802
visitIfBlockBranch(block: IfBlockBranch): Result;
803+
visitBoundaryBlock(block: BoundaryBlock): Result;
804+
visitBoundaryErrorBlock(block: BoundaryErrorBlock): Result;
761805
visitUnknownBlock(block: UnknownBlock): Result;
762806
visitLetDeclaration(decl: LetDeclaration): Result;
763807
visitComponent(component: Component): Result;
@@ -818,6 +862,13 @@ export class RecursiveVisitor implements Visitor<void> {
818862
visitAll(this, block.children);
819863
block.expressionAlias?.visit(this);
820864
}
865+
visitBoundaryBlock(block: BoundaryBlock): void {
866+
block.visitAll(this);
867+
}
868+
visitBoundaryErrorBlock(block: BoundaryErrorBlock): void {
869+
const blockItems = [...block.contextVariables, ...block.children];
870+
visitAll(this, blockItems);
871+
}
821872
visitContent(content: Content): void {
822873
visitAll(this, content.children);
823874
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import {AST} from '../expression_parser/ast';
2+
import * as html from '../ml_parser/ast';
3+
import {ParseError, ParseSourceSpan} from '../parse_util';
4+
import {BindingParser} from '../template_parser/binding_parser';
5+
6+
import * as t from './r3_ast';
7+
8+
/** Pattern used to identify a boundary `let` parameter. */
9+
const LET_PATTERN = /^(let\s+)(.*)/;
10+
11+
/** Pattern used to identify a boundary `when` expression. */
12+
const WHEN_PATTERN = /^(when\s+)(.*)/;
13+
14+
/** Pattern used to validate a JavaScript identifier. */
15+
const IDENTIFIER_PATTERN = /^[$A-Z_][0-9A-Z_$]*$/i;
16+
17+
export function isConnectedBoundaryErrorBlock(name: string): boolean {
18+
return name === 'error';
19+
}
20+
21+
export function createBoundaryBlock(
22+
ast: html.Block,
23+
connectedBlocks: html.Block[],
24+
visitor: html.Visitor,
25+
bindingParser: BindingParser,
26+
): {node: t.BoundaryBlock | null; errors: ParseError[]} {
27+
const errors: ParseError[] = [];
28+
const errorBlocks: t.BoundaryErrorBlock[] = [];
29+
30+
for (const block of connectedBlocks) {
31+
if (block.name === 'error') {
32+
const contextVariables: t.Variable[] = [];
33+
let expression: AST | null = null;
34+
35+
for (const param of block.parameters) {
36+
const letMatch = param.expression.match(LET_PATTERN);
37+
if (letMatch) {
38+
const variablesExpression = letMatch[2].trim();
39+
const parts = variablesExpression.split(',');
40+
41+
for (const part of parts) {
42+
const expressionParts = part.split('=');
43+
const name = expressionParts[0].trim();
44+
const variableName =
45+
expressionParts.length === 2 ? expressionParts[1].trim() : '$error';
46+
47+
if (name.length === 0) {
48+
errors.push(
49+
new ParseError(
50+
param.sourceSpan,
51+
`Invalid @error block "let" parameter. Variable name cannot be empty`,
52+
),
53+
);
54+
} else if (!IDENTIFIER_PATTERN.test(name)) {
55+
errors.push(
56+
new ParseError(
57+
param.sourceSpan,
58+
`"let" parameter must be a valid JavaScript identifier`,
59+
),
60+
);
61+
} else if (variableName !== '$error' && variableName !== '$retry') {
62+
errors.push(
63+
new ParseError(
64+
param.sourceSpan,
65+
`Unknown context variable "${variableName}". Only "$error" and "$retry" are allowed`,
66+
),
67+
);
68+
} else if (contextVariables.some((v) => v.name === name)) {
69+
errors.push(
70+
new ParseError(param.sourceSpan, `Duplicate "let" parameter variable "${name}"`),
71+
);
72+
} else {
73+
const varSpan = param.sourceSpan;
74+
contextVariables.push(new t.Variable(name, variableName, varSpan, varSpan));
75+
}
76+
}
77+
continue;
78+
}
79+
80+
const whenMatch = param.expression.match(WHEN_PATTERN);
81+
if (whenMatch) {
82+
if (expression !== null) {
83+
errors.push(
84+
new ParseError(param.sourceSpan, '@error block can only have one "when" expression'),
85+
);
86+
} else {
87+
const start = param.expression.indexOf(whenMatch[2]);
88+
const end = start + whenMatch[2].length;
89+
const expressionAST = bindingParser.parseBinding(
90+
param.expression.slice(start, end),
91+
false,
92+
param.sourceSpan,
93+
param.sourceSpan.start.offset + start,
94+
);
95+
expression = expressionAST.ast;
96+
}
97+
continue;
98+
}
99+
100+
errors.push(
101+
new ParseError(
102+
param.sourceSpan,
103+
`Unrecognized @error block parameter "${param.expression}"`,
104+
),
105+
);
106+
}
107+
108+
errorBlocks.push(
109+
new t.BoundaryErrorBlock(
110+
html.visitAll(visitor, block.children, block.children),
111+
contextVariables,
112+
expression,
113+
block.nameSpan,
114+
block.sourceSpan,
115+
block.startSourceSpan,
116+
block.endSourceSpan,
117+
block.i18n,
118+
),
119+
);
120+
} else {
121+
errors.push(
122+
new ParseError(block.sourceSpan, `Unrecognized @boundary connected block @${block.name}`),
123+
);
124+
}
125+
}
126+
127+
const node = new t.BoundaryBlock(
128+
html.visitAll(visitor, ast.children, ast.children),
129+
errorBlocks,
130+
ast.nameSpan,
131+
ast.sourceSpan,
132+
ast.startSourceSpan,
133+
ast.endSourceSpan,
134+
ast.i18n,
135+
);
136+
137+
return {node, errors};
138+
}

packages/compiler/src/render3/r3_identifiers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ export class Identifiers {
198198
moduleName: CORE,
199199
};
200200
static conditional: o.ExternalReference = {name: 'ɵɵconditional', moduleName: CORE};
201+
static boundaryCreate: o.ExternalReference = {name: 'ɵɵboundaryCreate', moduleName: CORE};
202+
static boundaryUpdate: o.ExternalReference = {name: 'ɵɵboundaryUpdate', moduleName: CORE};
203+
static getBoundary: o.ExternalReference = {name: 'ɵɵgetBoundary', moduleName: CORE};
201204
static repeater: o.ExternalReference = {name: 'ɵɵrepeater', moduleName: CORE};
202205
static repeaterCreate: o.ExternalReference = {name: 'ɵɵrepeaterCreate', moduleName: CORE};
203206
static repeaterTrackByIndex: o.ExternalReference = {

0 commit comments

Comments
 (0)