Skip to content

Commit dd4e794

Browse files
committed
refactor(compiler): support passing @content blocks as functions
Previously, foreign component `@content` blocks were rendered eagerly by Angular and could only project a list of nodes. With this change, `@content` can be used to declare a function (e.g. `@content(renderItem; let item)`) that is passed as a callback prop to the foreign component, allowing the foreign component to invoke it with context arguments at its leisure. Implementation details: - Introduces a new runtime instruction `ɵɵforeignContentFn` which wraps the template function so it can be called on demand with arguments by the foreign component. - Extends the compiler AST to parse and validate `@content` parameters. - Maps `@content` parameters to the corresponding positional arguments of the calling foreign component function property.
1 parent 6955467 commit dd4e794

20 files changed

Lines changed: 548 additions & 30 deletions

File tree

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,35 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
453453
],
454454
}]
455455
}] });
456+
export class TestCmpRenderProps {
457+
title = 'Submit';
458+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpRenderProps, deps: [], target: i0.ɵɵFactoryTarget.Component });
459+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestCmpRenderProps, isStandalone: true, selector: "main-render-props", ngImport: i0, template: `
460+
<FancyButton [label]="title">
461+
@content(items; let item, index) {
462+
<span>#{{index}}: {{item}}</span>
463+
}
464+
</FancyButton>
465+
`, isInline: true });
466+
}
467+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpRenderProps, decorators: [{
468+
type: Component,
469+
args: [{
470+
selector: 'main-render-props',
471+
template: `
472+
<FancyButton [label]="title">
473+
@content(items; let item, index) {
474+
<span>#{{index}}: {{item}}</span>
475+
}
476+
</FancyButton>
477+
`,
478+
// @ts-ignore: @angular/core does not expose the `foreignImports` property.
479+
foreignImports: [
480+
// @ts-ignore: @angular/core does not expose the `ForeignComponent` type this expects.
481+
frameworkImport(FancyButton)
482+
],
483+
}]
484+
}] });
456485

457486
/****************************************************************************************************
458487
* PARTIAL FILE: foreign_component.d.ts
@@ -469,4 +498,9 @@ export declare class TestCmpChildren {
469498
static ɵfac: i0.ɵɵFactoryDeclaration<TestCmpChildren, never>;
470499
static ɵcmp: i0.ɵɵComponentDeclaration<TestCmpChildren, "main-children", never, {}, {}, never, never, true, never>;
471500
}
501+
export declare class TestCmpRenderProps {
502+
title: string;
503+
static ɵfac: i0.ɵɵFactoryDeclaration<TestCmpRenderProps, never>;
504+
static ɵcmp: i0.ɵɵComponentDeclaration<TestCmpRenderProps, "main-render-props", never, {}, {}, never, never, true, never>;
505+
}
472506

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ function TestCmpChildren_Children_2_Template(rf, ctx) {
2222
}
2323
}
2424

25+
function TestCmpRenderProps_Items_0_Template(rf, ctx) {
26+
if (rf & 1) {
27+
i0.ɵɵdomElementStart(0, "span");
28+
i0.ɵɵtext(1);
29+
i0.ɵɵdomElementEnd();
30+
}
31+
if (rf & 2) {
32+
const item_r1 = ctx[0];
33+
const index_r2 = ctx[1];
34+
i0.ɵɵadvance();
35+
i0.ɵɵtextInterpolate2("#", index_r2, ": ", item_r1);
36+
}
37+
}
38+
2539
2640

2741
export class TestCmp {
@@ -58,3 +72,23 @@ export class TestCmpChildren {
5872
encapsulation: 2
5973
});
6074
}
75+
76+
77+
78+
export class TestCmpRenderProps {
79+
// ...
80+
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({
81+
type: TestCmpRenderProps,
82+
selectors: [["main-render-props"]],
83+
decls: 2,
84+
vars: 0,
85+
template: function TestCmpRenderProps_Template(rf, ctx) {
86+
if (rf & 1) {
87+
i0.ɵɵdomTemplate(0, TestCmpRenderProps_Items_0_Template, 2, 2);
88+
i0.ɵɵforeignComponent(1, frameworkImport(FancyButton), { label: ctx.title, items: i0.ɵɵforeignContentFn(0) });
89+
}
90+
},
91+
encapsulation: 2
92+
});
93+
}
94+

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ function TestCmpChildren_Children_2_Template(rf, ctx) {
2222
}
2323
}
2424

25+
function TestCmpRenderProps_Items_0_Template(rf, ctx) {
26+
if (rf & 1) {
27+
i0.ɵɵelementStart(0, "span");
28+
i0.ɵɵtext(1);
29+
i0.ɵɵelementEnd();
30+
}
31+
if (rf & 2) {
32+
const item_r1 = ctx[0];
33+
const index_r2 = ctx[1];
34+
i0.ɵɵadvance();
35+
i0.ɵɵtextInterpolate2("#", index_r2, ": ", item_r1);
36+
}
37+
}
38+
2539
2640

2741
export class TestCmp {
@@ -58,3 +72,22 @@ export class TestCmpChildren {
5872
encapsulation: 2
5973
});
6074
}
75+
76+
77+
78+
export class TestCmpRenderProps {
79+
// ...
80+
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({
81+
type: TestCmpRenderProps,
82+
selectors: [["main-render-props"]],
83+
decls: 2,
84+
vars: 0,
85+
template: function TestCmpRenderProps_Template(rf, ctx) {
86+
if (rf & 1) {
87+
i0.ɵɵtemplate(0, TestCmpRenderProps_Items_0_Template, 2, 2);
88+
i0.ɵɵforeignComponent(1, frameworkImport(FancyButton), { label: ctx.title, items: i0.ɵɵforeignContentFn(0) });
89+
}
90+
},
91+
encapsulation: 2
92+
});
93+
}

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,22 @@ export class TestCmpChildren {
5050
title = 'Submit';
5151
}
5252

53+
@Component({
54+
selector: 'main-render-props',
55+
template: `
56+
<FancyButton [label]="title">
57+
@content(items; let item, index) {
58+
<span>#{{index}}: {{item}}</span>
59+
}
60+
</FancyButton>
61+
`,
62+
// @ts-ignore: @angular/core does not expose the `foreignImports` property.
63+
foreignImports: [
64+
// @ts-ignore: @angular/core does not expose the `ForeignComponent` type this expects.
65+
frameworkImport(FancyButton)
66+
],
67+
})
68+
export class TestCmpRenderProps {
69+
title = 'Submit';
70+
}
71+

packages/compiler/src/render3/r3_ast.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ export class DeferredBlockError extends BlockNode implements Node {
342342
export class ContentBlock extends BlockNode implements Node {
343343
constructor(
344344
public name: string,
345+
public variables: Variable[],
345346
public children: Node[],
346347
nameSpan: ParseSourceSpan,
347348
sourceSpan: ParseSourceSpan,

packages/compiler/src/render3/r3_content_blocks.ts

Lines changed: 97 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,48 +7,52 @@
77
*/
88

99
import * as html from '../ml_parser/ast';
10-
import {ParseError} from '../parse_util';
10+
import {ParseError, ParseSourceSpan} from '../parse_util';
1111

1212
import * as t from './r3_ast';
13-
import {IDENTIFIER_PATTERN} from './util';
13+
import {IDENTIFIER_PATTERN, LET_PATTERN} from './util';
1414

