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 7c09ea421ea..d849aa5f3fe 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -1187,6 +1187,13 @@ export class ComponentDecoratorHandler implements DecoratorHandler< getReferenceTarget(node) { return boundTemplate.getReferenceTarget(node); }, + getConsumerOfBinding(binding) { + const consumer = boundTemplate.getConsumerOfBinding(binding); + if (consumer && 'ref' in consumer && consumer.ref) { + return {ref: {node: consumer.ref.node}}; + } + return null; + }, getExpressionTarget(ast) { return boundTemplate.getExpressionTarget(ast); }, diff --git a/packages/compiler-cli/src/ngtsc/indexer/src/api.ts b/packages/compiler-cli/src/ngtsc/indexer/src/api.ts index 70b1ab26658..fe3e866838d 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/src/api.ts @@ -8,6 +8,8 @@ import { AST, + TmplAstBoundAttribute, + TmplAstBoundEvent, TmplAstComponent, TmplAstDirective, TmplAstElement, @@ -15,6 +17,7 @@ import { TmplAstNode, TmplAstReference, TmplAstTemplate, + TmplAstTextAttribute, TmplAstVariable, } from '@angular/compiler'; import {DeclarationNode} from '../../reflection'; @@ -33,6 +36,8 @@ export enum IdentifierKind { LetDeclaration, Component, Directive, + Input, + Output, } /** @@ -138,11 +143,19 @@ export interface VariableIdentifier extends TemplateIdentifier { kind: IdentifierKind.Variable; } -/** Describes a `@let` declaration in a template. */ +/** Describes an `@let` declaration in a template. */ export interface LetDeclarationIdentifier extends TemplateIdentifier { kind: IdentifierKind.LetDeclaration; } +/** Describes a bound attribute or event in a template targeting an Angular input/output. */ +export interface BoundAttributeIdentifier extends TemplateIdentifier { + kind: IdentifierKind.Input | IdentifierKind.Output; + target: { + node: T; + } | null; +} + /** * Identifiers recorded at the top level of the template, without any context about the HTML nodes * they were discovered in. @@ -156,7 +169,8 @@ export type TopLevelIdentifier = | MethodIdentifier | LetDeclarationIdentifier | ComponentNodeIdentifier - | DirectiveNodeIdentifier; + | DirectiveNodeIdentifier + | BoundAttributeIdentifier; /** Identifiers that can bring in directives to the template. */ export type DirectiveHostIdentifier = @@ -207,6 +221,9 @@ export interface AbstractBoundTemplate { directive: {ref: {node: T}}; } | null; + getConsumerOfBinding?( + binding: TmplAstBoundAttribute | TmplAstBoundEvent | TmplAstTextAttribute, + ): {ref: {node: T}} | TmplAstElement | TmplAstTemplate | null; getExpressionTarget(ast: AST): TmplAstReference | TmplAstVariable | TmplAstLetDeclaration | null; getUsedDirectives(): Array<{ref: {node: T}; isComponent: boolean}>; getTemplateAst(): TmplAstNode[] | undefined; diff --git a/packages/compiler-cli/src/ngtsc/indexer/src/template.ts b/packages/compiler-cli/src/ngtsc/indexer/src/template.ts index c0820f20a06..f402aad5178 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/src/template.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/src/template.ts @@ -14,6 +14,7 @@ import { PropertyRead, ThisReceiver, TmplAstBoundAttribute, + TmplAstBoundEvent, TmplAstComponent, TmplAstDirective, TmplAstElement, @@ -21,6 +22,7 @@ import { TmplAstNode, TmplAstReference, TmplAstTemplate, + TmplAstTextAttribute, TmplAstVariable, tmplAstVisitAll, } from '@angular/compiler'; @@ -31,6 +33,7 @@ import { AbsoluteSourceSpan, AbstractBoundTemplate, AttributeIdentifier, + BoundAttributeIdentifier, ComponentNodeIdentifier, DirectiveHostIdentifier, DirectiveNodeIdentifier, @@ -48,9 +51,7 @@ import { type ExpressionIdentifier = PropertyIdentifier | MethodIdentifier; type TmplTarget = TmplAstReference | TmplAstVariable | TmplAstLetDeclaration; type TargetIdentifier = - | ReferenceIdentifier - | VariableIdentifier - | LetDeclarationIdentifier; + ReferenceIdentifier | VariableIdentifier | LetDeclarationIdentifier; type TargetIdentifierMap = Map>; /** @@ -148,6 +149,10 @@ class TemplateVisitor extends CombinedRecursiveAstVisitor { } override visitBoundAttribute(attribute: TmplAstBoundAttribute): void { + const identifier = this.bindingToIdentifier(attribute, IdentifierKind.Input); + if (identifier !== null) { + this.identifiers.add(identifier); + } const previous = this.currentAstWithSource; this.currentAstWithSource = { source: attribute.valueSpan?.toString() || null, @@ -157,6 +162,53 @@ class TemplateVisitor extends CombinedRecursiveAstVisitor { this.currentAstWithSource = previous; } + override visitBoundEvent(event: TmplAstBoundEvent): void { + const identifier = this.bindingToIdentifier(event, IdentifierKind.Output); + if (identifier !== null) { + this.identifiers.add(identifier); + } + super.visitBoundEvent(event); + } + + override visitTextAttribute(attribute: TmplAstTextAttribute): void { + const identifier = this.bindingToIdentifier(attribute, IdentifierKind.Input); + if (identifier !== null) { + this.identifiers.add(identifier); + } + super.visitTextAttribute(attribute); + } + + private bindingToIdentifier( + node: TmplAstBoundAttribute | TmplAstBoundEvent | TmplAstTextAttribute, + kind: IdentifierKind.Input | IdentifierKind.Output, + ): BoundAttributeIdentifier | null { + if (!this.boundTemplate.getConsumerOfBinding) { + return null; + } + const consumer = this.boundTemplate.getConsumerOfBinding(node); + if (!consumer || consumer instanceof TmplAstElement || consumer instanceof TmplAstTemplate) { + return null; + } + + const keySpan = node.keySpan ?? (node instanceof TmplAstTextAttribute ? node.sourceSpan : null); + if (!keySpan) { + return null; + } + + const span = new AbsoluteSourceSpan( + keySpan.start.offset, + keySpan.start.offset + node.name.length, + ); + return { + name: node.name, + span, + kind, + target: { + node: consumer.ref.node, + }, + }; + } + /** Creates an identifier for a template element or template node. */ private directiveHostToIdentifier( node: TmplAstElement | TmplAstTemplate | TmplAstComponent | TmplAstDirective, diff --git a/packages/compiler-cli/src/ngtsc/indexer/test/template_spec.ts b/packages/compiler-cli/src/ngtsc/indexer/test/template_spec.ts index 4fc4c5aa781..05d682f5711 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/test/template_spec.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/test/template_spec.ts @@ -1196,5 +1196,135 @@ runInEachFileSystem(() => { }, ]); }); + + it('should discover bound attribute inputs on components', () => { + const comp = ` + export class MyComp { + myInput: string; + } + `; + const compDecl = util.getComponentDeclaration(comp, 'MyComp'); + const template = ''; + const boundTemplate = util.getBoundTemplate(template, {}, [ + { + selector: 'my-comp', + declaration: compDecl, + inputs: {myInput: 'myInput'}, + }, + ]); + const refs = Array.from(getTemplateIdentifiers(boundTemplate)); + + expect(refs).toContain({ + name: 'myInput', + kind: IdentifierKind.Input, + span: new AbsoluteSourceSpan(10, 17), + target: { + node: compDecl, + }, + }); + }); + + it('should discover bound event outputs on components', () => { + const comp = ` + export class MyComp { + myOutput: any; + } + `; + const compDecl = util.getComponentDeclaration(comp, 'MyComp'); + const template = ''; + const boundTemplate = util.getBoundTemplate(template, {}, [ + { + selector: 'my-comp', + declaration: compDecl, + outputs: {myOutput: 'myOutput'}, + }, + ]); + const refs = Array.from(getTemplateIdentifiers(boundTemplate)); + + expect(refs).toContain({ + name: 'myOutput', + kind: IdentifierKind.Output, + span: new AbsoluteSourceSpan(10, 18), + target: { + node: compDecl, + }, + }); + }); + + it('should discover static text attribute inputs on components', () => { + const comp = ` + export class MyComp { + myInput: string; + } + `; + const compDecl = util.getComponentDeclaration(comp, 'MyComp'); + const template = ''; + const boundTemplate = util.getBoundTemplate(template, {}, [ + { + selector: 'my-comp', + declaration: compDecl, + inputs: {myInput: 'myInput'}, + }, + ]); + const refs = Array.from(getTemplateIdentifiers(boundTemplate)); + + expect(refs).toContain({ + name: 'myInput', + kind: IdentifierKind.Input, + span: new AbsoluteSourceSpan(9, 16), + target: { + node: compDecl, + }, + }); + }); + + it('should discover bound attribute inputs and event outputs on directives applied to elements', () => { + const dir = ` + export class MyDir { + dirInput: string; + dirOutput: any; + } + `; + const dirDecl = util.getComponentDeclaration(dir, 'MyDir'); + const template = '
'; + const boundTemplate = util.getBoundTemplate(template, {}, [ + { + selector: '[myDir]', + declaration: dirDecl, + inputs: {dirInput: 'dirInput'}, + outputs: {dirOutput: 'dirOutput'}, + }, + ]); + const refs = Array.from(getTemplateIdentifiers(boundTemplate)); + + expect(refs).toContain({ + name: 'dirInput', + kind: IdentifierKind.Input, + span: new AbsoluteSourceSpan(12, 20), + target: { + node: dirDecl, + }, + }); + expect(refs).toContain({ + name: 'dirOutput', + kind: IdentifierKind.Output, + span: new AbsoluteSourceSpan(29, 38), + target: { + node: dirDecl, + }, + }); + }); + + it('should not discover input or output identifiers for native element bindings and events', () => { + const template = + ''; + const boundTemplate = util.getBoundTemplate(template); + const refs = Array.from(getTemplateIdentifiers(boundTemplate)); + + const inputOrOutputRefs = refs.filter( + (ref) => ref.kind === IdentifierKind.Input || ref.kind === IdentifierKind.Output, + ); + expect(inputOrOutputRefs).toEqual([]); + }); }); }); diff --git a/packages/compiler-cli/src/ngtsc/indexer/test/util.ts b/packages/compiler-cli/src/ngtsc/indexer/test/util.ts index a3d0f320a3a..d7f98cea161 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/test/util.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/test/util.ts @@ -57,15 +57,20 @@ export function getComponentDeclaration(componentStr: string, className: string) export function getBoundTemplate( template: string, options: ParseTemplateOptions = {}, - components: Array<{selector: string | null; declaration: ClassDeclaration}> = [], + components: Array<{ + selector: string | null; + declaration: ClassDeclaration; + inputs?: Record; + outputs?: Record; + }> = [], ): AbstractBoundTemplate { - const componentsMeta = components.map(({selector, declaration}) => ({ + const componentsMeta = components.map(({selector, declaration, inputs = {}, outputs = {}}) => ({ ref: new Reference(declaration), selector, name: declaration.name.getText(), isComponent: true, - inputs: ClassPropertyMapping.fromMappedObject({}), - outputs: ClassPropertyMapping.fromMappedObject({}), + inputs: ClassPropertyMapping.fromMappedObject(inputs), + outputs: ClassPropertyMapping.fromMappedObject(outputs), exportAs: null, isStructural: false, animationTriggerNames: null, @@ -106,6 +111,13 @@ export function getBoundTemplate( getReferenceTarget(node) { return boundTemplate.getReferenceTarget(node); }, + getConsumerOfBinding(binding) { + const consumer = boundTemplate.getConsumerOfBinding(binding); + if (consumer && 'ref' in consumer && consumer.ref) { + return {ref: {node: consumer.ref.node}}; + } + return null; + }, getExpressionTarget(ast) { return boundTemplate.getExpressionTarget(ast); },