refactor(compiler): support passing @content blocks as functions#69087
refactor(compiler): support passing @content blocks as functions#69087atscott merged 3 commits intoangular:mainangular/angular:mainfrom leonsenft:foreign-component-render-propsleonsenft/angular:foreign-component-render-propsCopy head branch name to clipboard
@content blocks as functions#69087Conversation
a6cdad6 to
99f2aba
Compare
| i0.ɵɵdomElementEnd(); | ||
| } | ||
| if (rf & 2) { | ||
| const item_r1 = ctx[0]; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
angular/packages/compiler/src/template/pipeline/src/ingest.ts
Lines 312 to 317 in 6bde84f
There was a problem hiding this comment.
Check out the reactivity tests for an example of this working end to end.
99f2aba to
5a04bf0
Compare
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.
5a04bf0 to
8e3f50c
Compare
…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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Or is this just driven by the signal graph and what you were describing in https://github.com/angular/angular/pull/69087/changes#r3356991004?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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('=')) { |
There was a problem hiding this comment.
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;
There was a problem hiding this comment.
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])". |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
✨ 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.
| 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). |
There was a problem hiding this comment.
I was playing with Gemini creating the inline GitHub 'suggestion' blocks and it looks like it tagged the wrong line. Whoops 😅
| ); | ||
| return null; | ||
| } | ||
|
|
There was a problem hiding this comment.
✨ 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.
| 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`.
|
This PR was merged into the repository. The changes were merged into the following branches:
|
Previously, foreign component
@contentblocks were rendered eagerly by Angular and could only project a list of nodes. With this change,@contentcan 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:
ɵɵforeignContentFnwhich wraps the template function so it can be called on demand with arguments by the foreign component.@contentparameters.@contentparameters to the corresponding positional arguments of the calling foreign component function property.