mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
29e0834c4d
commit
c7c7ea9813
5 changed files with 302 additions and 9 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue