Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Merged
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
Expand Up @@ -453,6 +453,35 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
],
}]
}] });
export class TestCmpRenderProps {
title = 'Submit';
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpRenderProps, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestCmpRenderProps, isStandalone: true, selector: "main-render-props", ngImport: i0, template: `
<FancyButton [label]="title">
@content(items; let item, index) {
<span>#{{index}}: {{item}}</span>
}
</FancyButton>
`, isInline: true });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpRenderProps, decorators: [{
type: Component,
args: [{
selector: 'main-render-props',
template: `
<FancyButton [label]="title">
@content(items; let item, index) {
<span>#{{index}}: {{item}}</span>
}
</FancyButton>
`,
// @ts-ignore: @angular/core does not expose the `foreignImports` property.
foreignImports: [
// @ts-ignore: @angular/core does not expose the `ForeignComponent` type this expects.
frameworkImport(FancyButton)
],
}]
}] });

/****************************************************************************************************
* PARTIAL FILE: foreign_component.d.ts
Expand All @@ -469,4 +498,9 @@ export declare class TestCmpChildren {
static ɵfac: i0.ɵɵFactoryDeclaration<TestCmpChildren, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<TestCmpChildren, "main-children", never, {}, {}, never, never, true, never>;
}
export declare class TestCmpRenderProps {
title: string;
static ɵfac: i0.ɵɵFactoryDeclaration<TestCmpRenderProps, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<TestCmpRenderProps, "main-render-props", never, {}, {}, never, never, true, never>;
}

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ function TestCmpChildren_Children_2_Template(rf, ctx) {
}
}

function TestCmpRenderProps_Items_0_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵdomElementStart(0, "span");
i0.ɵɵtext(1);
i0.ɵɵdomElementEnd();
}
if (rf & 2) {
const item_r1 = ctx[0];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How exactly does this work? How would the foreign component communicate a change to the values of the parameters and when would that be written to the DOM as part of the update pass?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes are communicated through reactivity–if you want to provide mutable props, you pass a signal/function instead of a value. It's up to the foreign component to reactively track those reads and schedule updates accordingly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Foreign components are created in the creation block. Updates are triggered reactively
// through directly passed signal properties, alleviating the need for any explicit update
// operations.
unit.create.push(
ir.createForeignComponentOp(id, foreignComp.component, props, element.startSourceSpan),
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check out the reactivity tests for an example of this working end to end.

const index_r2 = ctx[1];
i0.ɵɵadvance();
i0.ɵɵtextInterpolate2("#", index_r2, ": ", item_r1);
}
}


export class TestCmp {
Expand All @@ -31,9 +45,10 @@ export class TestCmp {
selectors: [["main"]],
decls: 1,
vars: 0,
consts: [frameworkImport(FancyButton)],
template: function TestCmp_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵforeignComponent(0, frameworkImport(FancyButton), { class: "btn-cls", "unsafe-attr": "value", label: ctx.title, "unsafe-input": ctx.title });
i0.ɵɵforeignComponent(0, 0, { class: "btn-cls", "unsafe-attr": "value", label: ctx.title, "unsafe-input": ctx.title });
}
},
encapsulation: 2
Expand All @@ -49,12 +64,34 @@ export class TestCmpChildren {
selectors: [["main-children"]],
decls: 4,
vars: 0,
consts: [frameworkImport(FancyButton)],
template: function TestCmpChildren_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵdomTemplate(0, TestCmpChildren_Icon_0_Template, 2, 0)(1, TestCmpChildren_Description_1_Template, 2, 0)(2, TestCmpChildren_Children_2_Template, 2, 0);
i0.ɵɵforeignComponent(3, frameworkImport(FancyButton), { label: ctx.title, icon: i0.ɵɵforeignContent(0), description: i0.ɵɵforeignContent(1), children: i0.ɵɵforeignContent(2) });
i0.ɵɵforeignComponent(3, 0, { label: ctx.title, icon: i0.ɵɵforeignContent(0), description: i0.ɵɵforeignContent(1), children: i0.ɵɵforeignContent(2) });
}
},
encapsulation: 2
});
}


export class TestCmpRenderProps {
// ...
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({
type: TestCmpRenderProps,
selectors: [["main-render-props"]],
decls: 2,
vars: 0,
consts: [frameworkImport(FancyButton)],
template: function TestCmpRenderProps_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵdomTemplate(0, TestCmpRenderProps_Items_0_Template, 2, 2);
i0.ɵɵforeignComponent(1, 0, { label: ctx.title, items: i0.ɵɵforeignContentFn(0, 0) });
}
},
encapsulation: 2
});
}

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ function TestCmpChildren_Children_2_Template(rf, ctx) {
}
}

function TestCmpRenderProps_Items_0_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵelementStart(0, "span");
i0.ɵɵtext(1);
i0.ɵɵelementEnd();
}
if (rf & 2) {
const item_r1 = ctx[0];
const index_r2 = ctx[1];
i0.ɵɵadvance();
i0.ɵɵtextInterpolate2("#", index_r2, ": ", item_r1);
}
}


export class TestCmp {
Expand All @@ -31,9 +45,10 @@ export class TestCmp {
selectors: [["main"]],
decls: 1,
vars: 0,
consts: [frameworkImport(FancyButton)],
template: function TestCmp_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵforeignComponent(0, frameworkImport(FancyButton), { class: "btn-cls", "unsafe-attr": "value", label: ctx.title, "unsafe-input": ctx.title });
i0.ɵɵforeignComponent(0, 0, { class: "btn-cls", "unsafe-attr": "value", label: ctx.title, "unsafe-input": ctx.title });
}
},
encapsulation: 2
Expand All @@ -49,10 +64,31 @@ export class TestCmpChildren {
selectors: [["main-children"]],
decls: 4,
vars: 0,
consts: [frameworkImport(FancyButton)],
template: function TestCmpChildren_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵtemplate(0, TestCmpChildren_Icon_0_Template, 2, 0)(1, TestCmpChildren_Description_1_Template, 2, 0)(2, TestCmpChildren_Children_2_Template, 2, 0);
i0.ɵɵforeignComponent(3, frameworkImport(FancyButton), { label: ctx.title, icon: i0.ɵɵforeignContent(0), description: i0.ɵɵforeignContent(1), children: i0.ɵɵforeignContent(2) });
i0.ɵɵforeignComponent(3, 0, { label: ctx.title, icon: i0.ɵɵforeignContent(0), description: i0.ɵɵforeignContent(1), children: i0.ɵɵforeignContent(2) });
}
},
encapsulation: 2
});
}


export class TestCmpRenderProps {
// ...
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({
type: TestCmpRenderProps,
selectors: [["main-render-props"]],
decls: 2,
vars: 0,
consts: [frameworkImport(FancyButton)],
template: function TestCmpRenderProps_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵtemplate(0, TestCmpRenderProps_Items_0_Template, 2, 2);
i0.ɵɵforeignComponent(1, 0, { label: ctx.title, items: i0.ɵɵforeignContentFn(0, 0) });
}
},
encapsulation: 2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,22 @@ export class TestCmpChildren {
title = 'Submit';
}

@Component({
selector: 'main-render-props',
template: `
<FancyButton [label]="title">
@content(items; let item, index) {
<span>#{{index}}: {{item}}</span>
}
</FancyButton>
`,
// @ts-ignore: @angular/core does not expose the `foreignImports` property.
foreignImports: [
// @ts-ignore: @angular/core does not expose the `ForeignComponent` type this expects.
frameworkImport(FancyButton)
],
})
export class TestCmpRenderProps {
title = 'Submit';
}

1 change: 1 addition & 0 deletions 1 packages/compiler/src/render3/r3_ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ export class DeferredBlockError extends BlockNode implements Node {
export class ContentBlock extends BlockNode implements Node {
constructor(
public name: string,
public variables: Variable[],
public children: Node[],
nameSpan: ParseSourceSpan,
sourceSpan: ParseSourceSpan,
Expand Down
112 changes: 97 additions & 15 deletions 112 packages/compiler/src/render3/r3_content_blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,48 +7,52 @@
*/

