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

refactor(compiler): support passing @content blocks as functions#69087

Merged
atscott merged 3 commits into
angular:mainangular/angular:mainfrom
leonsenft:foreign-component-render-propsleonsenft/angular:foreign-component-render-propsCopy head branch name to clipboard
Jun 8, 2026
Merged

refactor(compiler): support passing @content blocks as functions#69087
atscott merged 3 commits into
angular:mainangular/angular:mainfrom
leonsenft:foreign-component-render-propsleonsenft/angular:foreign-component-render-propsCopy head branch name to clipboard

Conversation

@leonsenft

@leonsenft leonsenft commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

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.

@leonsenft leonsenft added area: core Issues related to the framework runtime area: compiler Issues related to `ngc`, Angular's template compiler target: minor This PR is targeted for the next minor release labels Jun 2, 2026
@ngbot ngbot Bot added this to the Backlog milestone Jun 2, 2026
@leonsenft leonsenft force-pushed the foreign-component-render-props branch 2 times, most recently from a6cdad6 to 99f2aba Compare June 3, 2026 21:36
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.

@leonsenft leonsenft force-pushed the foreign-component-render-props branch from 99f2aba to 5a04bf0 Compare June 5, 2026 06:06
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.
@leonsenft leonsenft force-pushed the foreign-component-render-props branch from 5a04bf0 to 8e3f50c Compare June 5, 2026 20:06
@leonsenft leonsenft marked this pull request as ready for review June 5, 2026 20:06
@leonsenft leonsenft requested a review from mattrbeck June 5, 2026 20:10
…cope

Currently, the template pipeline directly emits the raw expression for foreign
component definitions (such as `frameworkImport(MyComponent)`) directly into
the body of the generated template function. If a foreign component is defined
inside a local scope or is non-exported (e.g. nested inside a test block), the
emitted template function may not have access to that variable because
`ɵɵdefineComponent` and its template functions are emitted at the top-level
module scope. This previously caused reference errors during template
compilation.

This commit updates the compilation pipeline to instead ingest foreign
component references into the component's `consts` pool. The
`ɵɵforeignComponent` runtime instruction is updated to accept an index into the
constant pool rather than a raw expression. By routing the references through
the `consts` pool, block-scoped classes and variables are appropriately
captured by `ngtsc` without scoping errors, properly supporting nested/local
foreign component usage.
// When the function is called, instantiate and render a new embedded view inside the container.
// The arguments are passed directly as the context of the view.
const embeddedLView = createAndRenderEmbeddedLView(lView, tNode, args);
addLViewToLContainer(

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.

This came up during our discussion, but how do we do proper cleanup? If the foreign component wants to render this in a conditional or something, then it probably wants a cleanup function?

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.

I think some kind of cleanup feels like a requirement (at least for productionalizing this), but I wonder if we can do any smart recycling of LViews? Also, should we assume that frameworks we're consuming are will be doing smart diffing of these args, or is it the consumed framework's responsibility to invoke this responsibly?

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.

Or is this just driven by the signal graph and what you were describing in https://github.com/angular/angular/pull/69087/changes#r3356991004?

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.

No, Andrew is right. We absolutely need a cleanup function. I'm going to introduce a hook for wiring up framework-specific cleanup logic in foreignImport.

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.

How about smart diffing or cleaning up LViews when the render function is called repeatedly?

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.

// (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

const context = new ir.ContextExpr(scope.view);
// We either read the context, or, if the variable is CTX_REF, use the context directly.
const variable = value === ir.CTX_REF ? context : new o.ReadPropExpr(context, value);
let variable: o.Expression;

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.

✨ Now that the pipeline has been extended to support reading variables from a positional index (using ReadKeyExpr for numbers) in addition to property names, the existing comment is slightly outdated and incomplete. It should be updated to clearly explain both paths.

Suggested change
let variable: o.Expression;
// We either use the context directly (if CTX_REF), or read a value from it
// via a property name (string) or a positional index (number).

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.

I was playing with Gemini creating the inline GitHub 'suggestion' blocks and it looks like it tagged the wrong line. Whoops 😅

);
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());

…`@content`

Coordinate template lifecycle events between Angular and foreign components to
allow clean teardown of nested Angular views inside a foreign container.

Previously, when Angular content was projected into a foreign component (for
instance, via render props), Angular had no way to receive destruction
notifications from the foreign component. If the foreign component unmounted
or conditionally removed its children, the nested Angular views remained active,
leading to memory leaks and incomplete lifecycle teardowns.

This change introduces the `ON_DESTROY` symbol and a new registration mechanism
(`ForeignOnDestroyFn`) on the `ForeignComponent` interface. The `foreignImport`
helper now takes an additional `onDestroy` callback function where the foreign
component can register to receive Angular's view-destruction callback.

During the creation phase, `ɵɵforeignContentFn` resolves the foreign component
from the constant pool using a new constant pool index and invokes the
`onDestroy` function. This registers a callback that destroys the corresponding
embedded view from the container.

In the compiler, `ForeignComponentOp` is modified to track the target constant
pool index, and `ForeignContentExpr` reification is updated to pass this index
to `ɵɵforeignContentFn`.
@atscott atscott added the action: merge The PR is ready for merge by the caretaker label Jun 8, 2026
@atscott atscott merged commit 11b206b into angular:main Jun 8, 2026
24 checks passed
@atscott

atscott commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

This PR was merged into the repository. The changes were merged into the following branches:

@leonsenft leonsenft deleted the foreign-component-render-props branch June 8, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action: merge The PR is ready for merge by the caretaker area: compiler Issues related to `ngc`, Angular's template compiler area: core Issues related to the framework runtime target: minor This PR is targeted for the next minor release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.