fix(core): inherit host directives (#52992)

Adds support for inheriting host directives from the parent class. This is consistent with how we inherit other features like host bindings.

Fixes #51203.

PR Close #52992
This commit is contained in:
Kristiyan Kostadinov 2023-11-17 21:23:28 +01:00 committed by Andrew Kushnir
parent 29e0834c4d
commit c7c7ea9813
5 changed files with 302 additions and 9 deletions

View file

@ -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<ClassPropertyName>();
const restrictedInputFields = new Set<ClassPropertyName>();
const stringLiteralInputFields = new Set<ClassPropertyName>();
let hostDirectives: HostDirectiveMeta[]|null = null;
let isDynamic = false;
let inputs = ClassPropertyMapping.empty<InputMapping>();
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,
};
}

View file

@ -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: '<div dir [input]="person.name" [alias]="person.age"></div>',
})
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<string>();
@Output() numberEvent = new EventEmitter<number>();
}
@Directive({
hostDirectives: [
{directive: HostDir, outputs: ['stringEvent', 'numberEvent: numberAlias']}
]
})
class Parent {}
@Directive({selector: '[dir]'})
class Dir extends Parent {}
@Component({
selector: 'test',
template: \`
<div
dir
(numberAlias)="handleStringEvent($event)"
(stringEvent)="handleNumberEvent($event)"></div>
\`,
})
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', () => {

View file

@ -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));
}

View file

@ -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<unknown>|{
@ -42,9 +42,8 @@ type HostDirectiveConfig = Type<unknown>|{
*/
export function ɵɵHostDirectivesFeature(rawHostDirectives: HostDirectiveConfig[]|
(() => HostDirectiveConfig[])) {
return (definition: DirectiveDef<unknown>) => {
definition.findHostDirectiveDefs = findHostDirectiveDefs;
definition.hostDirectives =
const feature: DirectiveDefFeature = (definition: DirectiveDef<unknown>) => {
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(

View file

@ -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: '<div dir></div>'})
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<string>();
}
@Directive({
hostDirectives: [{
directive: HostDir,
outputs: ['hasBeenClicked'],
}]
})
class Parent {
}
@Directive({selector: '[dir]'})
class Dir extends Parent {
}
@Component({template: '<button dir (hasBeenClicked)="spy($event)"></button>'})
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: '<button dir [color]="color"></button>'})
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: '<div dir></div>'})
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.');
});
});
});