From adb1a6167711d9afb82c0cbc09843ec88a48b0b2 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 14 Sep 2022 11:00:10 +0200 Subject: [PATCH] refactor(core): invoke basic host directives (#47430) Expands the runtime to allow for basic host directives to be invoked within a template. This is achieved by making a second pass over the directives that were matched based on their selectors and producing a new array of directives that include host directives. Note that the ordering in the array is important, because it determines which host bindings and DI tokens will be overwritten. PR Close #47430 --- .../size-tracking/integration-payloads.json | 2 +- packages/core/src/render3/definition.ts | 3 +- .../features/host_directives_feature.ts | 56 +- .../core/src/render3/instructions/shared.ts | 30 +- .../core/src/render3/interfaces/definition.ts | 23 +- packages/core/src/render3/jit/directive.ts | 8 +- .../test/acceptance/host_directives_spec.ts | 646 ++++++++++++++++++ 7 files changed, 739 insertions(+), 29 deletions(-) create mode 100644 packages/core/test/acceptance/host_directives_spec.ts diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 1189e882466217..e7ba2b3bd1558e 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -41,7 +41,7 @@ "forms": { "uncompressed": { "runtime": 1060, - "main": 156626, + "main": 157136, "polyfills": 33915 } }, diff --git a/packages/core/src/render3/definition.ts b/packages/core/src/render3/definition.ts index 2359f5ad8ee7a5..7d216319108e34 100644 --- a/packages/core/src/render3/definition.ts +++ b/packages/core/src/render3/definition.ts @@ -326,7 +326,8 @@ export function ɵɵdefineComponent(componentDefinition: { setInput: null, schemas: componentDefinition.schemas || null, tView: null, - applyHostDirectives: null, + findHostDirectiveDefs: null, + hostDirectives: null, }; const dependencies = componentDefinition.dependencies; const feature = componentDefinition.features; diff --git a/packages/core/src/render3/features/host_directives_feature.ts b/packages/core/src/render3/features/host_directives_feature.ts index f563e7f37dbad3..ccb360f01b5d1f 100644 --- a/packages/core/src/render3/features/host_directives_feature.ts +++ b/packages/core/src/render3/features/host_directives_feature.ts @@ -5,14 +5,16 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ +import {resolveForwardRef} from '../../di'; import {Type} from '../../interface/type'; import {EMPTY_OBJ} from '../../util/empty'; +import {getDirectiveDef} from '../definition'; import {DirectiveDef} from '../interfaces/definition'; import {TContainerNode, TElementContainerNode, TElementNode} from '../interfaces/node'; import {LView, TView} from '../interfaces/view'; /** Values that can be used to define a host directive through the `HostDirectivesFeature`. */ -type HostDirectiveDefiniton = Type|{ +type HostDirectiveConfig = Type|{ directive: Type; inputs?: string[]; outputs?: string[]; @@ -38,38 +40,54 @@ type HostDirectiveDefiniton = Type|{ * * @codeGenApi */ -export function ɵɵHostDirectivesFeature(rawHostDirectives: HostDirectiveDefiniton[]| - (() => HostDirectiveDefiniton[])) { - const unwrappedHostDirectives = - Array.isArray(rawHostDirectives) ? rawHostDirectives : rawHostDirectives(); - const hostDirectives = unwrappedHostDirectives.map( - dir => typeof dir === 'function' ? {directive: dir, inputs: EMPTY_OBJ, outputs: EMPTY_OBJ} : { - directive: dir.directive, - inputs: bindingArrayToMap(dir.inputs), - outputs: bindingArrayToMap(dir.outputs) - }); - +export function ɵɵHostDirectivesFeature(rawHostDirectives: HostDirectiveConfig[]| + (() => HostDirectiveConfig[])) { return (definition: DirectiveDef) => { - // TODO(crisbeto): implement host directive matching logic. - definition.applyHostDirectives = - (tView: TView, viewData: LView, tNode: TElementNode|TContainerNode|TElementContainerNode, - matches: any[]) => {}; + definition.findHostDirectiveDefs = findHostDirectiveDefs; + definition.hostDirectives = + (Array.isArray(rawHostDirectives) ? rawHostDirectives : rawHostDirectives()).map(dir => { + return typeof dir === 'function' ? + {directive: resolveForwardRef(dir), inputs: EMPTY_OBJ, outputs: EMPTY_OBJ} : + { + directive: resolveForwardRef(dir.directive), + inputs: bindingArrayToMap(dir.inputs), + outputs: bindingArrayToMap(dir.outputs) + }; + }); }; } +function findHostDirectiveDefs( + matches: DirectiveDef[], def: DirectiveDef, tView: TView, lView: LView, + tNode: TElementNode|TContainerNode|TElementContainerNode): void { + if (def.hostDirectives !== null) { + for (const hostDirectiveConfig of def.hostDirectives) { + const hostDirectiveDef = getDirectiveDef(hostDirectiveConfig.directive)!; + + // TODO(crisbeto): assert that the def exists. + + // Host directives execute before the host so that its host bindings can be overwritten. + findHostDirectiveDefs(matches, hostDirectiveDef, tView, lView, tNode); + } + } + + // Push the def itself at the end since it needs to execute after the host directives. + matches.push(def); +} + /** * Converts an array in the form of `['publicName', 'alias', 'otherPublicName', 'otherAlias']` into * a map in the form of `{publicName: 'alias', otherPublicName: 'otherAlias'}`. */ function bindingArrayToMap(bindings: string[]|undefined) { - if (!bindings || bindings.length === 0) { + if (bindings === undefined || bindings.length === 0) { return EMPTY_OBJ; } const result: {[publicName: string]: string} = {}; - for (let i = 1; i < bindings.length; i += 2) { - result[bindings[i - 1]] = bindings[i]; + for (let i = 0; i < bindings.length; i += 2) { + result[bindings[i]] = bindings[i + 1]; } return result; diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 925b4856c11404..9e6f3f9d19c5dc 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -1062,7 +1062,10 @@ export function resolveDirectives( let hasDirectives = false; if (getBindingsEnabled()) { - const directiveDefs: DirectiveDef[]|null = findDirectiveDefMatches(tView, lView, tNode); + const directiveDefsMatchedBySelectors = findDirectiveDefMatches(tView, lView, tNode); + const directiveDefs = directiveDefsMatchedBySelectors ? + findHostDirectiveDefs(directiveDefsMatchedBySelectors, tView, lView, tNode) : + null; const exportsMap: ({[key: string]: number}|null) = localRefs === null ? null : {'': -1}; if (directiveDefs !== null) { @@ -1288,8 +1291,6 @@ function findDirectiveDefMatches( } else { matches.push(def); } - - def.applyHostDirectives?.(tView, viewData, tNode, matches); } } } @@ -1308,6 +1309,29 @@ export function markAsComponentHost(tView: TView, hostTNode: TNode): void { .push(hostTNode.index); } +/** + * Given an array of directives that were matched by their selectors, this function + * produces a new array that also includes any host directives that have to be applied. + * @param selectorMatches Directives matched in a template based on their selectors. + * @param tView Current TView. + * @param lView Current LView. + * @param tNode Current TNode that is being matched. + */ +function findHostDirectiveDefs( + selectorMatches: DirectiveDef[], tView: TView, lView: LView, + tNode: TElementNode|TContainerNode|TElementContainerNode): DirectiveDef[] { + const matches: DirectiveDef[] = []; + + for (const def of selectorMatches) { + if (def.findHostDirectiveDefs === null) { + matches.push(def); + } else { + def.findHostDirectiveDefs(matches, def, tView, lView, tNode); + } + } + + return matches; +} /** Caches local names and their matching directive indices for query and template lookups. */ function cacheMatchingLocalNames( diff --git a/packages/core/src/render3/interfaces/definition.ts b/packages/core/src/render3/interfaces/definition.ts index bb439bcd07a8f9..226f345a918eb4 100644 --- a/packages/core/src/render3/interfaces/definition.ts +++ b/packages/core/src/render3/interfaces/definition.ts @@ -207,12 +207,15 @@ export interface DirectiveDef { readonly features: DirectiveDefFeature[]|null; /** - * Function that will apply the host directives to the list of matches during directive matching. + * Function that will add the host directives to the list of matches during directive matching. * Patched onto the definition by the `HostDirectivesFeature`. */ - applyHostDirectives: - ((tView: TView, viewData: LView, tNode: TElementNode|TContainerNode|TElementContainerNode, - matches: any[]) => void)|null; + findHostDirectiveDefs: + ((matches: DirectiveDef[], def: DirectiveDef, tView: TView, lView: LView, + tNode: TElementNode|TContainerNode|TElementContainerNode) => void)|null; + + /** Additional directives to be applied whenever the directive has been matched. */ + hostDirectives: HostDirectiveDef[]|null; setInput: (( @@ -403,6 +406,18 @@ export interface DirectiveDefFeature { ngInherit?: true; } +/** Runtime information used to configure a host directive. */ +export interface HostDirectiveDef { + /** Class representing the host directive. */ + directive: Type; + + /** Directive inputs that have been exposed. */ + inputs: {[publicName: string]: string}; + + /** Directive outputs that have been exposed. */ + outputs: {[publicName: string]: string}; +} + export interface ComponentDefFeature { (componentDef: ComponentDef): void; /** diff --git a/packages/core/src/render3/jit/directive.ts b/packages/core/src/render3/jit/directive.ts index 0daa179fe3b884..f2b2dd623f4df9 100644 --- a/packages/core/src/render3/jit/directive.ts +++ b/packages/core/src/render3/jit/directive.ts @@ -416,7 +416,13 @@ export function directiveMetadata(type: Type, metadata: Directive): R3Direc providers: metadata.providers || null, viewQueries: extractQueriesMetadata(type, propMetadata, isViewQuery), isStandalone: !!metadata.standalone, - hostDirectives: null, + hostDirectives: + // TODO(crisbeto): remove the `as any` usage here and down in the `map` call once + // host directives are exposed in the public API. + (metadata as any) + .hostDirectives?.map( + (directive: any) => typeof directive === 'function' ? {directive} : directive) || + null }; } diff --git a/packages/core/test/acceptance/host_directives_spec.ts b/packages/core/test/acceptance/host_directives_spec.ts new file mode 100644 index 00000000000000..a8bca6071a5b7b --- /dev/null +++ b/packages/core/test/acceptance/host_directives_spec.ts @@ -0,0 +1,646 @@ +/*! + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AfterViewChecked, AfterViewInit, Component, Directive, forwardRef, inject, Inject, InjectionToken, OnInit, ViewChild} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; + +/** + * Temporary `any` used for metadata until `hostDirectives` is enabled publicly. + * TODO(crisbeto): remove this once host directives are enabled in the public API. + */ +type HostDirectiveAny = any; + +describe('host directives', () => { + it('should apply a basic host directive', () => { + const logs: string[] = []; + + @Directive({ + standalone: true, + host: {'host-dir-attr': '', 'class': 'host-dir', 'style': 'height: 50px'} + }) + class HostDir { + constructor() { + logs.push('HostDir'); + } + } + + @Directive({ + selector: '[dir]', + host: {'host-attr': '', 'class': 'dir', 'style': 'width: 50px'}, + hostDirectives: [HostDir] + } as HostDirectiveAny) + class Dir { + constructor() { + logs.push('Dir'); + } + } + + @Component({template: '
'}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Dir]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(logs).toEqual(['HostDir', 'Dir']); + expect(fixture.nativeElement.innerHTML) + .toBe( + '
'); + }); + + it('should apply a host directive referenced through a forwardRef', () => { + const logs: string[] = []; + + @Directive({ + selector: '[dir]', + hostDirectives: [forwardRef(() => HostDir), {directive: forwardRef(() => OtherHostDir)}] + } as HostDirectiveAny) + class Dir { + constructor() { + logs.push('Dir'); + } + } + + @Directive({standalone: true}) + class HostDir { + constructor() { + logs.push('HostDir'); + } + } + + @Directive({standalone: true}) + class OtherHostDir { + constructor() { + logs.push('OtherHostDir'); + } + } + + @Component({template: '
'}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Dir]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(logs).toEqual(['HostDir', 'OtherHostDir', 'Dir']); + }); + + it('should apply a chain of host directives', () => { + const logs: string[] = []; + const token = new InjectionToken('message'); + let diTokenValue: string; + + @Directive({ + host: { + 'class': 'leaf', + 'id': 'leaf-id', + }, + providers: [{provide: token, useValue: 'leaf value'}], + standalone: true + }) + class Chain1_3 { + constructor(@Inject(token) tokenValue: string) { + diTokenValue = tokenValue; + logs.push('Chain1 - level 3'); + } + } + + @Directive({ + standalone: true, + hostDirectives: [Chain1_3], + } as HostDirectiveAny) + class Chain1_2 { + constructor() { + logs.push('Chain1 - level 2'); + } + } + + @Directive({ + standalone: true, + hostDirectives: [Chain1_2], + } as HostDirectiveAny) + class Chain1 { + constructor() { + logs.push('Chain1 - level 1'); + } + } + + @Directive({ + standalone: true, + host: { + 'class': 'middle', + 'id': 'middle-id', + }, + providers: [{provide: token, useValue: 'middle value'}], + }) + class Chain2_2 { + constructor() { + logs.push('Chain2 - level 2'); + } + } + + @Directive({ + standalone: true, + hostDirectives: [Chain2_2], + } as HostDirectiveAny) + class Chain2 { + constructor() { + logs.push('Chain2 - level 1'); + } + } + + @Directive({standalone: true}) + class Chain3_2 { + constructor() { + logs.push('Chain3 - level 2'); + } + } + + @Directive({standalone: true, hostDirectives: [Chain3_2]} as HostDirectiveAny) + class Chain3 { + constructor() { + logs.push('Chain3 - level 1'); + } + } + + @Component({ + selector: 'my-comp', + host: { + 'class': 'host', + 'id': 'host-id', + }, + template: '', + hostDirectives: [Chain1, Chain2, Chain3], + providers: [{provide: token, useValue: 'host value'}], + } as HostDirectiveAny) + class MyComp { + constructor() { + logs.push('host'); + } + } + @Component({template: ''}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, MyComp]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(diTokenValue!).toBe('host value'); + expect(fixture.nativeElement.innerHTML) + .toBe(''); + expect(logs).toEqual([ + 'Chain1 - level 3', + 'Chain1 - level 2', + 'Chain1 - level 1', + 'Chain2 - level 2', + 'Chain2 - level 1', + 'Chain3 - level 2', + 'Chain3 - level 1', + 'host', + ]); + }); + + it('should be able to query for the host directives', () => { + let hostInstance!: Host; + let firstHostDirInstance!: FirstHostDir; + let secondHostDirInstance!: SecondHostDir; + + @Directive({standalone: true}) + class SecondHostDir { + constructor() { + secondHostDirInstance = this; + } + } + + @Directive({standalone: true, hostDirectives: [SecondHostDir]} as HostDirectiveAny) + class FirstHostDir { + constructor() { + firstHostDirInstance = this; + } + } + + @Directive({selector: '[dir]', hostDirectives: [FirstHostDir]} as HostDirectiveAny) + class Host { + constructor() { + hostInstance = this; + } + } + + @Component({template: '
'}) + class App { + @ViewChild(FirstHostDir) firstHost!: FirstHostDir; + @ViewChild(SecondHostDir) secondHost!: SecondHostDir; + } + + TestBed.configureTestingModule({declarations: [App, Host]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(hostInstance instanceof Host).toBe(true); + expect(firstHostDirInstance instanceof FirstHostDir).toBe(true); + expect(secondHostDirInstance instanceof SecondHostDir).toBe(true); + + expect(fixture.componentInstance.firstHost).toBe(firstHostDirInstance); + expect(fixture.componentInstance.secondHost).toBe(secondHostDirInstance); + }); + + it('should be able to reference exported host directives', () => { + @Directive({standalone: true, exportAs: 'secondHost'}) + class SecondHostDir { + name = 'SecondHost'; + } + + @Directive( + {standalone: true, hostDirectives: [SecondHostDir], exportAs: 'firstHost'} as + HostDirectiveAny) + class FirstHostDir { + name = 'FirstHost'; + } + + @Directive({selector: '[dir]', hostDirectives: [FirstHostDir]} as HostDirectiveAny) + class Host { + } + + @Component({ + template: ` +
{{firstHost.name}} | {{secondHost.name}}
+ ` + }) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Host]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('FirstHost | SecondHost'); + }); + + it('should invoke lifecycle hooks from the host directives', () => { + const logs: string[] = []; + + @Directive({standalone: true}) + class HostDir implements OnInit, AfterViewInit, AfterViewChecked { + ngOnInit() { + logs.push('HostDir - ngOnInit'); + } + + ngAfterViewInit() { + logs.push('HostDir - ngAfterViewInit'); + } + + ngAfterViewChecked() { + logs.push('HostDir - ngAfterViewChecked'); + } + } + + @Directive({standalone: true}) + class OtherHostDir implements OnInit, AfterViewInit, AfterViewChecked { + ngOnInit() { + logs.push('OtherHostDir - ngOnInit'); + } + + ngAfterViewInit() { + logs.push('OtherHostDir - ngAfterViewInit'); + } + + ngAfterViewChecked() { + logs.push('OtherHostDir - ngAfterViewChecked'); + } + } + + @Directive({selector: '[dir]', hostDirectives: [HostDir, OtherHostDir]} as HostDirectiveAny) + class Dir implements OnInit, AfterViewInit, AfterViewChecked { + ngOnInit() { + logs.push('Dir - ngOnInit'); + } + + ngAfterViewInit() { + logs.push('Dir - ngAfterViewInit'); + } + + ngAfterViewChecked() { + logs.push('Dir - ngAfterViewChecked'); + } + } + + @Component({template: '
'}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Dir]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(logs).toEqual([ + 'HostDir - ngOnInit', + 'OtherHostDir - ngOnInit', + 'Dir - ngOnInit', + 'HostDir - ngAfterViewInit', + 'HostDir - ngAfterViewChecked', + 'OtherHostDir - ngAfterViewInit', + 'OtherHostDir - ngAfterViewChecked', + 'Dir - ngAfterViewInit', + 'Dir - ngAfterViewChecked', + ]); + }); + + describe('host bindings', () => { + it('should apply the host bindings from all host directives', () => { + const clicks: string[] = []; + + @Directive({standalone: true, host: {'host-dir-attr': 'true', '(click)': 'handleClick()'}}) + class HostDir { + handleClick() { + clicks.push('HostDir'); + } + } + + @Directive( + {standalone: true, host: {'other-host-dir-attr': 'true', '(click)': 'handleClick()'}}) + class OtherHostDir { + handleClick() { + clicks.push('OtherHostDir'); + } + } + + @Directive({ + selector: '[dir]', + host: {'host-attr': 'true', '(click)': 'handleClick()'}, + hostDirectives: [HostDir, OtherHostDir] + } as HostDirectiveAny) + class Dir { + handleClick() { + clicks.push('Dir'); + } + } + + @Component({template: ''}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Dir]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + const host = fixture.nativeElement.querySelector('[dir]'); + + expect(host.outerHTML) + .toBe( + ''); + + host.click(); + fixture.detectChanges(); + + expect(clicks).toEqual(['HostDir', 'OtherHostDir', 'Dir']); + }); + + it('should have the host bindings take precedence over the ones from the host directives', + () => { + @Directive({standalone: true, host: {'id': 'host-dir'}}) + class HostDir { + } + + @Directive({standalone: true, host: {'id': 'other-host-dir'}}) + class OtherHostDir { + } + + @Directive( + {selector: '[dir]', host: {'id': 'host'}, hostDirectives: [HostDir, OtherHostDir]} as + HostDirectiveAny) + class Dir { + } + + @Component({template: '
'}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Dir]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[dir]').getAttribute('id')).toBe('host'); + }); + }); + + describe('dependency injection', () => { + it('should allow the host directives to inject their host', () => { + let hostInstance!: Host; + let firstHostDirInstance!: FirstHostDir; + let secondHostDirInstance!: SecondHostDir; + + @Directive({standalone: true}) + class SecondHostDir { + host = inject(Host); + + constructor() { + secondHostDirInstance = this; + } + } + + @Directive({standalone: true, hostDirectives: [SecondHostDir]} as HostDirectiveAny) + class FirstHostDir { + host = inject(Host); + + constructor() { + firstHostDirInstance = this; + } + } + + @Directive({selector: '[dir]', hostDirectives: [FirstHostDir]} as HostDirectiveAny) + class Host { + constructor() { + hostInstance = this; + } + } + + @Component({template: '
'}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Host]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(hostInstance instanceof Host).toBe(true); + expect(firstHostDirInstance instanceof FirstHostDir).toBe(true); + expect(secondHostDirInstance instanceof SecondHostDir).toBe(true); + + expect(firstHostDirInstance.host).toBe(hostInstance); + expect(secondHostDirInstance.host).toBe(hostInstance); + }); + + it('should give precedence to the DI tokens from the host over the host directive tokens', + () => { + const token = new InjectionToken('token'); + let hostInstance!: Host; + let firstHostDirInstance!: FirstHostDir; + let secondHostDirInstance!: SecondHostDir; + + @Directive({standalone: true, providers: [{provide: token, useValue: 'SecondDir'}]}) + class SecondHostDir { + tokenValue = inject(token); + + constructor() { + secondHostDirInstance = this; + } + } + + @Directive({ + standalone: true, + hostDirectives: [SecondHostDir], + providers: [{provide: token, useValue: 'FirstDir'}] + } as HostDirectiveAny) + class FirstHostDir { + tokenValue = inject(token); + + constructor() { + firstHostDirInstance = this; + } + } + + @Directive({ + selector: '[dir]', + hostDirectives: [FirstHostDir], + providers: [{provide: token, useValue: 'HostDir'}] + } as HostDirectiveAny) + class Host { + tokenValue = inject(token); + + constructor() { + hostInstance = this; + } + } + + @Component({template: '
'}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Host]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(hostInstance instanceof Host).toBe(true); + expect(firstHostDirInstance instanceof FirstHostDir).toBe(true); + expect(secondHostDirInstance instanceof SecondHostDir).toBe(true); + + expect(hostInstance.tokenValue).toBe('HostDir'); + expect(firstHostDirInstance.tokenValue).toBe('HostDir'); + expect(secondHostDirInstance.tokenValue).toBe('HostDir'); + }); + + it('should allow the host to inject tokens from the host directives', () => { + const firstToken = new InjectionToken('firstToken'); + const secondToken = new InjectionToken('secondToken'); + + @Directive({standalone: true, providers: [{provide: secondToken, useValue: 'SecondDir'}]}) + class SecondHostDir { + } + + @Directive({ + standalone: true, + hostDirectives: [SecondHostDir], + providers: [{provide: firstToken, useValue: 'FirstDir'}] + } as HostDirectiveAny) + class FirstHostDir { + } + + @Directive({selector: '[dir]', hostDirectives: [FirstHostDir]} as HostDirectiveAny) + class Host { + firstTokenValue = inject(firstToken); + secondTokenValue = inject(secondToken); + } + + @Component({template: '
'}) + class App { + @ViewChild(Host) host!: Host; + } + + TestBed.configureTestingModule({declarations: [App, Host]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.componentInstance.host.firstTokenValue).toBe('FirstDir'); + expect(fixture.componentInstance.host.secondTokenValue).toBe('SecondDir'); + }); + + it('should not give precedence to tokens from host directives over ones in viewProviders', + () => { + const token = new InjectionToken('token'); + let tokenValue: string|undefined; + + @Directive({standalone: true, providers: [{provide: token, useValue: 'host-dir'}]}) + class HostDir { + } + + @Component({ + selector: 'host', + hostDirectives: [HostDir], + providers: [{provide: token, useValue: 'host'}], + template: '', + } as HostDirectiveAny) + class Host { + } + + @Directive({selector: '[child]'}) + class Child { + constructor() { + tokenValue = inject(token); + } + } + + @Component({template: ''}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Host, Child]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(tokenValue).toBe('host'); + }); + + it('should not be able to access viewProviders from the host in the host directives', () => { + const token = new InjectionToken('token'); + let tokenValue: string|null = null; + + @Directive({standalone: true}) + class HostDir { + constructor() { + tokenValue = inject(token, {optional: true}); + } + } + + @Component({ + selector: 'host', + hostDirectives: [HostDir], + viewProviders: [{provide: token, useValue: 'host'}], + template: '', + } as HostDirectiveAny) + class Host { + } + + @Component({template: ''}) + class App { + } + + TestBed.configureTestingModule({declarations: [App, Host]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(tokenValue).toBe(null); + }); + }); +});