1515
/** Creates a content block from an HTML AST node. */
1616
export function createContentBlock(
1717
ast: html.Block,
1818
visitor: html.Visitor,
1919
): {node: t.ContentBlock | null; errors: ParseError[]} {
2020
const errors: ParseError[] = [];
21-
if (ast.parameters.length !== 1) {
21+
if (ast.parameters.length < 1 || ast.parameters.length > 2) {
2222
errors.push(
23-
new ParseError(ast.startSourceSpan, '@content block must have exactly one parameter'),
23+
new ParseError(
24+
ast.startSourceSpan,
25+
'@content block must have one or two parameters, e.g. ' +
26+
'"@content(header)" or "@content(items; let item, index)"',
27+
),
2428
);
2529
return {node: null, errors};
2630
}
2731

28-
const param = ast.parameters[0];
29-
let expr = param.expression.trim();
30-
if (expr.startsWith('(') && expr.endsWith(')')) {
31-
expr = expr.slice(1, -1).trim();
32-
}
33-
34-
const parts = expr.split(',').map((p) => p.trim());
35-
if (parts.length !== 1 || parts[0] === '') {
32+
const nameParam = ast.parameters[0];
33+
const name = nameParam.expression.trim();
34+
if (name.includes(',')) {
3635
errors.push(
37-
new ParseError(ast.startSourceSpan, '@content block must have exactly one parameter'),
36+
new ParseError(ast.startSourceSpan, '@content block must have exactly one name parameter'),
3837
);
3938
return {node: null, errors};
4039
}
4140

42-
const name = parts[0];
4341
if (!IDENTIFIER_PATTERN.test(name)) {
4442
errors.push(
45-
new ParseError(param.sourceSpan, '@content name must be a valid JavaScript identifier'),
43+
new ParseError(nameParam.sourceSpan, '@content name must be a valid JavaScript identifier'),
4644
);
4745
return {node: null, errors};
4846
}
4947

48+
const variables = parseContentBlockVariables(ast, errors);
49+
if (variables === null) {
50+
return {node: null, errors};
51+
}
52+
5053
const node = new t.ContentBlock(
5154
name,
55+
variables,
5256
html.visitAll(visitor, ast.children, ast.children),
5357
ast.nameSpan,
5458
ast.sourceSpan,
@@ -58,3 +62,81 @@ export function createContentBlock(
5862
);
5963
return {node, errors};
6064
}
65+
66+
/** Parses the variables of a content block. */
67+
function parseContentBlockVariables(ast: html.Block, errors: ParseError[]): t.Variable[] | null {
68+
const variables: t.Variable[] = [];
69+
if (ast.parameters.length < 2) {
70+
return variables;
71+
}
72+
73+
const varsParam = ast.parameters[1];
74+
const varsExpr = varsParam.expression.trim();
75+
const letMatch = varsExpr.match(LET_PATTERN);
76+
if (!letMatch) {
77+
errors.push(
78+
new ParseError(
79+
varsParam.sourceSpan,
80+
'@content block variables must start with "let", e.g. "let item, index"',
81+
),
82+
);
83+
return null;
84+
}
85+
86+
const varNames = letMatch[1].split(',').map((v) => v.trim());
87+
const variablesRawString = letMatch[1];
88+
const variablesStartOffset = varsParam.expression.indexOf(variablesRawString);
89+
const variablesStartLocation = varsParam.sourceSpan.start.moveBy(variablesStartOffset);
90+
91+
let searchIndex = 0;
92+
for (let varName of varNames) {
93+
if (varName === '') {
94+
errors.push(new ParseError(varsParam.sourceSpan, 'Invalid variable name in @content block'));
95+
continue;
96+
}
97+
98+
let varSpan: ParseSourceSpan;
99+
const index = variablesRawString.indexOf(varName, searchIndex);
100+
const varStart = variablesStartLocation.moveBy(index);
101+
102+
if (varName.includes('=')) {
103+
const eqIndex = varName.indexOf('=');
104+
const namePart = varName.substring(0, eqIndex).trim();
105+
const fullVarSpan = new ParseSourceSpan(varStart, varStart.moveBy(varName.length));
106+
107+
errors.push(
108+
new ParseError(fullVarSpan, `@content block variables cannot be assigned a value`),
109+
);
110+
111+
varName = namePart;
112+
varSpan = new ParseSourceSpan(varStart, varStart.moveBy(varName.length));
113+
} else {
114+
varSpan = new ParseSourceSpan(varStart, varStart.moveBy(varName.length));
115+
}
116+
117+
if (!IDENTIFIER_PATTERN.test(varName)) {
118+
errors.push(
119+
new ParseError(varSpan, `Variable name "${varName}" must be a valid JavaScript identifier`),
120+
);
121+
searchIndex = index + varName.length;
122+
continue;
123+
}
124+
125+
if (variables.some((v) => v.name === varName)) {
126+
errors.push(
127+
new ParseError(varSpan, `Duplicate variable name "${varName}" in @content block`),
128+
);
129+
searchIndex = index + varName.length;
130+
continue;
131+
}
132+
133+
// @content block variables cannot be assigned an explicit value in the template
134+
// (e.g. "let item = value"). Instead, they are assigned an argument of the calling render function
135+
// based on their positional index. For example if we have a @content block like
136+
// "@content(items; let item, index)" the render function for that block will be called like
137+
// "render(items, ctx[0], ctx[1])".
138+
variables.push(new t.Variable(varName, '', varSpan, varSpan));
139+
searchIndex = index + varName.length;
140+
}
141+
return variables;
142+
}

packages/compiler/src/render3/r3_control_flow.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {ParseError, ParseSourceSpan} from '../parse_util';
1212
import {BindingParser} from '../template_parser/binding_parser';
1313

1414
import * as t from './r3_ast';
15-
import {IDENTIFIER_PATTERN} from './util';
15+
import {IDENTIFIER_PATTERN, LET_PATTERN} from './util';
1616

1717
/** Pattern for the expression in a for loop block. */
1818
const FOR_LOOP_EXPRESSION_PATTERN = /^\s*([0-9A-Za-z_$]*)\s+of\s+([\S\s]*)/;
@@ -26,9 +26,6 @@ const CONDITIONAL_ALIAS_PATTERN = /^(as\s+)(.*)/;
2626
/** Pattern used to identify an `else if` block. */
2727
const ELSE_IF_PATTERN = /^else[^\S\r\n]+if/;
2828

29-
/** Pattern used to identify a `let` parameter. */
30-
const FOR_LOOP_LET_PATTERN = /^let\s+([\S\s]*)/;
31-
3229
/**
3330
* Pattern to group a string into leading whitespace, non whitespace, and trailing whitespace.
3431
* Useful for getting the variable name span when a span can contain leading and trailing space.
@@ -429,7 +426,7 @@ function parseForLoopParameters(
429426
};
430427

431428
for (const param of secondaryParams) {
432-
const letMatch = param.expression.match(FOR_LOOP_LET_PATTERN);
429+
const letMatch = param.expression.match(LET_PATTERN);
433430

434431
if (letMatch !== null) {
435432
const variablesSpan = new ParseSourceSpan(

packages/compiler/src/render3/r3_identifiers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export class Identifiers {
2727

2828
static foreignComponent: o.ExternalReference = {name: 'ɵɵforeignComponent', moduleName: CORE};
2929
static foreignContent: o.ExternalReference = {name: 'ɵɵforeignContent', moduleName: CORE};
30+
static foreignContentFn: o.ExternalReference = {name: 'ɵɵforeignContentFn', moduleName: CORE};
3031

3132
static domElement: o.ExternalReference = {name: 'ɵɵdomElement', moduleName: CORE};
3233
static domElementStart: o.ExternalReference = {name: 'ɵɵdomElementStart', moduleName: CORE};

packages/compiler/src/render3/util.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ const UNSAFE_OBJECT_KEY_NAME_REGEXP = /[-.]/;
1717
/** Pattern used to validate a JavaScript identifier. */
1818
export const IDENTIFIER_PATTERN = /^[$A-Z_][0-9A-Z_$]*$/i;
1919

20+
/** Pattern used to identify a `let` parameter. */
21+
export const LET_PATTERN = /^let\s+([\S\s]*)/;
22+
2023
export function typeWithParameters(type: o.Expression, numParams: number): o.ExpressionType {
2124
if (numParams === 0) {
2225
return o.expressionType(type);

packages/compiler/src/template/pipeline/src/compilation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ export class ViewCompilationUnit extends CompilationUnit {
269269
* Map of declared variables available within this view to the property on the context object
270270
* which they alias.
271271
*/
272-
readonly contextVariables = new Map<string, string>();
272+
readonly contextVariables = new Map<string, string | number>();
273273

274274
/**
275275
* Set of aliases available within this view. An alias is a variable whose provided expression is

0 commit comments

Comments
 (0)