feat(core): add API to inject attributes on the host node (#54604)

Angular has the `@Attribute` decorator that allows for attributes to be injected from the host node, but we don't have an equivalent for the `inject` function. These changes introduce the new `HostAttributeToken` class that can be used to inject attributes similarly to `@Attribute`. It can be used as follows:

```typescript
import {HostAttributeToken, inject} from '@angular/core';

class MyDir {
  someAttr = inject(new HostAttributeToken('some-attr'));
}
```

The new API works similarly to `@Attribute` with one key exception: it will throw a DI error when the attribute doesn't exist, instead of returning `null` like `@Attribute`. We made this change to align its behavior closer to other injection tokens.

PR Close #54604
This commit is contained in:
Kristiyan Kostadinov 2024-02-27 21:46:35 +01:00 committed by Dylan Hunn
parent 4066e62db6
commit 331b16efd2
6 changed files with 396 additions and 5 deletions

View file

@ -720,6 +720,13 @@ export interface Host {
// @public
export const Host: HostDecorator;
// @public
export class HostAttributeToken {
constructor(attributeName: string);
// (undocumented)
toString(): string;
}
// @public
export interface HostBinding {
hostPropertyName?: string;
@ -789,6 +796,19 @@ export function inject<T>(token: ProviderToken<T>, options: InjectOptions & {
// @public (undocumented)
export function inject<T>(token: ProviderToken<T>, options: InjectOptions): T | null;
// @public (undocumented)
export function inject(token: HostAttributeToken): string;
// @public (undocumented)
export function inject(token: HostAttributeToken, options: {
optional: true;
}): string | null;
// @public (undocumented)
export function inject(token: HostAttributeToken, options: {
optional: false;
}): string;
// @public
export interface Injectable {
providedIn?: Type<any> | 'root' | 'platform' | 'any' | null;

View file

@ -0,0 +1,41 @@
/*!
* @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 {ɵɵinjectAttribute} from '../render3/instructions/di_attr';
/**
* Creates a token that can be used to inject static attributes of the host node.
*
* @usageNotes
* ### Injecting an attribute that is known to exist
* ```typescript
* @Directive()
* class MyDir {
* attr: string = inject(new HostAttributeToken('some-attr'));
* }
* ```
*
* ### Optionally injecting an attribute
* ```typescript
* @Directive()
* class MyDir {
* attr: string | null = inject(new HostAttributeToken('some-attr'), {optional: true});
* }
* ```
* @publicApi
*/
export class HostAttributeToken {
constructor(private attributeName: string) {}
/** @internal */
__NG_ELEMENT_ID__ = () => ɵɵinjectAttribute(this.attributeName);
toString(): string {
return `HostAttributeToken ${this.attributeName}`;
}
}

View file

@ -28,3 +28,4 @@ export {InjectOptions} from './interface/injector';
export {INJECTOR} from './injector_token';
export {ClassProvider, ModuleWithProviders, ClassSansProvider, ImportedNgModuleProviders, ConstructorProvider, EnvironmentProviders, ConstructorSansProvider, ExistingProvider, ExistingSansProvider, FactoryProvider, FactorySansProvider, Provider, StaticClassProvider, StaticClassSansProvider, StaticProvider, TypeProvider, ValueProvider, ValueSansProvider} from './interface/provider';
export {InjectionToken} from './injection_token';
export {HostAttributeToken} from './host_attribute_token';

View file

@ -18,6 +18,7 @@ import {getInjectImplementation, injectRootLimpMode} from './inject_switch';
import type {Injector} from './injector';
import {DecoratorFlags, InjectFlags, InjectOptions, InternalInjectFlags} from './interface/injector';
import {ProviderToken} from './provider_token';
import type {HostAttributeToken} from './host_attribute_token';
const _THROW_IF_NOT_FOUND = {};
@ -85,8 +86,14 @@ export function injectInjectorOnly<T>(token: ProviderToken<T>, flags = InjectFla
*/
export function ɵɵinject<T>(token: ProviderToken<T>): T;
export function ɵɵinject<T>(token: ProviderToken<T>, flags?: InjectFlags): T|null;
export function ɵɵinject<T>(token: ProviderToken<T>, flags = InjectFlags.Default): T|null {
return (getInjectImplementation() || injectInjectorOnly)(resolveForwardRef(token), flags);
export function ɵɵinject(token: HostAttributeToken): string;
export function ɵɵinject(token: HostAttributeToken, flags?: InjectFlags): string|null;
export function ɵɵinject<T>(
token: ProviderToken<T>|HostAttributeToken, flags?: InjectFlags): string|null;
export function ɵɵinject<T>(
token: ProviderToken<T>|HostAttributeToken, flags = InjectFlags.Default): T|null {
return (getInjectImplementation() || injectInjectorOnly)(
resolveForwardRef(token as Type<T>), flags);
}
/**
@ -153,6 +160,30 @@ export function inject<T>(token: ProviderToken<T>, options: InjectOptions&{optio
* @publicApi
*/
export function inject<T>(token: ProviderToken<T>, options: InjectOptions): T|null;
/**
* @param token A token that represents a static attribute on the host node that should be injected.
* @returns Value of the attribute if it exists.
* @throws If called outside of a supported context or the attribute does not exist.
*
* @publicApi
*/
export function inject(token: HostAttributeToken): string;
/**
* @param token A token that represents a static attribute on the host node that should be injected.
* @returns Value of the attribute if it exists, otherwise `null`.
* @throws If called outside of a supported context.
*
* @publicApi
*/
export function inject(token: HostAttributeToken, options: {optional: true}): string|null;
/**
* @param token A token that represents a static attribute on the host node that should be injected.
* @returns Value of the attribute if it exists.
* @throws If called outside of a supported context or the attribute does not exist.
*
* @publicApi
*/
export function inject(token: HostAttributeToken, options: {optional: false}): string;
/**
* Injects a token from the currently active injector.
* `inject` is only supported in an [injection context](/guide/dependency-injection-context). It can
@ -219,8 +250,11 @@ export function inject<T>(token: ProviderToken<T>, options: InjectOptions): T|nu
* @publicApi
*/
export function inject<T>(
token: ProviderToken<T>, flags: InjectFlags|InjectOptions = InjectFlags.Default): T|null {
return ɵɵinject(token, convertToBitFlags(flags));
token: ProviderToken<T>|HostAttributeToken,
flags: InjectFlags|InjectOptions = InjectFlags.Default) {
// The `as any` here _shouldn't_ be necessary, but without it JSCompiler
// throws a disambiguation error due to the multiple signatures.
return ɵɵinject(token as any, convertToBitFlags(flags));
}
// Converts object-based DI flags (`InjectOptions`) to bit flags (`InjectFlags`).

View file

@ -7,7 +7,7 @@
*/
import {CommonModule} from '@angular/common';
import {assertInInjectionContext, Attribute, ChangeDetectorRef, Component, ComponentRef, createEnvironmentInjector, createNgModule, Directive, ElementRef, ENVIRONMENT_INITIALIZER, EnvironmentInjector, EventEmitter, forwardRef, Host, HostBinding, ImportedNgModuleProviders, importProvidersFrom, ImportProvidersSource, inject, Inject, Injectable, InjectFlags, InjectionToken, InjectOptions, INJECTOR, Injector, Input, LOCALE_ID, makeEnvironmentProviders, ModuleWithProviders, NgModule, NgModuleRef, NgZone, Optional, Output, Pipe, PipeTransform, Provider, runInInjectionContext, Self, SkipSelf, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ViewRef, ɵcreateInjector as createInjector, ɵDEFAULT_LOCALE_ID as DEFAULT_LOCALE_ID, ɵINJECTOR_SCOPE, ɵInternalEnvironmentProviders as InternalEnvironmentProviders} from '@angular/core';
import {assertInInjectionContext, Attribute, ChangeDetectorRef, Component, ComponentRef, createEnvironmentInjector, createNgModule, Directive, ElementRef, ENVIRONMENT_INITIALIZER, EnvironmentInjector, EventEmitter, forwardRef, Host, HostAttributeToken, HostBinding, importProvidersFrom, ImportProvidersSource, inject, Inject, Injectable, InjectFlags, InjectionToken, InjectOptions, INJECTOR, Injector, Input, LOCALE_ID, makeEnvironmentProviders, ModuleWithProviders, NgModule, NgModuleRef, NgZone, Optional, Output, Pipe, PipeTransform, Provider, runInInjectionContext, Self, SkipSelf, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ViewRef, ɵcreateInjector as createInjector, ɵDEFAULT_LOCALE_ID as DEFAULT_LOCALE_ID, ɵINJECTOR_SCOPE, ɵInternalEnvironmentProviders as InternalEnvironmentProviders} from '@angular/core';
import {RuntimeError, RuntimeErrorCode} from '@angular/core/src/errors';
import {ViewRef as ViewRefInternal} from '@angular/core/src/render3/view_ref';
import {TestBed} from '@angular/core/testing';
@ -4215,6 +4215,298 @@ describe('di', () => {
});
});
describe('HostAttributeToken', () => {
it('should inject an attribute on an element node', () => {
@Directive({selector: '[dir]', standalone: true})
class Dir {
value = inject(new HostAttributeToken('some-attr'));
}
@Component({
standalone: true,
template: '<div dir some-attr="foo" other="ignore"></div>',
imports: [Dir],
})
class TestCmp {
@ViewChild(Dir) dir!: Dir;
}
const fixture = TestBed.createComponent(TestCmp);
fixture.detectChanges();
expect(fixture.componentInstance.dir.value).toBe('foo');
});
it('should inject an attribute on <ng-template>', () => {
@Directive({selector: '[dir]', standalone: true})
class Dir {
value = inject(new HostAttributeToken('some-attr'));
}
@Component({
standalone: true,
template: '<ng-template dir some-attr="foo" other="ignore"></ng-template>',
imports: [Dir],
})
class TestCmp {
@ViewChild(Dir) dir!: Dir;
}
const fixture = TestBed.createComponent(TestCmp);
fixture.detectChanges();
expect(fixture.componentInstance.dir.value).toBe('foo');
});
it('should inject an attribute on <ng-container>', () => {
@Directive({selector: '[dir]', standalone: true})
class Dir {
value = inject(new HostAttributeToken('some-attr'));
}
@Component({
standalone: true,
template: '<ng-container dir some-attr="foo" other="ignore"></ng-container>',
imports: [Dir],
})
class TestCmp {
@ViewChild(Dir) dir!: Dir;
}
const fixture = TestBed.createComponent(TestCmp);
fixture.detectChanges();
expect(fixture.componentInstance.dir.value).toBe('foo');
});
it('should be able to inject different kinds of attributes', () => {
@Directive({selector: '[dir]', standalone: true})
class Dir {
className = inject(new HostAttributeToken('class'));
inlineStyles = inject(new HostAttributeToken('style'));
value = inject(new HostAttributeToken('some-attr'));
}
@Component({
standalone: true,
template: `
<div
dir
style="margin: 1px; color: red;"
class="hello there"
some-attr="foo"
other="ignore"></div>
`,
imports: [Dir],
})
class TestCmp {
@ViewChild(Dir) dir!: Dir;
}
const fixture = TestBed.createComponent(TestCmp);
fixture.detectChanges();
const directive = fixture.componentInstance.dir;
expect(directive.className).toBe('hello there');
expect(directive.inlineStyles).toMatch(/color:\s*red/);
expect(directive.inlineStyles).toMatch(/margin:\s*1px/);
expect(directive.value).toBe('foo');
});
it('should throw a DI error when injecting a non-existent attribute', () => {
@Directive({selector: '[dir]', standalone: true})
class Dir {
value = inject(new HostAttributeToken('some-attr'));
}
@Component({
standalone: true,
template: '<div dir other="ignore"></div>',
imports: [Dir],
})
class TestCmp {
@ViewChild(Dir) dir!: Dir;
}
expect(() => TestBed.createComponent(TestCmp))
.toThrowError(/No provider for HostAttributeToken some-attr found/);
});
it('should not throw a DI error when injecting a non-existent attribute with optional: true',
() => {
@Directive({selector: '[dir]', standalone: true})
class Dir {
value = inject(new HostAttributeToken('some-attr'), {optional: true});
}
@Component({
standalone: true,
template: '<div dir other="ignore"></div>',
imports: [Dir],
})
class TestCmp {
@ViewChild(Dir) dir!: Dir;
}
const fixture = TestBed.createComponent(TestCmp);
fixture.detectChanges();
expect(fixture.componentInstance.dir.value).toBe(null);
});
it('should not inject attributes with namespace', () => {
@Directive({selector: '[dir]', standalone: true})
class Dir {
value = inject(new HostAttributeToken('some-attr'), {optional: true});
namespaceExists = inject(new HostAttributeToken('svg:exist'), {optional: true});
other = inject(new HostAttributeToken('other'), {optional: true});
}
@Component({
standalone: true,
template: `
<div dir some-attr="foo" svg:exists="testExistValue" other="otherValue"></div>
`,
imports: [Dir],
})
class TestCmp {
@ViewChild(Dir) dir!: Dir;
}
const fixture = TestBed.createComponent(TestCmp);
fixture.detectChanges();
const directive = fixture.componentInstance.dir;
expect(directive.value).toBe('foo');
expect(directive.namespaceExists).toBe(null);
expect(directive.other).toBe('otherValue');
});
it('should not inject attributes representing bindings and outputs', () => {
@Directive({selector: '[dir]', standalone: true})
class Dir {
@Input() binding!: string;
@Output() output = new EventEmitter();
exists = inject(new HostAttributeToken('exists'));
bindingAttr = inject(new HostAttributeToken('binding'), {optional: true});
outputAttr = inject(new HostAttributeToken('output'), {optional: true});
other = inject(new HostAttributeToken('other'));
}
@Component({
standalone: true,
imports: [Dir],
template: `
<div
dir
exists="existsValue"
[binding]="bindingValue"
(output)="noop()"
other="otherValue"
ignore="ignoreValue"></div>`
})
class TestCmp {
@ViewChild(Dir) dir!: Dir;
bindingValue = 'hello';
noop() {}
}
const fixture = TestBed.createComponent(TestCmp);
fixture.detectChanges();
const directive = fixture.componentInstance.dir;
expect(directive.exists).toBe('existsValue');
expect(directive.bindingAttr).toBe(null);
expect(directive.outputAttr).toBe(null);
expect(directive.other).toBe('otherValue');
});
it('should not inject data-bound attributes', () => {
@Directive({selector: '[dir]', standalone: true})
class Dir {
value = inject(new HostAttributeToken('title'), {optional: true});
}
@Component({
standalone: true,
template: '<div dir title="foo {{value}}" other="ignore"></div>',
imports: [Dir],
})
class TestCmp {
@ViewChild(Dir) dir!: Dir;
value = 123;
}
const fixture = TestBed.createComponent(TestCmp);
fixture.detectChanges();
expect(fixture.componentInstance.dir.value).toBe(null);
expect(fixture.nativeElement.querySelector('[dir]').getAttribute('title')).toBe('foo 123');
});
it('should inject an attribute using @Inject', () => {
const TOKEN = new HostAttributeToken('some-attr');
@Directive({selector: '[dir]', standalone: true})
class Dir {
constructor(@Inject(TOKEN) readonly value: string) {}
}
@Component({
standalone: true,
template: '<div dir some-attr="foo" other="ignore"></div>',
imports: [Dir],
})
class TestCmp {
@ViewChild(Dir) dir!: Dir;
}
const fixture = TestBed.createComponent(TestCmp);
fixture.detectChanges();
expect(fixture.componentInstance.dir.value).toBe('foo');
});
it('should throw when injecting a non-existent attribute using @Inject', () => {
const TOKEN = new HostAttributeToken('some-attr');
@Directive({selector: '[dir]', standalone: true})
class Dir {
constructor(@Inject(TOKEN) readonly value: string) {}
}
@Component({
standalone: true,
template: '<div dir other="ignore"></div>',
imports: [Dir],
})
class TestCmp {
@ViewChild(Dir) dir!: Dir;
}
expect(() => TestBed.createComponent(TestCmp))
.toThrowError(/No provider for HostAttributeToken some-attr found/);
});
it('should not throw when injecting a non-existent attribute using @Inject @Optional', () => {
const TOKEN = new HostAttributeToken('some-attr');
@Directive({selector: '[dir]', standalone: true})
class Dir {
constructor(@Inject(TOKEN) @Optional() readonly value: string|null) {}
}
@Component({
standalone: true,
template: '<div dir other="ignore"></div>',
imports: [Dir],
})
class TestCmp {
@ViewChild(Dir) dir!: Dir;
}
const fixture = TestBed.createComponent(TestCmp);
fixture.detectChanges();
expect(fixture.componentInstance.dir.value).toBe(null);
});
});
it('should support dependencies in Pipes used inside ICUs', () => {
@Injectable()
class MyService {

View file

@ -1367,6 +1367,9 @@
{
"name": "init_hooks"
},
{
"name": "init_host_attribute_token"
},
{
"name": "init_host_directives_feature"
},