From b22195cbe5af5e7bb84af7478ac330b9c40f209c Mon Sep 17 00:00:00 2001 From: leonsenft Date: Mon, 11 May 2026 11:12:02 -0700 Subject: [PATCH 1/2] refactor(compiler): support matching and validating foreign components in templates We extract the identifier name from the `foreignImports` expression in `ComponentDecoratorHandler` and use a `SelectorlessMatcher` to match element tags against these names. If an element matches both a regular Angular directive and a foreign component, a conflict error is thrown. In addition, we implement strict template semantic validation for these matched foreign components within `TemplateSemanticsChecker`. Elements matched as foreign components only support static attributes and property bindings. Any event bindings, template references, or non-property input bindings (e.g. class, style, or attribute bindings) trigger a semantic error diagnostic. Finally, we skip standard DOM schema checks for foreign components to prevent spurious validation errors since foreign components are not defined in standard HTML schemas. --- .../public-api/compiler-cli/error_code.api.md | 1 + .../annotations/component/src/handler.ts | 12 +- .../annotations/component/src/metadata.ts | 2 + .../component/test/component_spec.ts | 11 +- .../src/ngtsc/diagnostics/src/error_code.ts | 5 + .../compiler-cli/src/ngtsc/metadata/index.ts | 1 + .../src/ngtsc/metadata/src/util.ts | 23 +++- .../src/ngtsc/scope/src/typecheck.ts | 12 ++ .../src/ngtsc/typecheck/api/checker.ts | 9 ++ .../src/ngtsc/typecheck/src/checker.ts | 14 +++ .../src/ngtsc/typecheck/src/tcb_adapter.ts | 1 + .../src/template_semantics_checker.ts | 61 +++++++++- .../test/ngtsc/template_typecheck_spec.ts | 112 ++++++++++++++++++ .../compiler/src/render3/partial/component.ts | 2 - packages/compiler/src/render3/view/api.ts | 16 +-- packages/compiler/src/render3/view/t2_api.ts | 22 ++++ .../compiler/src/render3/view/t2_binder.ts | 41 ++++++- packages/compiler/src/typecheck/ops/scope.ts | 19 ++- .../test/render3/view/binding_spec.ts | 30 ++++- 19 files changed, 362 insertions(+), 32 deletions(-) diff --git a/goldens/public-api/compiler-cli/error_code.api.md b/goldens/public-api/compiler-cli/error_code.api.md index f0416825e1a9..979393176de9 100644 --- a/goldens/public-api/compiler-cli/error_code.api.md +++ b/goldens/public-api/compiler-cli/error_code.api.md @@ -55,6 +55,7 @@ export enum ErrorCode { DUPLICATE_DECORATED_PROPERTIES = 1012, DUPLICATE_VARIABLE_DECLARATION = 8006, FORBIDDEN_REQUIRED_INITIALIZER_INVOCATION = 8118, + FOREIGN_COMPONENT_UNSUPPORTED_BINDING = 8025, FORM_FIELD_UNSUPPORTED_BINDING = 8022, HOST_BINDING_PARSE_ERROR = 5001, HOST_DIRECTIVE_COMPONENT = 2015, diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts index 88c562fd2056..762b68aee625 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -44,6 +44,7 @@ import { SelectorMatcher, TmplAstDeferredBlock, TypeCheckId, + ForeignComponentMeta, ViewEncapsulation, } from '@angular/compiler'; import ts from 'typescript'; @@ -85,6 +86,7 @@ import { PipeMeta, Resource, ResourceRegistry, + createForeignComponentMatcher, } from '../../../metadata'; import {PartialEvaluator} from '../../../partial_evaluator'; import {PerfEvent, PerfRecorder} from '../../../perf'; @@ -1206,7 +1208,10 @@ export class ComponentDecoratorHandler implements DecoratorHandler< return; } - const binder = new R3TargetBinder(scope.matcher); + const binder = new R3TargetBinder( + scope.matcher, + scope.foreignMatcher, + ); const templateContext: TemplateContext = { nodes: meta.template.diagNodes, pipes: scope.pipes, @@ -1810,7 +1815,10 @@ export class ComponentDecoratorHandler implements DecoratorHandler< } // Set up the R3TargetBinder. - const binder = new R3TargetBinder(createMatcherFromScope(scope, this.hostDirectivesResolver)); + const binder = new R3TargetBinder( + createMatcherFromScope(scope, this.hostDirectivesResolver), + createForeignComponentMatcher(analysis.foreignImports), + ); let allDependencies = dependencies; let deferBlockBinder = binder; diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts index 1dae6ad37e19..7a98101ca526 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts @@ -19,6 +19,8 @@ import { SchemaMetadata, TmplAstDeferredBlock, ClassPropertyMapping, + SelectorlessMatcher, + ForeignComponentMeta, } from '@angular/compiler'; import ts from 'typescript'; diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts b/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts index f7a374dd21bf..8ce50e9815f9 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts @@ -1064,6 +1064,7 @@ runInEachFileSystem(() => { import {foreignImport} from '@angular/core/src/render3/foreign_import'; function FancyButton() {} + function FancyMenu() {} function frameworkImport(component: unknown) { return foreignImport(() => {/* render component */}); @@ -1072,7 +1073,10 @@ runInEachFileSystem(() => { @Component({ selector: 'main', template: '', - foreignImports: [frameworkImport(FancyButton)], + foreignImports: [ + frameworkImport(FancyButton), + frameworkImport(FancyMenu), + ], }) class TestCmp {} `, }, @@ -1089,8 +1093,9 @@ runInEachFileSystem(() => { const {analysis, diagnostics} = handler.analyze(TestCmp, detected.metadata); expect(diagnostics).toBeUndefined(); - expect(analysis?.foreignImports?.length).toBe(1); - expect(analysis?.foreignImports![0].node.name.text).toBe('FancyButton'); + expect(analysis?.foreignImports).toHaveSize(2); + expect(analysis!.foreignImports![0].debugName).toBe('FancyButton'); + expect(analysis!.foreignImports![1].debugName).toBe('FancyMenu'); }); it('should produce diagnostic for imports in non-standalone component', () => { diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts index f3ed5bb9764b..504de50ccfe4 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts @@ -459,6 +459,11 @@ export enum ErrorCode { */ CONFLICTING_HOST_DIRECTIVE_BINDING = -8024, + /** + * Raised when a foreign component node has an unsupported Angular binding. + */ + FOREIGN_COMPONENT_UNSUPPORTED_BINDING = 8025, + /** * A two way binding in a template has an incorrect syntax, * parentheses outside brackets. For example: diff --git a/packages/compiler-cli/src/ngtsc/metadata/index.ts b/packages/compiler-cli/src/ngtsc/metadata/index.ts index 194fa5860b28..5ee000b99425 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/index.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/index.ts @@ -22,6 +22,7 @@ export { hasInjectableFields, CompoundMetadataReader, isHostDirectiveMetaForGlobalMode, + createForeignComponentMatcher, } from './src/util'; export {ExportedProviderStatusResolver} from './src/providers'; export {HostDirectivesResolver} from './src/host_directives_resolver'; diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts index 5bec046946ce..2e65e65afc9f 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts @@ -31,7 +31,13 @@ import { PipeMeta, } from './api'; import {TypeEntityToDeclarationError} from '../../reflection/src/typescript'; -import {ClassPropertyMapping, ClassPropertyName, TemplateGuardMeta} from '@angular/compiler'; +import { + ClassPropertyMapping, + ClassPropertyName, + TemplateGuardMeta, + SelectorlessMatcher, + ForeignComponentMeta, +} from '@angular/compiler'; export function extractReferencesFromType( checker: ts.TypeChecker, @@ -355,3 +361,18 @@ export function isHostDirectiveMetaForGlobalMode( ): hostDirectiveMeta is HostDirectiveMetaForGlobalMode { return hostDirectiveMeta.directive instanceof Reference; } + +/** Extracts foreign component names from foreignImports and creates a SelectorlessMatcher. */ +export function createForeignComponentMatcher( + foreignImports: Reference[] | null, +): SelectorlessMatcher | null { + if (foreignImports === null || foreignImports.length === 0) { + return null; + } + const registry = new Map(); + for (const ref of foreignImports) { + const name = ref.node.name.getText(); + registry.set(name, [{name, ref}]); + } + return new SelectorlessMatcher(registry); +} diff --git a/packages/compiler-cli/src/ngtsc/scope/src/typecheck.ts b/packages/compiler-cli/src/ngtsc/scope/src/typecheck.ts index ded08b430de3..5a7bde1d3b05 100644 --- a/packages/compiler-cli/src/ngtsc/scope/src/typecheck.ts +++ b/packages/compiler-cli/src/ngtsc/scope/src/typecheck.ts @@ -12,6 +12,7 @@ import { SchemaMetadata, SelectorlessMatcher, SelectorMatcher, + ForeignComponentMeta, } from '@angular/compiler'; import {Reference} from '../../imports'; @@ -23,6 +24,7 @@ import { MetaKind, NgModuleMeta, PipeMeta, + createForeignComponentMatcher, } from '../../metadata'; import {ClassDeclaration} from '../../reflection'; import {ComponentScopeKind, ComponentScopeReader, SelectorlessScope} from './api'; @@ -37,6 +39,11 @@ export interface TypeCheckScope { */ matcher: DirectiveMatcher | null; + /** + * A `SelectorlessMatcher` instance that contains matched foreign components. + */ + foreignMatcher: SelectorlessMatcher | null; + /** * All of the directives available in the compilation scope of the declaring NgModule. */ @@ -100,6 +107,7 @@ export class TypeCheckScopeRegistry { if (scope === null) { return { matcher: null, + foreignMatcher: null, directives, pipes, schemas: [], @@ -152,8 +160,12 @@ export class TypeCheckScopeRegistry { } } + const foreignMatcher = + hostMeta !== null ? createForeignComponentMatcher(hostMeta.foreignImports) : null; + const typeCheckScope: TypeCheckScope = { matcher, + foreignMatcher, directives, pipes, schemas: scope.schemas, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts index c8ad8c084aa1..6051c688c343 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts @@ -8,6 +8,7 @@ import { AST, + ForeignComponentMeta, LiteralPrimitive, ParseSourceSpan, PropertyRead, @@ -333,6 +334,14 @@ export interface TemplateTypeChecker { node: TmplAstElement | TmplAstTemplate, ): TypeCheckableDirectiveMeta[] | null; + /** + * Gets the foreign component that matched the given template element. + */ + getForeignComponent( + component: ts.ClassDeclaration, + element: TmplAstElement, + ): ForeignComponentMeta | null; + /** * Gets the directives that have been used in a component's template. */ diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts index 215c9e99ec47..d9394d666808 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts @@ -9,6 +9,7 @@ import { AST, BoundTarget, + ForeignComponentMeta, CssSelector, DomElementSchemaRegistry, ExternalExpr, @@ -469,6 +470,19 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { ); } + getForeignComponent( + component: ts.ClassDeclaration, + element: TmplAstElement, + ): ForeignComponentMeta | null { + const data = this.getLatestComponentState(component).data; + if (!data) { + return null; + } + return ( + this.getLatestComponentState(component).data?.boundTarget.getForeignComponent(element) ?? null + ); + } + getUsedDirectives(component: ts.ClassDeclaration): TypeCheckableDirectiveMeta[] | null { return this.getLatestComponentState(component).data?.boundTarget.getUsedDirectives() ?? null; } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts index 7edcbbe8b046..f8231eb83aa9 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts @@ -194,6 +194,7 @@ export function adaptTypeCheckBlockMetadata( const dirs = meta.boundTarget.getDirectivesOfNode(node); return dirs ? dirs.map(convertDir) : null; }, + getForeignComponent: (element) => meta.boundTarget.getForeignComponent(element), getReferenceTarget: (ref) => { const target = meta.boundTarget.getReferenceTarget(ref); if (target && 'directive' in target) { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts index d880b807198d..a3b8fa9eef4a 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts @@ -9,12 +9,14 @@ import { AST, ASTWithSource, + BindingType, ImplicitReceiver, ParsedEventType, PropertyRead, Binary, RecursiveAstVisitor, TmplAstBoundEvent, + TmplAstElement, TmplAstLetDeclaration, TmplAstNode, TmplAstRecursiveVisitor, @@ -41,7 +43,12 @@ export class TemplateSemanticsCheckerImpl implements TemplateSemanticsChecker { /** Visitor that verifies the semantics of a template. */ class TemplateSemanticsVisitor extends TmplAstRecursiveVisitor { - private constructor(private expressionVisitor: ExpressionsSemanticsVisitor) { + private constructor( + private expressionVisitor: ExpressionsSemanticsVisitor, + private templateTypeChecker: TemplateTypeChecker, + private component: ts.ClassDeclaration, + private diagnostics: TemplateDiagnostic[], + ) { super(); } @@ -56,7 +63,12 @@ class TemplateSemanticsVisitor extends TmplAstRecursiveVisitor { component, diagnostics, ); - const templateVisitor = new TemplateSemanticsVisitor(expressionVisitor); + const templateVisitor = new TemplateSemanticsVisitor( + expressionVisitor, + templateTypeChecker, + component, + diagnostics, + ); nodes.forEach((node) => node.visit(templateVisitor)); return diagnostics; } @@ -65,6 +77,51 @@ class TemplateSemanticsVisitor extends TmplAstRecursiveVisitor { super.visitBoundEvent(event); event.handler.visit(this.expressionVisitor, event); } + + override visitElement(element: TmplAstElement): void { + super.visitElement(element); + + const foreignMeta = this.templateTypeChecker.getForeignComponent(this.component, element); + if (foreignMeta !== null) { + this.validateForeignComponent(element); + } + } + + private validateForeignComponent(element: TmplAstElement) { + if (element.outputs.length > 0) { + this.diagnostics.push( + this.templateTypeChecker.makeTemplateDiagnostic( + this.component, + element.sourceSpan, + ts.DiagnosticCategory.Error, + ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING, + `Foreign components do not support event bindings.`, + ), + ); + } + if (element.references.length > 0) { + this.diagnostics.push( + this.templateTypeChecker.makeTemplateDiagnostic( + this.component, + element.sourceSpan, + ts.DiagnosticCategory.Error, + ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING, + `Foreign components do not support references.`, + ), + ); + } + if (element.inputs.some((input) => input.type !== BindingType.Property)) { + this.diagnostics.push( + this.templateTypeChecker.makeTemplateDiagnostic( + this.component, + element.sourceSpan, + ts.DiagnosticCategory.Error, + ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING, + `Foreign components only support static attributes and property bindings.`, + ), + ); + } + } } /** Visitor that verifies the semantics of the expressions within a template. */ diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 44addc76d163..b582623c6911 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -2186,6 +2186,118 @@ runInEachFileSystem(() => { ); }); + describe('foreign component template semantics', () => { + const foreignSetupCode = ` + // We must redeclare foreignImports and ForeignComponent to test them since they are marked @internal. + declare module '@angular/core' { + export interface ForeignComponent {} + export function foreignImport(render: Function): ForeignComponent; + + interface Component { + foreignImports?: ForeignComponent[]; + } + } + + import {Component, ForeignComponent, foreignImport} from '@angular/core'; + + + function FancyButton() {} + + function frameworkImport(component: unknown): ForeignComponent { + return foreignImport(() => {}); + } + `; + + it('should detect an unsupported event binding on a foreign component', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: '', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp { + click() {} + } + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(1); + expect(diags[0].code).toEqual(ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING)); + expect(diags[0].messageText).toEqual('Foreign components do not support event bindings.'); + expect(getSourceCodeForDiagnostic(diags[0])).toEqual( + '', + ); + }); + + it('should detect an unsupported template reference on a foreign component', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: '', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(1); + expect(diags[0].code).toEqual(ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING)); + expect(diags[0].messageText).toEqual('Foreign components do not support references.'); + expect(getSourceCodeForDiagnostic(diags[0])).toEqual(''); + }); + + it('should detect unsupported non-property bindings on a foreign component', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: '', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(1); + expect(diags[0].code).toEqual(ngErrorCode(ErrorCode.FOREIGN_COMPONENT_UNSUPPORTED_BINDING)); + expect(diags[0].messageText).toEqual( + 'Foreign components only support static attributes and property bindings.', + ); + expect(getSourceCodeForDiagnostic(diags[0])).toEqual( + '', + ); + }); + + it('should allow static attributes and property bindings on a foreign component', () => { + env.write( + 'test.ts', + ` + ${foreignSetupCode} + + @Component({ + selector: 'test', + template: '', + foreignImports: [frameworkImport(FancyButton)], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toEqual(0); + }); + }); + it('should detect a duplicate variable declaration', () => { env.write( 'test.ts', diff --git a/packages/compiler/src/render3/partial/component.ts b/packages/compiler/src/render3/partial/component.ts index 3da73c38096e..77bca73a71c1 100644 --- a/packages/compiler/src/render3/partial/component.ts +++ b/packages/compiler/src/render3/partial/component.ts @@ -235,8 +235,6 @@ function compileUsedDependenciesMetadata( ngModuleMeta.set('kind', o.literal('ngmodule')); ngModuleMeta.set('type', wrapType(decl.type)); return ngModuleMeta.toLiteralMap(); - case R3TemplateDependencyKind.ForeignComponent: - throw new Error('Foreign components are not supported in partial compilation'); } }); } diff --git a/packages/compiler/src/render3/view/api.ts b/packages/compiler/src/render3/view/api.ts index 076df5e77e2e..216825b0f8b2 100644 --- a/packages/compiler/src/render3/view/api.ts +++ b/packages/compiler/src/render3/view/api.ts @@ -341,7 +341,6 @@ export enum R3TemplateDependencyKind { Directive = 0, Pipe = 1, NgModule = 2, - ForeignComponent = 3, } /** @@ -362,8 +361,7 @@ export interface R3TemplateDependency { export type R3TemplateDependencyMetadata = | R3DirectiveDependencyMetadata | R3PipeDependencyMetadata - | R3NgModuleDependencyMetadata - | R3ForeignComponentDependencyMetadata; + | R3NgModuleDependencyMetadata; /** * Information about a directive that is used in a component template. Only the stable, public @@ -408,18 +406,6 @@ export interface R3NgModuleDependencyMetadata extends R3TemplateDependency { kind: R3TemplateDependencyKind.NgModule; } -/** - * Information about a foreign component that is used in a component template. - */ -export interface R3ForeignComponentDependencyMetadata extends R3TemplateDependency { - kind: R3TemplateDependencyKind.ForeignComponent; - - /** - * The foreign component's name. - */ - name: string; -} - /** * Information needed to compile a query (view or content). */ diff --git a/packages/compiler/src/render3/view/t2_api.ts b/packages/compiler/src/render3/view/t2_api.ts index a2ca23659496..7ce03af4dfa3 100644 --- a/packages/compiler/src/render3/view/t2_api.ts +++ b/packages/compiler/src/render3/view/t2_api.ts @@ -172,6 +172,22 @@ export interface DirectiveMeta { matchSource: MatchSource; } +/** + * Metadata regarding a foreign component that's needed to match it against template elements. + */ +export interface ForeignComponentMeta { + /** + * Name of the foreign component (used for matching and debugging). + */ + name: string; + + /** Reference to the foreign component declaration site. */ + ref: { + /** Key that uniquely identifies the reference. */ + key: string; + }; +} + /** * Possible ways that a directive can be matched. */ @@ -213,6 +229,12 @@ export interface BoundTarget { */ getDirectivesOfNode(node: DirectiveOwner): DirectiveT[] | null; + /** + * For a given template node (usually an `Element`), get the foreign component that matched + * the node, if any. + */ + getForeignComponent(element: Element): ForeignComponentMeta | null; + /** * For a given `Reference`, get the reference's target - either an `Element`, a `Template`, or * a directive on a particular node. diff --git a/packages/compiler/src/render3/view/t2_binder.ts b/packages/compiler/src/render3/view/t2_binder.ts index 38d23acefdb9..28e89060ced0 100644 --- a/packages/compiler/src/render3/view/t2_binder.ts +++ b/packages/compiler/src/render3/view/t2_binder.ts @@ -58,6 +58,7 @@ import { ConflictingHostDirectiveBinding, DirectiveMeta, DirectiveOwner, + ForeignComponentMeta, MatchSource, ReferenceTarget, ScopedNode, @@ -170,7 +171,10 @@ export type DirectiveMatcher = * target. */ export class R3TargetBinder implements TargetBinder { - constructor(private directiveMatcher: DirectiveMatcher | null) {} + constructor( + private directiveMatcher: DirectiveMatcher | null, + private foreignComponentMatcher: SelectorlessMatcher | null = null, + ) {} /** * Perform a binding operation on the given `Target` and return a `BoundTarget` which contains @@ -182,6 +186,7 @@ export class R3TargetBinder implements TargetB } const directives: MatchedDirectives = new Map(); + const foreignComponents = new Map(); const eagerDirectives: DirectiveT[] = []; const missingDirectives = new Set(); const bindings: BindingsMap = new Map(); @@ -214,7 +219,9 @@ export class R3TargetBinder implements TargetB DirectiveBinder.apply( target.template, this.directiveMatcher, + this.foreignComponentMatcher, directives, + foreignComponents, eagerDirectives, missingDirectives, bindings, @@ -255,6 +262,7 @@ export class R3TargetBinder implements TargetB return new R3BoundTarget( target, directives, + foreignComponents, eagerDirectives, missingDirectives, bindings, @@ -516,7 +524,9 @@ class DirectiveBinder implements Visitor { private constructor( private directiveMatcher: DirectiveMatcher | null, + private foreignMatcher: SelectorlessMatcher | null, private directives: MatchedDirectives, + private foreignComponents: Map, private eagerDirectives: DirectiveT[], private missingDirectives: Set, private bindings: BindingsMap, @@ -542,7 +552,9 @@ class DirectiveBinder implements Visitor { static apply( template: Node[], directiveMatcher: DirectiveMatcher | null, + foreignMatcher: SelectorlessMatcher | null, directives: MatchedDirectives, + foreignComponents: Map, eagerDirectives: DirectiveT[], missingDirectives: Set, bindings: BindingsMap, @@ -554,7 +566,9 @@ class DirectiveBinder implements Visitor { ): void { const matcher = new DirectiveBinder( directiveMatcher, + foreignMatcher, directives, + foreignComponents, eagerDirectives, missingDirectives, bindings, @@ -665,11 +679,12 @@ class DirectiveBinder implements Visitor { } private visitElementOrTemplate(node: Element | Template): void { + const matchedDirectives: DirectiveT[] = []; + if (this.directiveMatcher instanceof SelectorMatcher) { - const directives: DirectiveT[] = []; const cssSelector = createCssSelectorFromNode(node); - this.directiveMatcher.match(cssSelector, (_, results) => directives.push(...results)); - this.trackSelectorBasedBindingsAndDirectives(node, directives); + this.directiveMatcher.match(cssSelector, (_, results) => matchedDirectives.push(...results)); + this.trackSelectorBasedBindingsAndDirectives(node, matchedDirectives); } else { node.references.forEach((ref) => { if (ref.value.trim() === '') { @@ -678,6 +693,19 @@ class DirectiveBinder implements Visitor { }); } + if (this.foreignMatcher && node instanceof Element) { + const foreignMatches = this.foreignMatcher.match(node.name); + if (foreignMatches.length > 0) { + if (matchedDirectives.length > 0) { + throw new Error( + `Conflict: Element '${node.name}' matches both an Angular directive and a foreign component.`, + ); + } + // We assume at most one foreign component matches by name. + this.foreignComponents.set(node, foreignMatches[0]); + } + } + node.directives.forEach((directive) => directive.visit(this)); node.children.forEach((child) => child.visit(this)); } @@ -1182,6 +1210,7 @@ class R3BoundTarget implements BoundTarget, private directives: MatchedDirectives, + private foreignComponents: Map, private eagerDirectives: DirectiveT[], private missingDirectives: Set, private bindings: BindingsMap, @@ -1210,6 +1239,10 @@ class R3BoundTarget implements BoundTarget | null { return this.references.get(ref) || null; } diff --git a/packages/compiler/src/typecheck/ops/scope.ts b/packages/compiler/src/typecheck/ops/scope.ts index 210978b20c23..4f418e601326 100644 --- a/packages/compiler/src/typecheck/ops/scope.ts +++ b/packages/compiler/src/typecheck/ops/scope.ts @@ -561,8 +561,18 @@ export class Scope { if (node instanceof Element) { this.opQueue.push( new TcbUnclaimedInputsOp(this.tcb, this, node.inputs, node, claimedInputs), - new TcbDomSchemaCheckerOp(this.tcb, node, /* checkElement */ true, claimedInputs), ); + + // Skip DOM schema checks for elements matched as foreign components. + // An element can never match both an Angular directive and a foreign component + // without throwing a fatal error, so we are guaranteed that directives is empty + // and we only need to intercept in this directiveless block. + const isForeign = this.tcb.boundTarget.getForeignComponent(node) !== null; + if (!isForeign) { + this.opQueue.push( + new TcbDomSchemaCheckerOp(this.tcb, node, /* checkElement */ true, claimedInputs), + ); + } } return; } @@ -843,7 +853,12 @@ export class Scope { } } } - this.opQueue.push(new TcbDomSchemaCheckerOp(this.tcb, node, !hasDirectives, claimedInputs)); + const isForeign = this.tcb.boundTarget.getForeignComponent(node) !== null; + if (!isForeign) { + this.opQueue.push( + new TcbDomSchemaCheckerOp(this.tcb, node, !hasDirectives, claimedInputs), + ); + } } this.appendDeepSchemaChecks(node.children); diff --git a/packages/compiler/test/render3/view/binding_spec.ts b/packages/compiler/test/render3/view/binding_spec.ts index a35a72982da2..3e918ee429aa 100644 --- a/packages/compiler/test/render3/view/binding_spec.ts +++ b/packages/compiler/test/render3/view/binding_spec.ts @@ -8,7 +8,7 @@ import * as e from '../../../src/expression_parser/ast'; import * as a from '../../../src/render3/r3_ast'; -import {DirectiveMeta, MatchSource} from '../../../src/render3/view/t2_api'; +import {DirectiveMeta, MatchSource, ForeignComponentMeta} from '../../../src/render3/view/t2_api'; import {ClassPropertyMapping} from '../../../src/property_mapping'; import {findMatchingDirectivesAndPipes, R3TargetBinder} from '../../../src/render3/view/t2_binder'; import {parseTemplate, ParseTemplateOptions} from '../../../src/render3/view/template'; @@ -1595,5 +1595,33 @@ describe('t2 binding', () => { expect(mergedHost.outputs.toDirectMappedObject()).toEqual({one: 'oneAlias'}); expect(res.getConflictingHostDirectiveBindings(element)).toBe(null); }); + + it('should match foreign components by tag name', () => { + const template = parseTemplate('', '', {}); + const registry = new Map(); + registry.set('FancyButton', [{name: 'FancyButton', ref: {key: 'FancyButtonKey'}}]); + const foreignMatcher = new SelectorlessMatcher(registry); + + const binder = new R3TargetBinder(new SelectorMatcher(), foreignMatcher); + const res = binder.bind({template: template.nodes}); + + const el = template.nodes[0] as a.Element; + const foreignComp = res.getForeignComponent(el); + expect(foreignComp).not.toBeNull(); + expect(foreignComp?.name).toBe('FancyButton'); + }); + + it('should throw an error when tag matches both directive and foreign component', () => { + const template = parseTemplate('', '', {}); + const registry = new Map(); + registry.set('comp', [{name: 'comp', ref: {key: 'compKey'}}]); + const foreignMatcher = new SelectorlessMatcher(registry); + + const binder = new R3TargetBinder(makeSelectorMatcher(), foreignMatcher); + + expect(() => binder.bind({template: template.nodes})).toThrowError( + "Conflict: Element 'comp' matches both an Angular directive and a foreign component.", + ); + }); }); }); From 2e0edbe78320515db40556c29b839f5f5da5861a Mon Sep 17 00:00:00 2001 From: leonsenft Date: Wed, 13 May 2026 14:13:38 -0700 Subject: [PATCH 2/2] refactor(compiler): emit instructions for foreign components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a template element matches an imported foreign component, the compiler omits standard element instructions (`ɵɵelementStart`/`ɵɵelement`) and instead generates a single `ɵɵforeignComponent` call. The call passes the exact foreign import wrapper expression defined in `@Component.foreignImports` along with an aggregated object literal containing all static attributes and property bindings. The instruction itself is currently a no-op. --- .../annotations/component/src/handler.ts | 40 +++++++++- .../annotations/component/src/metadata.ts | 5 +- .../ngtsc/annotations/component/src/util.ts | 16 +++- .../component/test/component_spec.ts | 4 +- .../src/ngtsc/metadata/src/api.ts | 9 ++- .../src/ngtsc/metadata/src/util.ts | 9 +-- .../src/ngtsc/scope/src/typecheck.ts | 2 +- .../standalone/GOLDEN_PARTIAL.js | 39 ++++++++++ .../standalone/TEST_CASES.json | 75 +++++++------------ .../standalone/foreign_component.js | 15 ++++ .../standalone/foreign_component.local.js | 15 ++++ .../standalone/foreign_component.ts | 21 ++++++ .../compiler/src/render3/r3_identifiers.ts | 2 + packages/compiler/src/render3/view/api.ts | 20 +++++ .../compiler/src/render3/view/compiler.ts | 1 + .../src/template/pipeline/ir/src/enums.ts | 5 ++ .../template/pipeline/ir/src/expression.ts | 5 ++ .../template/pipeline/ir/src/ops/create.ts | 46 ++++++++++++ .../src/template/pipeline/src/compilation.ts | 25 ++++++- .../src/template/pipeline/src/ingest.ts | 34 ++++++++- .../src/template/pipeline/src/instruction.ts | 13 ++++ .../src/template/pipeline/src/phases/reify.ts | 6 ++ .../core/src/core_render3_private_export.ts | 1 + packages/core/src/render3/index.ts | 1 + packages/core/src/render3/instructions/all.ts | 1 + .../render3/instructions/foreign_component.ts | 25 +++++++ packages/core/src/render3/jit/environment.ts | 1 + .../core/test/render3/instructions_spec.ts | 1 + 28 files changed, 369 insertions(+), 68 deletions(-) create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts create mode 100644 packages/core/src/render3/instructions/foreign_component.ts diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts index 762b68aee625..900648c413b9 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -39,12 +39,14 @@ import { R3TemplateDependency, R3TemplateDependencyKind, R3TemplateDependencyMetadata, + R3ForeignComponentMetadata, SchemaMetadata, SelectorlessMatcher, SelectorMatcher, TmplAstDeferredBlock, + TmplAstNode, + TmplAstElement, TypeCheckId, - ForeignComponentMeta, ViewEncapsulation, } from '@angular/compiler'; import ts from 'typescript'; @@ -182,6 +184,7 @@ import { ComponentAnalysisData, ComponentResolutionData, DeferredComponentDependency, + ForeignComponentMeta, } from './metadata'; import { _extractTemplateStyleUrls, @@ -609,7 +612,7 @@ export class ComponentDecoratorHandler implements DecoratorHandler< } let resolvedImports: Reference[] | null = null; - let foreignImports: Reference[] | null = null; + let foreignImports: ForeignComponentMeta[] | null = null; let resolvedDeferredImports: Reference[] | null = null; let rawImports: ts.Expression | null = component.get('imports') ?? null; @@ -1495,10 +1498,12 @@ export class ComponentDecoratorHandler implements DecoratorHandler< ? this.resolveAllDeferredDependencies(resolution) : null; const defer = this.compileDeferBlocks(resolution); + const foreignImports = this.resolveForeignComponentImports(node, analysis); const meta: R3ComponentMetadata = { ...analysis.meta, ...resolution, defer, + foreignImports, }; const fac = compileNgFactoryDefField(toFactoryMetadata(meta, FactoryTarget.Component)); @@ -1625,10 +1630,12 @@ export class ComponentDecoratorHandler implements DecoratorHandler< const deferrableTypes = this.canDeferDeps ? analysis.explicitlyDeferredTypes : null; const defer = this.compileDeferBlocks(resolution); + const foreignImports = this.resolveForeignComponentImports(node, analysis); const meta = { ...analysis.meta, ...resolution, defer, + foreignImports, } as R3ComponentMetadata; if (deferrableTypes !== null) { @@ -1688,10 +1695,12 @@ export class ComponentDecoratorHandler implements DecoratorHandler< // Create a brand-new constant pool since there shouldn't be any constant sharing. const pool = new ConstantPool(); const defer = this.compileDeferBlocks(resolution); + const foreignImports = this.resolveForeignComponentImports(node, analysis); const meta: R3ComponentMetadata = { ...analysis.meta, ...resolution, defer, + foreignImports, }; const fac = compileNgFactoryDefField(toFactoryMetadata(meta, FactoryTarget.Component)); const def = compileComponentFromMetadata(meta, pool, this.getNewBindingParser()); @@ -2325,6 +2334,33 @@ export class ComponentDecoratorHandler implements DecoratorHandler< this.cycleAnalyzer.recordSyntheticImport(origin, imported); } + /** + * Resolves imported foreign components for code generation. + */ + private resolveForeignComponentImports( + node: ClassDeclaration, + analysis: Readonly, + ): R3ForeignComponentMetadata[] | null { + if (analysis.foreignImports === null || analysis.foreignImports.length === 0) { + return null; + } + const context = getSourceFile(node); + + return analysis.foreignImports.map((foreignMeta) => { + const {ref, rawExpression} = foreignMeta; + + const emittedRef = this.refEmitter.emit(ref, context); + assertSuccessfulReferenceEmit(emittedRef, node.name, 'foreign component'); + + ts.setEmitFlags(rawExpression, ts.EmitFlags.NoComments | ts.EmitFlags.NoNestedComments); + + return { + name: foreignMeta.name, + component: new o.WrappedNodeExpr(rawExpression), + } satisfies R3ForeignComponentMetadata; + }); + } + /** * Resolves information about defer blocks dependencies to make it * available for the final `compile` step. diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts index 7a98101ca526..8fe38e4b596b 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/metadata.ts @@ -20,7 +20,6 @@ import { TmplAstDeferredBlock, ClassPropertyMapping, SelectorlessMatcher, - ForeignComponentMeta, } from '@angular/compiler'; import ts from 'typescript'; @@ -28,9 +27,11 @@ import {Reference} from '../../../imports'; import { DirectiveResources, DirectiveTypeCheckMeta, + ForeignComponentMeta, HostDirectiveMeta, InputMapping, } from '../../../metadata'; +export {ForeignComponentMeta} from '../../../metadata'; import {ClassDeclaration, Import} from '../../../reflection'; import {SubsetOfKeys} from '../../../util/src/typescript'; @@ -94,7 +95,7 @@ export interface ComponentAnalysisData { rawImports: ts.Expression | null; resolvedImports: Reference[] | null; - foreignImports: Reference[] | null; + foreignImports: ForeignComponentMeta[] | null; rawDeferredImports: ts.Expression | null; resolvedDeferredImports: Reference[] | null; diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/util.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/util.ts index 58c5fed79eab..ddbd7a19f930 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/util.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/util.ts @@ -25,6 +25,7 @@ import { isNamedFunctionDeclaration, } from '../../../reflection'; import {createValueHasWrongTypeError, getOriginNodeForDiagnostics} from '../../common'; +import {ForeignComponentMeta} from './metadata'; /** * Collect the animation names from the static evaluation result. @@ -167,10 +168,10 @@ export function validateAndFlattenForeignImports( imports: ResolvedValue, expr: ts.Expression, ): { - foreignImports: Reference[]; + foreignImports: ForeignComponentMeta[]; diagnostics: ts.Diagnostic[]; } { - const flattened: Reference[] = []; + const flattened: ForeignComponentMeta[] = []; const errorMessage = `'foreignImports' must be an array of ForeignComponents.`; if (!Array.isArray(imports)) { @@ -199,8 +200,15 @@ export function validateAndFlattenForeignImports( validateAndFlattenForeignImports(ref, refExpr); flattened.push(...childForeignImports); diagnostics.push(...childDiagnostics); - } else if (ref instanceof Reference && isNamedFunctionDeclaration(ref.node)) { - flattened.push(ref as Reference); + } else if ( + ref instanceof Reference && + (isNamedFunctionDeclaration(ref.node) || isNamedClassDeclaration(ref.node)) + ) { + flattened.push({ + name: ref.node.name.getText(), + ref: ref as Reference, + rawExpression: refExpr, + }); } else { const {node: diagnosticNode, value: diagnosticValue} = getDiagnosticOrigin( ref, diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts b/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts index 8ce50e9815f9..74dd89b60c0a 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/test/component_spec.ts @@ -1094,8 +1094,8 @@ runInEachFileSystem(() => { expect(diagnostics).toBeUndefined(); expect(analysis?.foreignImports).toHaveSize(2); - expect(analysis!.foreignImports![0].debugName).toBe('FancyButton'); - expect(analysis!.foreignImports![1].debugName).toBe('FancyMenu'); + expect(analysis!.foreignImports![0].name).toBe('FancyButton'); + expect(analysis!.foreignImports![1].name).toBe('FancyMenu'); }); it('should produce diagnostic for imports in non-standalone component', () => { diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts index 9d93dd9b2e3a..d686f74fdd94 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts @@ -16,12 +16,19 @@ import { InputOrOutput, ClassPropertyMapping, TemplateGuardMeta, + ForeignComponentMeta as T2ForeignComponentMeta, } from '@angular/compiler'; import ts from 'typescript'; import {Reference} from '../../imports'; import {ClassDeclaration} from '../../reflection'; +/** Metadata for a resolved foreign component import. */ +export interface ForeignComponentMeta extends T2ForeignComponentMeta { + ref: Reference; + rawExpression: ts.Expression; +} + /** * Metadata collected for an `NgModule`. */ @@ -256,7 +263,7 @@ export interface DirectiveMeta extends T2DirectiveMeta, DirectiveTypeCheckMeta { * Note that while a foreign import is not likely to be a class, this type is used * because it includes the expected identifier we'll need, making further code simpler. */ - foreignImports: Reference[] | null; + foreignImports: ForeignComponentMeta[] | null; /** * Node declaring the `imports` of a standalone component. Used to produce diagnostics. diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts index 2e65e65afc9f..2a0e666e7cd9 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts @@ -29,6 +29,7 @@ import { MetadataReader, NgModuleMeta, PipeMeta, + ForeignComponentMeta, } from './api'; import {TypeEntityToDeclarationError} from '../../reflection/src/typescript'; import { @@ -36,7 +37,6 @@ import { ClassPropertyName, TemplateGuardMeta, SelectorlessMatcher, - ForeignComponentMeta, } from '@angular/compiler'; export function extractReferencesFromType( @@ -364,15 +364,14 @@ export function isHostDirectiveMetaForGlobalMode( /** Extracts foreign component names from foreignImports and creates a SelectorlessMatcher. */ export function createForeignComponentMatcher( - foreignImports: Reference[] | null, + foreignImports: ForeignComponentMeta[] | null, ): SelectorlessMatcher | null { if (foreignImports === null || foreignImports.length === 0) { return null; } const registry = new Map(); - for (const ref of foreignImports) { - const name = ref.node.name.getText(); - registry.set(name, [{name, ref}]); + for (const meta of foreignImports) { + registry.set(meta.name, [meta]); } return new SelectorlessMatcher(registry); } diff --git a/packages/compiler-cli/src/ngtsc/scope/src/typecheck.ts b/packages/compiler-cli/src/ngtsc/scope/src/typecheck.ts index 5a7bde1d3b05..105961c1e341 100644 --- a/packages/compiler-cli/src/ngtsc/scope/src/typecheck.ts +++ b/packages/compiler-cli/src/ngtsc/scope/src/typecheck.ts @@ -12,13 +12,13 @@ import { SchemaMetadata, SelectorlessMatcher, SelectorMatcher, - ForeignComponentMeta, } from '@angular/compiler'; import {Reference} from '../../imports'; import { DirectiveMeta, flattenInheritedDirectiveMetadata, + ForeignComponentMeta, HostDirectivesResolver, MetadataReader, MetaKind, diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js index dd21cf288989..75b3cbd4574c 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js @@ -375,3 +375,42 @@ export declare class StandaloneComponent { static ɵcmp: i0.ɵɵComponentDeclaration; } +/**************************************************************************************************** + * PARTIAL FILE: foreign_component.js + ****************************************************************************************************/ +import { Component } from '@angular/core'; +import * as i0 from "@angular/core"; +export function FancyButton() { } +// @angular/core does not expose the `ForeignComponent` type this should return. +function frameworkImport(component) { + return () => { }; +} +export class TestCmp { + title = 'Submit'; + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestCmp, isStandalone: true, selector: "main", ngImport: i0, template: '', isInline: true }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, decorators: [{ + type: Component, + args: [{ + selector: 'main', + template: '', + // @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 + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare function FancyButton(): void; +export declare class TestCmp { + title: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/TEST_CASES.json index 81bd37b78135..4093dbfc449b 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/TEST_CASES.json @@ -3,106 +3,85 @@ "cases": [ { "description": "should properly compile a standalone component", - "inputFiles": [ - "component.ts" - ], + "inputFiles": ["component.ts"], "expectations": [ { "failureMessage": "Invalid component definition", - "files": [ - "component.js" - ] + "files": ["component.js"] } ], - "compilationModeFilter": [ - "full compile", - "local compile", - "declaration-only emit" - ] + "compilationModeFilter": ["full compile", "local compile", "declaration-only emit"] }, { "description": "should properly compile a standalone directive", - "inputFiles": [ - "directive.ts" - ], + "inputFiles": ["directive.ts"], "expectations": [ { "failureMessage": "Invalid directive definition", - "files": [ - "directive.js" - ] + "files": ["directive.js"] } ] }, { "description": "should properly compile a standalone pipe", - "inputFiles": [ - "pipe.ts" - ], + "inputFiles": ["pipe.ts"], "expectations": [ { "failureMessage": "Invalid pipe definition", - "files": [ - "pipe.js" - ] + "files": ["pipe.js"] } ] }, { "description": "should generate dependencies array from imports", - "inputFiles": [ - "imports.ts" - ], + "inputFiles": ["imports.ts"], "expectations": [ { "failureMessage": "Invalid standalone component dependencies", - "files": [ - "imports.js" - ] + "files": ["imports.js"] } ] }, { "description": "should support recursivity in templates", - "inputFiles": [ - "recursive.ts" - ], + "inputFiles": ["recursive.ts"], "expectations": [ { "failureMessage": "Recursive usage not accounted for", - "files": [ - "recursive.js" - ] + "files": ["recursive.js"] } ] }, { "description": "should optimize injector imports", - "inputFiles": [ - "module_optimization.ts" - ], + "inputFiles": ["module_optimization.ts"], "expectations": [ { "failureMessage": "Injector imports not optimized", - "files": [ - "module_optimization.js" - ] + "files": ["module_optimization.js"] } ] }, { "description": "should handle a forwardRef in the imports of a standalone component", - "inputFiles": [ - "forward_ref.ts" - ], + "inputFiles": ["forward_ref.ts"], "expectations": [ { "failureMessage": "Invalid component definition", - "files": [ - "forward_ref.js" - ] + "files": ["forward_ref.js"] } ] + }, + { + "description": "should properly compile foreign component imports in a standalone component", + "inputFiles": ["foreign_component.ts"], + "expectations": [ + { + "failureMessage": "Invalid foreign component definition", + "files": ["foreign_component.js"] + } + ], + "compilationModeFilter": ["full compile", "local compile"] } ] -} \ No newline at end of file +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js new file mode 100644 index 000000000000..331eca172eaa --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js @@ -0,0 +1,15 @@ +export class TestCmp { + // ... + static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ + type: TestCmp, + selectors: [["main"]], + decls: 1, + vars: 0, + template: function TestCmp_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵforeignComponent(0, frameworkImport(FancyButton), { class: "btn-cls", label: ctx.title }); + } + }, + encapsulation: 2 + }); +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js new file mode 100644 index 000000000000..331eca172eaa --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js @@ -0,0 +1,15 @@ +export class TestCmp { + // ... + static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ + type: TestCmp, + selectors: [["main"]], + decls: 1, + vars: 0, + template: function TestCmp_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵforeignComponent(0, frameworkImport(FancyButton), { class: "btn-cls", label: ctx.title }); + } + }, + encapsulation: 2 + }); +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts new file mode 100644 index 000000000000..9803123fa084 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts @@ -0,0 +1,21 @@ +import {Component} from '@angular/core'; + +export function FancyButton() {} + +// @angular/core does not expose the `ForeignComponent` type this should return. +function frameworkImport(component: {}): Function { + return () => {}; +} + +@Component({ + selector: 'main', + template: '', + // @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 TestCmp { + title = 'Submit'; +} diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index 82d96103762f..73fdad09c1af 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -25,6 +25,8 @@ export class Identifiers { static elementEnd: o.ExternalReference = {name: 'ɵɵelementEnd', moduleName: CORE}; + static foreignComponent: o.ExternalReference = {name: 'ɵɵforeignComponent', moduleName: CORE}; + static domElement: o.ExternalReference = {name: 'ɵɵdomElement', moduleName: CORE}; static domElementStart: o.ExternalReference = {name: 'ɵɵdomElementStart', moduleName: CORE}; static domElementEnd: o.ExternalReference = {name: 'ɵɵdomElementEnd', moduleName: CORE}; diff --git a/packages/compiler/src/render3/view/api.ts b/packages/compiler/src/render3/view/api.ts index 216825b0f8b2..91263d3b0627 100644 --- a/packages/compiler/src/render3/view/api.ts +++ b/packages/compiler/src/render3/view/api.ts @@ -305,6 +305,11 @@ export interface R3ComponentMetadata< * not be set. If component has empty array imports then this field is not set. */ rawImports?: o.Expression; + + /** + * Foreign components imported by the component. + */ + foreignImports?: R3ForeignComponentMetadata[] | null; } /** @@ -406,6 +411,21 @@ export interface R3NgModuleDependencyMetadata extends R3TemplateDependency { kind: R3TemplateDependencyKind.NgModule; } +/** + * Information about a foreign component that is used in a component template. + */ +export interface R3ForeignComponentMetadata { + /** + * The foreign component's name. + */ + name: string; + + /** + * The expression used to refer to this foreign component. + */ + component: o.Expression; +} + /** * Information needed to compile a query (view or content). */ diff --git a/packages/compiler/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 2a68935853c8..9959c84773d4 100644 --- a/packages/compiler/src/render3/view/compiler.ts +++ b/packages/compiler/src/render3/view/compiler.ts @@ -216,6 +216,7 @@ export function compileComponentFromMetadata( meta.relativeTemplatePath, getTemplateSourceLocationsEnabled(), meta.legacyOptionalChaining, + meta.foreignImports, ); // Then the IR is transformed to prepare it for code generation. diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index c10097f9e833..c36a5fd32670 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -38,6 +38,11 @@ export enum OpKind { */ Element, + /** + * An operation to render a foreign component. + */ + ForeignComponent, + /** * An operation which declares an embedded view. */ diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index 4c3a094b5b58..5221bb7c6bc0 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -1266,6 +1266,11 @@ export function transformExpressionsInOp( case OpKind.StoreLet: op.value = transformExpressionsInExpression(op.value, transform, flags); break; + case OpKind.ForeignComponent: + if (op.props !== null) { + op.props = transformExpressionsInExpression(op.props, transform, flags); + } + break; case OpKind.Advance: case OpKind.Container: case OpKind.ContainerEnd: diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts index c104e9d2a6e8..51276d58d95f 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -44,6 +44,7 @@ export type CreateOp = | ElementOp | ElementStartOp | ElementEndOp + | ForeignComponentOp | ContainerOp | ContainerStartOp | ContainerEndOp @@ -237,6 +238,51 @@ export function createElementStartOp( }; } +/** + * Logical operation representing a foreign component in the creation IR. + */ +export interface ForeignComponentOp extends Op, ConsumesSlotOpTrait { + kind: OpKind.ForeignComponent; + + /** + * The `XrefId` allocated for this foreign component. + */ + xref: XrefId; + + /** + * Reference to the foreign component class/function itself as an output AST expression. + */ + foreignComponentRef: o.Expression; + + /** + * Static attributes and property bindings aggregated as an object literal. + */ + props: o.Expression | null; + + sourceSpan: ParseSourceSpan | null; +} + +/** + * Create a `ForeignComponentOp`. + */ +export function createForeignComponentOp( + xref: XrefId, + foreignComponentRef: o.Expression, + props: o.Expression | null, + sourceSpan: ParseSourceSpan | null, +): ForeignComponentOp { + return { + kind: OpKind.ForeignComponent, + xref, + handle: new SlotHandle(), + foreignComponentRef, + props, + sourceSpan, + ...TRAIT_CONSUMES_SLOT, + ...NEW_OP, + }; +} + /** * Logical operation representing an element with no children in the creation IR. */ diff --git a/packages/compiler/src/template/pipeline/src/compilation.ts b/packages/compiler/src/template/pipeline/src/compilation.ts index 0a1753507d8a..60edddffa88d 100644 --- a/packages/compiler/src/template/pipeline/src/compilation.ts +++ b/packages/compiler/src/template/pipeline/src/compilation.ts @@ -7,8 +7,10 @@ */ import {ConstantPool} from '../../../constant_pool'; +import {SelectorlessMatcher} from '../../../directive_matching'; import * as o from '../../../output/output_ast'; -import {R3ComponentDeferMetadata} from '../../../render3/view/api'; +import {R3ComponentDeferMetadata, R3ForeignComponentMetadata} from '../../../render3/view/api'; +import * as t from '../../../render3/r3_ast'; import * as ir from '../ir'; export enum CompilationJobKind { @@ -75,6 +77,8 @@ export abstract class CompilationJob { * embedded views or host bindings. */ export class ComponentCompilationJob extends CompilationJob { + private foreignMatcher: SelectorlessMatcher | null; + constructor( componentName: string, pool: ConstantPool, @@ -86,10 +90,29 @@ export class ComponentCompilationJob extends CompilationJob { readonly relativeTemplatePath: string | null, readonly enableDebugLocations: boolean, legacyOptionalChaining: boolean, + readonly foreignImports?: R3ForeignComponentMetadata[] | null, ) { super(componentName, pool, mode, legacyOptionalChaining); this.root = new ViewCompilationUnit(this, this.allocateXrefId(), null); this.views.set(this.root.xref, this.root); + + if (foreignImports && foreignImports.length > 0) { + const registry = new Map(); + for (const meta of foreignImports) { + registry.set(meta.name, [meta]); + } + this.foreignMatcher = new SelectorlessMatcher(registry); + } else { + this.foreignMatcher = null; + } + } + + getForeignComponentOf(element: t.Element): R3ForeignComponentMetadata | null { + if (this.foreignMatcher === null) { + return null; + } + const matches = this.foreignMatcher.match(element.name); + return matches.length > 0 ? matches[0] : null; } override kind = CompilationJobKind.Tmpl; diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 30a5da127527..f699619f23d1 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -14,7 +14,11 @@ import {splitNsName} from '../../../ml_parser/tags'; import * as o from '../../../output/output_ast'; import {ParseSourceSpan} from '../../../parse_util'; import * as t from '../../../render3/r3_ast'; -import {DeferBlockDepsEmitMode, R3ComponentDeferMetadata} from '../../../render3/view/api'; +import { + DeferBlockDepsEmitMode, + R3ComponentDeferMetadata, + R3ForeignComponentMetadata, +} from '../../../render3/view/api'; import {icuFromI18nMessage} from '../../../render3/view/i18n/util'; import {DomElementSchemaRegistry} from '../../../schema/dom_element_schema_registry'; import {BindingParser} from '../../../template_parser/binding_parser'; @@ -65,6 +69,7 @@ export function ingestComponent( relativeTemplatePath: string | null, enableDebugLocations: boolean, legacyOptionalChaining: boolean, + foreignImports?: R3ForeignComponentMetadata[] | null, ): ComponentCompilationJob { const job = new ComponentCompilationJob( componentName, @@ -77,6 +82,7 @@ export function ingestComponent( relativeTemplatePath, enableDebugLocations, legacyOptionalChaining, + foreignImports, ); ingestNodes(job.root, template); return job; @@ -283,8 +289,32 @@ function ingestElement(unit: ViewCompilationUnit, element: t.Element): void { const id = unit.job.allocateXrefId(); - const [namespaceKey, elementName] = splitNsName(element.name); + const foreignComp = unit.job.getForeignComponentOf(element); + if (foreignComp) { + const propEntries: {key: string; quoted: boolean; value: o.Expression}[] = []; + for (const attr of element.attributes) { + propEntries.push({ + key: attr.name, + value: o.literal(attr.value), + quoted: false, + }); + } + for (const input of element.inputs) { + propEntries.push({ + key: input.name, + value: convertAst(input.value, unit.job, input.sourceSpan), + quoted: false, + }); + } + const props = propEntries.length > 0 ? o.literalMap(propEntries) : null; + unit.create.push( + ir.createForeignComponentOp(id, foreignComp.component, props, element.startSourceSpan), + ); + return; + } + + const [namespaceKey, elementName] = splitNsName(element.name); const startOp = ir.createElementStartOp( elementName, id, diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts index a9e2573fc021..ac9f2d85aae2 100644 --- a/packages/compiler/src/template/pipeline/src/instruction.ts +++ b/packages/compiler/src/template/pipeline/src/instruction.ts @@ -49,6 +49,19 @@ export function elementStart( ); } +export function foreignComponent( + slot: number, + foreignComponentRef: o.Expression, + props: o.Expression | null, + sourceSpan: ParseSourceSpan | null, +): ir.CreateOp { + const args = [o.literal(slot), foreignComponentRef]; + if (props !== null) { + args.push(props); + } + return call(Identifiers.foreignComponent, args, sourceSpan); +} + function elementOrContainerBase( instruction: o.ExternalReference, slot: number, diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index 7a00b45aff59..929950c54ab4 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -142,6 +142,12 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList( + index: number, + foreignComponent: ForeignComponent, + props: TProps, +): void { + // No-op for now! +} diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 2f0029a89264..67b3417085b4 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -55,6 +55,7 @@ export const angularCoreEnv: {[name: string]: unknown} = (() => ({ 'ɵɵelementStart': r3.ɵɵelementStart, 'ɵɵelementEnd': r3.ɵɵelementEnd, 'ɵɵelement': r3.ɵɵelement, + 'ɵɵforeignComponent': r3.ɵɵforeignComponent, 'ɵɵelementContainerStart': r3.ɵɵelementContainerStart, 'ɵɵelementContainerEnd': r3.ɵɵelementContainerEnd, 'ɵɵdomElement': r3.ɵɵdomElement, diff --git a/packages/core/test/render3/instructions_spec.ts b/packages/core/test/render3/instructions_spec.ts index 3cb2ebe1bb88..69906224cf37 100644 --- a/packages/core/test/render3/instructions_spec.ts +++ b/packages/core/test/render3/instructions_spec.ts @@ -17,6 +17,7 @@ import { ɵɵproperty, ɵɵstyleMap, ɵɵstyleProp, + ɵɵforeignComponent, } from '../../src/render3/index'; import {AttributeMarker} from '../../src/render3/interfaces/attribute_marker'; import {