diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/inheritance.ts b/packages/compiler-cli/src/ngtsc/metadata/src/inheritance.ts index 22e99ad5230..6d3c845f71b 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/inheritance.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/inheritance.ts @@ -9,7 +9,7 @@ import {Reference} from '../../imports'; import {ClassDeclaration} from '../../reflection'; -import {DirectiveMeta, InputMapping, MetadataReader} from './api'; +import {DirectiveMeta, HostDirectiveMeta, InputMapping, MetadataReader} from './api'; import {ClassPropertyMapping, ClassPropertyName} from './property_mapping'; /** @@ -34,6 +34,7 @@ export function flattenInheritedDirectiveMetadata( const undeclaredInputFields = new Set(); const restrictedInputFields = new Set(); const stringLiteralInputFields = new Set(); + let hostDirectives: HostDirectiveMeta[]|null = null; let isDynamic = false; let inputs = ClassPropertyMapping.empty(); let outputs = ClassPropertyMapping.empty(); @@ -69,6 +70,10 @@ export function flattenInheritedDirectiveMetadata( for (const field of meta.stringLiteralInputFields) { stringLiteralInputFields.add(field); } + if (meta.hostDirectives !== null && meta.hostDirectives.length > 0) { + hostDirectives ??= []; + hostDirectives.push(...meta.hostDirectives); + } }; addMetadata(topMeta); @@ -83,5 +88,6 @@ export function flattenInheritedDirectiveMetadata( stringLiteralInputFields, baseClass: isDynamic ? 'dynamic' : null, isStructural, + hostDirectives, }; } diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index e3a9291589b..8aaae84a86f 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -3622,6 +3622,103 @@ suppress expect(ts.flattenDiagnosticMessageText(diags[0].messageText, '')) .toContain('HostBindDirective'); }); + + it('should check bindings to inherited host directive inputs', () => { + env.write('test.ts', ` + import {Component, Directive, NgModule, Input} from '@angular/core'; + + @Directive({ + standalone: true, + }) + class HostDir { + @Input() input: number; + @Input() otherInput: string; + } + + @Directive({ + hostDirectives: [{directive: HostDir, inputs: ['input', 'otherInput: alias']}] + }) + class Parent {} + + @Directive({selector: '[dir]'}) + class Dir extends Parent {} + + @Component({ + selector: 'test', + template: '
', + }) + class TestCmp { + person: { + name: string; + age: number; + }; + } + + @NgModule({ + declarations: [TestCmp, Dir], + }) + class Module {} + `); + + + const messages = env.driveDiagnostics().map(d => d.messageText); + + expect(messages).toEqual([ + `Type 'string' is not assignable to type 'number'.`, + `Type 'number' is not assignable to type 'string'.` + ]); + }); + + it('should check bindings to inherited host directive outputs', () => { + env.write('test.ts', ` + import {Component, Directive, NgModule, Output, EventEmitter} from '@angular/core'; + + @Directive({ + standalone: true, + }) + class HostDir { + @Output() stringEvent = new EventEmitter(); + @Output() numberEvent = new EventEmitter(); + } + + @Directive({ + hostDirectives: [ + {directive: HostDir, outputs: ['stringEvent', 'numberEvent: numberAlias']} + ] + }) + class Parent {} + + @Directive({selector: '[dir]'}) + class Dir extends Parent {} + + @Component({ + selector: 'test', + template: \` +
+ \`, + }) + class TestCmp { + handleStringEvent(event: string): void {} + handleNumberEvent(event: number): void {} + } + + @NgModule({ + declarations: [TestCmp, Dir], + }) + class Module {} + `); + + + const messages = env.driveDiagnostics().map(d => d.messageText); + + expect(messages).toEqual([ + `Argument of type 'number' is not assignable to parameter of type 'string'.`, + `Argument of type 'string' is not assignable to parameter of type 'number'.` + ]); + }); }); describe('deferred blocks', () => { diff --git a/packages/compiler/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 04bcd6df145..f0141d8e198 100644 --- a/packages/compiler/src/render3/view/compiler.ts +++ b/packages/compiler/src/render3/view/compiler.ts @@ -118,6 +118,12 @@ function addFeatures( break; } } + // Note: host directives feature needs to be inserted before the + // inheritance feature to ensure the correct execution order. + if (meta.hostDirectives?.length) { + features.push(o.importExpr(R3.HostDirectivesFeature).callFn([createHostDirectivesFeatureArg( + meta.hostDirectives)])); + } if (meta.usesInheritance) { features.push(o.importExpr(R3.InheritDefinitionFeature)); } @@ -131,10 +137,6 @@ function addFeatures( if (meta.hasOwnProperty('template') && meta.isStandalone) { features.push(o.importExpr(R3.StandaloneFeature)); } - if (meta.hostDirectives?.length) { - features.push(o.importExpr(R3.HostDirectivesFeature).callFn([createHostDirectivesFeatureArg( - meta.hostDirectives)])); - } if (features.length) { definitionMap.set('features', o.literalArr(features)); } diff --git a/packages/core/src/render3/features/host_directives_feature.ts b/packages/core/src/render3/features/host_directives_feature.ts index f63f66e5703..33fa8dd920e 100644 --- a/packages/core/src/render3/features/host_directives_feature.ts +++ b/packages/core/src/render3/features/host_directives_feature.ts @@ -11,7 +11,7 @@ import {Type} from '../../interface/type'; import {assertEqual} from '../../util/assert'; import {EMPTY_OBJ} from '../../util/empty'; import {getComponentDef, getDirectiveDef} from '../definition'; -import {DirectiveDef, HostDirectiveBindingMap, HostDirectiveDef, HostDirectiveDefs} from '../interfaces/definition'; +import {DirectiveDef, DirectiveDefFeature, HostDirectiveBindingMap, HostDirectiveDef, HostDirectiveDefs} from '../interfaces/definition'; /** Values that can be used to define a host directive through the `HostDirectivesFeature`. */ type HostDirectiveConfig = Type|{ @@ -42,9 +42,8 @@ type HostDirectiveConfig = Type|{ */ export function ɵɵHostDirectivesFeature(rawHostDirectives: HostDirectiveConfig[]| (() => HostDirectiveConfig[])) { - return (definition: DirectiveDef) => { - definition.findHostDirectiveDefs = findHostDirectiveDefs; - definition.hostDirectives = + const feature: DirectiveDefFeature = (definition: DirectiveDef) => { + const resolved = (Array.isArray(rawHostDirectives) ? rawHostDirectives : rawHostDirectives()).map(dir => { return typeof dir === 'function' ? {directive: resolveForwardRef(dir), inputs: EMPTY_OBJ, outputs: EMPTY_OBJ} : @@ -54,7 +53,15 @@ export function ɵɵHostDirectivesFeature(rawHostDirectives: HostDirectiveConfig outputs: bindingArrayToMap(dir.outputs) }; }); + if (definition.hostDirectives === null) { + definition.findHostDirectiveDefs = findHostDirectiveDefs; + definition.hostDirectives = resolved; + } else { + definition.hostDirectives.unshift(...resolved); + } }; + feature.ngInherit = true; + return feature; } function findHostDirectiveDefs( diff --git a/packages/core/test/acceptance/host_directives_spec.ts b/packages/core/test/acceptance/host_directives_spec.ts index 89636c60bc4..5cb8f7b3377 100644 --- a/packages/core/test/acceptance/host_directives_spec.ts +++ b/packages/core/test/acceptance/host_directives_spec.ts @@ -304,6 +304,95 @@ describe('host directives', () => { expect(fixture.nativeElement.textContent).toContain('FirstHost | SecondHost'); }); + it('should execute inherited host directives in the correct order', () => { + const logs: string[] = []; + + @Directive({standalone: true}) + class HostGrandparent_1 { + constructor() { + logs.push('HostGrandparent_1'); + } + } + + @Directive({standalone: true}) + class HostGrandparent_2 { + constructor() { + logs.push('HostGrandparent_2'); + } + } + + @Directive({standalone: true, hostDirectives: [HostGrandparent_1, HostGrandparent_2]}) + class Grandparent { + constructor() { + logs.push('Grandparent'); + } + } + + @Directive({standalone: true}) + class HostParent_1 { + constructor() { + logs.push('HostParent_1'); + } + } + + @Directive({standalone: true}) + class HostParent_2 { + constructor() { + logs.push('HostParent_2'); + } + } + + @Directive({standalone: true, hostDirectives: [HostParent_1, HostParent_2]}) + class Parent extends Grandparent { + constructor() { + super(); + logs.push('Parent'); + } + } + + @Directive({standalone: true}) + class HostDir_1 { + constructor() { + logs.push('HostDir_1'); + } + } + + @Directive({standalone: true}) + class HostDir_2 { + constructor() { + logs.push('HostDir_2'); + } + } + + @Directive({selector: '[dir]', hostDirectives: [HostDir_1, HostDir_2]}) + class Dir extends Parent { + constructor() { + super(); + logs.push('Dir'); + } + } + + @Component({template: '
'}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Dir]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(logs).toEqual([ + 'HostGrandparent_1', + 'HostGrandparent_2', + 'HostParent_1', + 'HostParent_2', + 'HostDir_1', + 'HostDir_2', + 'Grandparent', + 'Parent', + 'Dir', + ]); + }); + describe('lifecycle hooks', () => { it('should invoke lifecycle hooks from the host directives', () => { const logs: string[] = []; @@ -1329,6 +1418,40 @@ describe('host directives', () => { expect(fixture.componentInstance.spy).toHaveBeenCalledWith('FirstHostDir'); expect(fixture.componentInstance.spy).toHaveBeenCalledWith('SecondHostDir'); }); + + it('should emit to an output of an inherited host directive that has been exposed', () => { + @Directive({standalone: true, host: {'(click)': 'hasBeenClicked.emit("hello")'}}) + class HostDir { + @Output() hasBeenClicked = new EventEmitter(); + } + + @Directive({ + hostDirectives: [{ + directive: HostDir, + outputs: ['hasBeenClicked'], + }] + }) + class Parent { + } + + @Directive({selector: '[dir]'}) + class Dir extends Parent { + } + + @Component({template: ''}) + class App { + spy = jasmine.createSpy('click spy'); + } + + TestBed.configureTestingModule({declarations: [App, Dir]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.spy).toHaveBeenCalledOnceWith('hello'); + }); }); describe('inputs', () => { @@ -1874,6 +1997,36 @@ describe('host directives', () => { // The input on the button instance should not have been written to. expect(logs).toEqual(['spanValue']); }); + + it('should set the input of an inherited host directive that has been exposed', () => { + @Directive({standalone: true}) + class HostDir { + @Input() color?: string; + } + + @Directive({hostDirectives: [{directive: HostDir, inputs: ['color']}]}) + class Parent { + } + + @Directive({selector: '[dir]'}) + class Dir extends Parent { + } + + @Component({template: ''}) + class App { + @ViewChild(HostDir) hostDir!: HostDir; + color = 'red'; + } + + TestBed.configureTestingModule({declarations: [App, Dir]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.componentInstance.hostDir.color).toBe('red'); + + fixture.componentInstance.color = 'green'; + fixture.detectChanges(); + expect(fixture.componentInstance.hostDir.color).toBe('green'); + }); }); describe('ngOnChanges', () => { @@ -3318,5 +3471,33 @@ describe('host directives', () => { fixture.detectChanges(); }).not.toThrow(); }); + + it('should throw an error if a duplicate directive is inherited', () => { + @Directive({standalone: true}) + class HostDir { + } + + @Directive({standalone: true, hostDirectives: [HostDir]}) + class Grandparent { + } + + @Directive({standalone: true}) + class Parent extends Grandparent { + } + + @Directive({selector: '[dir]', hostDirectives: [HostDir]}) + class Dir extends Parent { + } + + @Component({template: '
'}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Dir]}); + + expect(() => TestBed.createComponent(App)) + .toThrowError( + 'NG0309: Directive HostDir matches multiple times on the same element. Directives can only match an element once.'); + }); }); });