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..2ddfb7190c36 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -85,6 +85,7 @@ import { PipeMeta, Resource, ResourceRegistry, + createForeignComponentMatcher, } from '../../../metadata'; import {PartialEvaluator} from '../../../partial_evaluator'; import {PerfEvent, PerfRecorder} from '../../../perf'; @@ -180,6 +181,7 @@ import { ComponentAnalysisData, ComponentResolutionData, DeferredComponentDependency, + ForeignComponentMeta, } from './metadata'; import { _extractTemplateStyleUrls, @@ -607,7 +609,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; @@ -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..8fe38e4b596b 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,7 @@ import { SchemaMetadata, TmplAstDeferredBlock, ClassPropertyMapping, + SelectorlessMatcher, } from '@angular/compiler'; import ts from 'typescript'; @@ -26,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'; @@ -92,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..34833ec50ca5 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)) { @@ -200,7 +201,13 @@ export function validateAndFlattenForeignImports( flattened.push(...childForeignImports); diagnostics.push(...childDiagnostics); } else if (ref instanceof Reference && isNamedFunctionDeclaration(ref.node)) { - flattened.push(ref as Reference); + // Use the local identity if available to account for import aliases. + const name = ref.getIdentityInExpression(refExpr)?.text ?? ref.node.name.getText(); + flattened.push({ + name, + 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 f7a374dd21bf..8e0485236ef5 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,66 @@ 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].name).toBe('FancyButton'); + expect(analysis!.foreignImports![1].name).toBe('FancyMenu'); + }); + + it('should support import aliases in foreignImports', () => { + const {program, options, host} = makeProgram([ + { + name: _('/node_modules/@angular/core/index.d.ts'), + contents: ` + export const Component: any; + `, + }, + { + name: _('/node_modules/@angular/core/src/render3/foreign_import.ts'), + contents: ` + export function foreignImport(render: any): any {} + `, + }, + { + name: _('/original.ts'), + contents: ` + export function Original() {} + `, + }, + { + name: _('/entry.ts'), + contents: ` + import {Component} from '@angular/core'; + import {foreignImport} from '@angular/core/src/render3/foreign_import'; + import {Original as Alias} from './original'; + + function frameworkImport(component: unknown) { + return foreignImport(() => {/* render component */}); + } + + @Component({ + selector: 'main', + template: '', + foreignImports: [ + frameworkImport(Alias), + ], + }) class TestCmp {} + `, + }, + ]); + const {reflectionHost, handler} = setup(program, options, host); + const TestCmp = getDeclaration(program, _('/entry.ts'), 'TestCmp', isNamedClassDeclaration); + const detected = handler.detect( + TestCmp, + reflectionHost.getDecoratorsOfDeclaration(TestCmp), + ); + if (detected === undefined) { + return fail('Failed to recognize @Component'); + } + const {analysis, diagnostics} = handler.analyze(TestCmp, detected.metadata); + + expect(diagnostics).toBeUndefined(); + expect(analysis?.foreignImports).toHaveSize(1); + expect(analysis!.foreignImports![0].name).toBe('Alias'); }); 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/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 5bec046946ce..2a0e666e7cd9 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts @@ -29,9 +29,15 @@ import { MetadataReader, NgModuleMeta, PipeMeta, + ForeignComponentMeta, } from './api'; import {TypeEntityToDeclarationError} from '../../reflection/src/typescript'; -import {ClassPropertyMapping, ClassPropertyName, TemplateGuardMeta} from '@angular/compiler'; +import { + ClassPropertyMapping, + ClassPropertyName, + TemplateGuardMeta, + SelectorlessMatcher, +} from '@angular/compiler'; export function extractReferencesFromType( checker: ts.TypeChecker, @@ -355,3 +361,17 @@ 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: ForeignComponentMeta[] | null, +): SelectorlessMatcher | null { + if (foreignImports === null || foreignImports.length === 0) { + return null; + } + const registry = new Map(); + 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 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..4393912ac337 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,15 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { ); } + getForeignComponent( + component: ts.ClassDeclaration, + element: TmplAstElement, + ): ForeignComponentMeta | 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..6024b1bd862d 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -2186,6 +2186,142 @@ 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 support import aliases for matching foreign components', () => { + env.write( + 'original.ts', + ` + export function Original() {} + `, + ); + env.write( + 'test.ts', + ` + ${foreignSetupCode} + import {Original as Alias} from './original'; + + @Component({ + selector: 'test', + template: '', + foreignImports: [frameworkImport(Alias)], + }) + export class TestCmp {} + `, + ); + env.driveMain(); + }); + }); + 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.", + ); + }); }); });