import * as html from '../ml_parser/ast';
import {ParseError} from '../parse_util';
import {ParseError, ParseSourceSpan} from '../parse_util';

import * as t from './r3_ast';
import {IDENTIFIER_PATTERN} from './util';
import {IDENTIFIER_PATTERN, LET_PATTERN} from './util';

/** Creates a content block from an HTML AST node. */
export function createContentBlock(
ast: html.Block,
visitor: html.Visitor,
): {node: t.ContentBlock | null; errors: ParseError[]} {
const errors: ParseError[] = [];
if (ast.parameters.length !== 1) {
if (ast.parameters.length < 1 || ast.parameters.length > 2) {
errors.push(
new ParseError(ast.startSourceSpan, '@content block must have exactly one parameter'),
new ParseError(
ast.startSourceSpan,
'@content block must have one or two parameters, e.g. ' +
'"@content(header)" or "@content(items; let item, index)"',
),
);
return {node: null, errors};
}

const param = ast.parameters[0];
let expr = param.expression.trim();
if (expr.startsWith('(') && expr.endsWith(')')) {
expr = expr.slice(1, -1).trim();
}

const parts = expr.split(',').map((p) => p.trim());
if (parts.length !== 1 || parts[0] === '') {
const nameParam = ast.parameters[0];
const name = nameParam.expression.trim();
if (name.includes(',')) {
errors.push(
new ParseError(ast.startSourceSpan, '@content block must have exactly one parameter'),
new ParseError(ast.startSourceSpan, '@content block must have exactly one name parameter'),
);
return {node: null, errors};
}

const name = parts[0];
if (!IDENTIFIER_PATTERN.test(name)) {
errors.push(
new ParseError(param.sourceSpan, '@content name must be a valid JavaScript identifier'),
new ParseError(nameParam.sourceSpan, '@content name must be a valid JavaScript identifier'),
);
return {node: null, errors};
}

const variables = parseContentBlockVariables(ast, errors);
if (variables === null) {
return {node: null, errors};
}

const node = new t.ContentBlock(
name,
variables,
html.visitAll(visitor, ast.children, ast.children),
ast.nameSpan,
ast.sourceSpan,
Expand All @@ -58,3 +62,81 @@ export function createContentBlock(
);
return {node, errors};
}

/** Parses the variables of a content block. */
function parseContentBlockVariables(ast: html.Block, errors: ParseError[]): t.Variable[] | null {
const variables: t.Variable[] = [];
if (ast.parameters.length < 2) {
return variables;
}

const varsParam = ast.parameters[1];
const varsExpr = varsParam.expression.trim();
const letMatch = varsExpr.match(LET_PATTERN);
if (!letMatch) {
errors.push(
new ParseError(
varsParam.sourceSpan,
'@content block variables must start with "let", e.g. "let item, index"',
),
);
return null;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ Currently, if a user attempts to use destructuring syntax (e.g. let [item, idx]) or type annotations (e.g. let item: MyType) for @content variables, the parser splits the string by comma and attempts to validate [item or item: MyType as separate variable names. This results in confusing compiler errors: Variable name "[item" must be a valid JavaScript identifier.

While we may want to revisit supporting destructuring or other advanced syntaxes in the future, explicitly guarding against them now with a simple regex check on the entire variables string before splitting allows us to throw a clear error.

Suggested change
const variablesRawString = letMatch[1].trim();
// Validate that the variables list consists only of simple, comma-separated identifiers.
// This explicitly prevents unsupported syntax (e.g., destructuring `let [item, idx]` or
// type annotations `let item: MyType`) and ensures a clean, user-friendly error message.
// Guarding against these explicitly avoids confusing downstream identifier/parser errors.
const VALID_VARIABLES_LIST_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\s*,\s*[a-zA-Z_$][a-zA-Z0-9_$]*)*$/;
if (!VALID_VARIABLES_LIST_PATTERN.test(variablesRawString)) {
errors.push(
new ParseError(
varsParam.sourceSpan,
'@content block variables must be a list of simple, comma-separated identifiers (e.g. "let item, index"). ' +
'Destructuring, type annotations, and assignments are not supported.'
)
);
return null;
}
const varNames = variablesRawString.split(',').map((v) => v.trim());

const varNames = letMatch[1].split(',').map((v) => v.trim());
const variablesRawString = letMatch[1];
const variablesStartOffset = varsParam.expression.indexOf(variablesRawString);
const variablesStartLocation = varsParam.sourceSpan.start.moveBy(variablesStartOffset);

let searchIndex = 0;
for (let varName of varNames) {
if (varName === '') {
errors.push(new ParseError(varsParam.sourceSpan, 'Invalid variable name in @content block'));
continue;
}

let varSpan: ParseSourceSpan;
const index = variablesRawString.indexOf(varName, searchIndex);
const varStart = variablesStartLocation.moveBy(index);

if (varName.includes('=')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: In parseContentBlockVariables, when a variable definition contains an assignment (e.g., let item = value, which is invalid but parsed to report an error), varName is trimmed to the identifier name ("item"). However, searchIndex is advanced using index + varName.length (trimmed length) instead of the original segment length (including the assignment). This prevents searchIndex from advancing past the assignment expression, causing subsequent variable parsing to fail or misalign.
Recommendation: Store the original segment length before trimming varName and use it to advance searchIndex:

const segmentLength = varName.length;
if (varName.includes('=')) {
  const eqIndex = varName.indexOf('=');
  const namePart = varName.substring(0, eqIndex).trim();
  // ...
  varName = namePart;
}
// ...
searchIndex = index + segmentLength;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably minor since this is invalid syntax anyways.

const eqIndex = varName.indexOf('=');
const namePart = varName.substring(0, eqIndex).trim();
const fullVarSpan = new ParseSourceSpan(varStart, varStart.moveBy(varName.length));

errors.push(
new ParseError(fullVarSpan, `@content block variables cannot be assigned a value`),
);

varName = namePart;
varSpan = new ParseSourceSpan(varStart, varStart.moveBy(varName.length));
} else {
varSpan = new ParseSourceSpan(varStart, varStart.moveBy(varName.length));
}

if (!IDENTIFIER_PATTERN.test(varName)) {
errors.push(
new ParseError(varSpan, `Variable name "${varName}" must be a valid JavaScript identifier`),
);
searchIndex = index + varName.length;
continue;
}

if (variables.some((v) => v.name === varName)) {
errors.push(
new ParseError(varSpan, `Duplicate variable name "${varName}" in @content block`),
);
searchIndex = index + varName.length;
continue;
}

// @content block variables cannot be assigned an explicit value in the template
// (e.g. "let item = value"). Instead, they are assigned an argument of the calling render function
// based on their positional index. For example if we have a @content block like
// "@content(items; let item, index)" the render function for that block will be called like
// "render(items, ctx[0], ctx[1])".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT nit: The name of the block (items) is actually not passed as the first parameter to the render function.

probably only address this if you're making other changes

variables.push(new t.Variable(varName, '', varSpan, varSpan));
searchIndex = index + varName.length;
}
return variables;
}
7 changes: 2 additions & 5 deletions 7 packages/compiler/src/render3/r3_control_flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {ParseError, ParseSourceSpan} from '../parse_util';
import {BindingParser} from '../template_parser/binding_parser';

import * as t from './r3_ast';
import {IDENTIFIER_PATTERN} from './util';
import {IDENTIFIER_PATTERN, LET_PATTERN} from './util';

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

/** Pattern used to identify a `let` parameter. */
const FOR_LOOP_LET_PATTERN = /^let\s+([\S\s]*)/;

/**
* Pattern to group a string into leading whitespace, non whitespace, and trailing whitespace.
* Useful for getting the variable name span when a span can contain leading and trailing space.
Expand Down Expand Up @@ -429,7 +426,7 @@ function parseForLoopParameters(
};

for (const param of secondaryParams) {
const letMatch = param.expression.match(FOR_LOOP_LET_PATTERN);
const letMatch = param.expression.match(LET_PATTERN);

if (letMatch !== null) {
const variablesSpan = new ParseSourceSpan(
Expand Down
1 change: 1 addition & 0 deletions 1 packages/compiler/src/render3/r3_identifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class Identifiers {

static foreignComponent: o.ExternalReference = {name: 'ɵɵforeignComponent', moduleName: CORE};
static foreignContent: o.ExternalReference = {name: 'ɵɵforeignContent', moduleName: CORE};
static foreignContentFn: o.ExternalReference = {name: 'ɵɵforeignContentFn', moduleName: CORE};

static domElement: o.ExternalReference = {name: 'ɵɵdomElement', moduleName: CORE};
static domElementStart: o.ExternalReference = {name: 'ɵɵdomElementStart', moduleName: CORE};
Expand Down
Loading
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.