feat(common): add an 'outlet' injector option for ngTemplateOutlet

Adds an option (`ngTemplateOutletInjector="outlet"`) that instructs the ngTemplateOutlet to inherit its injector from the outlet's place in the instantiated DOM.
This commit is contained in:
Miles Malerba
2026-02-06 20:31:44 -08:00
committed by Andrew Kushnir
parent a7e8abbb7e
commit 18003a33bb
3 changed files with 91 additions and 5 deletions
+3 -1
View File
@@ -729,10 +729,12 @@ export class NgSwitchDefault {
export class NgTemplateOutlet<C = unknown> implements OnChanges {
constructor(_viewContainerRef: ViewContainerRef);
// (undocumented)
protected injector: Injector;
// (undocumented)
ngOnChanges(changes: SimpleChanges): void;
ngTemplateOutlet: TemplateRef<C> | null | undefined;
ngTemplateOutletContext: C | null | undefined;
ngTemplateOutletInjector: Injector | null | undefined;
ngTemplateOutletInjector: Injector | 'outlet' | null | undefined;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<NgTemplateOutlet<any>, "[ngTemplateOutlet]", never, { "ngTemplateOutletContext": { "alias": "ngTemplateOutletContext"; "required": false; }; "ngTemplateOutlet": { "alias": "ngTemplateOutlet"; "required": false; }; "ngTemplateOutletInjector": { "alias": "ngTemplateOutletInjector"; "required": false; }; }, {}, never, never, true, never>;
// (undocumented)
@@ -9,6 +9,7 @@
import {
Directive,
EmbeddedViewRef,
inject,
Injector,
Input,
OnChanges,
@@ -60,8 +61,13 @@ export class NgTemplateOutlet<C = unknown> implements OnChanges {
*/
@Input() public ngTemplateOutlet: TemplateRef<C> | null | undefined = null;
/** Injector to be used within the embedded view. */
@Input() public ngTemplateOutletInjector: Injector | null | undefined = null;
/**
* Injector to be used within the embedded view. A value of "outlet" can be used to indicate
* that the injector should be inherited from the template outlet's location in the instantiated DOM.
*/
@Input() public ngTemplateOutletInjector: Injector | 'outlet' | null | undefined = null;
protected injector = inject(Injector);
constructor(private _viewContainerRef: ViewContainerRef) {}
@@ -83,11 +89,21 @@ export class NgTemplateOutlet<C = unknown> implements OnChanges {
// without having to destroy and re-create views whenever the context changes.
const viewContext = this._createContextForwardProxy();
this._viewRef = viewContainerRef.createEmbeddedView(this.ngTemplateOutlet, viewContext, {
injector: this.ngTemplateOutletInjector ?? undefined,
injector: this._getInjector(),
});
}
}
/**
* Gets the injector to use for the template outlet based on ngTemplateOutletInjector.
*/
private _getInjector(): Injector | undefined {
if (this.ngTemplateOutletInjector === 'outlet') {
return this.injector;
}
return this.ngTemplateOutletInjector ?? undefined;
}
/**
* We need to re-create existing embedded view if either is true:
* - the outlet changed.
@@ -6,7 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {CommonModule, NgTemplateOutlet} from '../../index';
import {
Component,
ContentChildren,
@@ -17,12 +16,16 @@ import {
Injector,
NO_ERRORS_SCHEMA,
OnDestroy,
Optional,
Provider,
QueryList,
SkipSelf,
TemplateRef,
inject,
} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {expect} from '@angular/private/testing/matchers';
import {CommonModule, NgTemplateOutlet} from '../../index';
describe('NgTemplateOutlet', () => {
let fixture: ComponentFixture<any>;
@@ -49,6 +52,8 @@ describe('NgTemplateOutlet', () => {
DestroyableCmpt,
MultiContextComponent,
InjectValueComponent,
ProvideValueComponent,
NestingCounter,
],
imports: [CommonModule],
providers: [DestroyedSpyService],
@@ -360,6 +365,43 @@ describe('NgTemplateOutlet', () => {
detectChangesAndExpectText('Hello world');
});
it('should be able to inherit outlet injector', () => {
const template = `
<ng-template #tpl><inject-value></inject-value></ng-template>
<provide-value>
<ng-container *ngTemplateOutlet="tpl; injector: 'outlet'"></ng-container>
</provide-value>
`;
fixture = createTestComponent(template, [{provide: templateToken, useValue: 'root'}]);
detectChangesAndExpectText('Hello provide-value');
});
it('should be able to inherit outlet injector in a deeply nested structure', () => {
// This template should create the following rendered structure
// (Spaces & newlines added for readability):
// <nesting-counter> 1
// <nesting counter> 2
// <nesting-counter> 3
// <nesting-counter> 4 </nesting-counter>
// </nesting-counter>
// </nesting-counter>
// <nesting-counter> 2 </nesting-counter>
// </nesting-counter>
const template = `
<ng-container *ngTemplateOutlet="node; context: {$implicit: [[[[]]], []]}" />
<ng-template #node let-data>
<nesting-counter>
@for (item of data; track $index) {
<ng-container *ngTemplateOutlet="node; context: {$implicit: item}; injector: 'outlet'" />
}
</nesting-counter>
</ng-template>
`;
fixture = createTestComponent(template);
detectChangesAndExpectText('12342');
});
it('should be available as a standalone directive', () => {
@Component({
selector: 'test-component',
@@ -443,6 +485,14 @@ class TestComponent {
injector: Injector | null = null;
}
@Component({
selector: 'provide-value',
template: '<ng-content />',
providers: [{provide: templateToken, useValue: 'provide-value'}],
standalone: false,
})
class ProvideValueComponent {}
@Component({
selector: 'inject-value',
template: 'Hello {{tokenValue}}',
@@ -466,6 +516,24 @@ class MultiContextComponent {
context2: {name: string} | undefined;
}
const NESTING_DEPTH = new InjectionToken<number>('NESTING_DEPTH');
@Component({
selector: 'nesting-counter',
template: '{{depth}}<ng-content />',
providers: [
{
provide: NESTING_DEPTH,
useFactory: (l: number) => (l ? l + 1 : 1),
deps: [[new Optional(), new SkipSelf(), NESTING_DEPTH]],
},
],
standalone: false,
})
class NestingCounter {
depth = inject(NESTING_DEPTH);
}
function createTestComponent(
template: string,
providers: Provider[] = [],