Skip to content

Commit dcb9deb

Browse files
AndrewKushnirdylhunn
authored andcommitted
fix(core): collect providers from NgModules while rendering @defer block (#52881)
Currently, when a `@defer` block contains standalone components that import NgModules with providers, those providers are not available to components declared within the same NgModule. The problem is that the standalone injector is not created for the host component (that hosts this `@defer` block), since dependencies become defer-loaded, thus no information is available at host component creation time. This commit updates the logic to collect all providers from all NgModules used as a dependency for standalone components used within a `@defer` block. When an instance of a defer block is created, a new environment injector instance with those providers is created. Resolves #52876. PR Close #52881
1 parent 0d95ae5 commit dcb9deb

File tree

5 files changed

+275
-3
lines changed

5 files changed

+275
-3
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {ɵɵdefineInjectable as defineInjectable} from './di/interface/defs';
10+
import {Provider} from './di/interface/provider';
11+
import {EnvironmentInjector} from './di/r3_injector';
12+
import {OnDestroy} from './interface/lifecycle_hooks';
13+
import {createEnvironmentInjector} from './render3/ng_module_ref';
14+
15+
/**
16+
* A service used by the framework to create and cache injector instances.
17+
*
18+
* This service is used to create a single injector instance for each defer
19+
* block definition, to avoid creating an injector for each defer block instance
20+
* of a certain type.
21+
*/
22+
export class CachedInjectorService implements OnDestroy {
23+
private cachedInjectors = new Map<unknown, EnvironmentInjector|null>();
24+
25+
getOrCreateInjector(
26+
key: unknown, parentInjector: EnvironmentInjector, providers: Provider[],
27+
debugName?: string) {
28+
if (!this.cachedInjectors.has(key)) {
29+
const injector = providers.length > 0 ?
30+
createEnvironmentInjector(providers, parentInjector, debugName) :
31+
null;
32+
this.cachedInjectors.set(key, injector);
33+
}
34+
return this.cachedInjectors.get(key)!;
35+
}
36+
37+
ngOnDestroy() {
38+
try {
39+
for (const injector of this.cachedInjectors.values()) {
40+
if (injector !== null) {
41+
injector.destroy();
42+
}
43+
}
44+
} finally {
45+
this.cachedInjectors.clear();
46+
}
47+
}
48+
49+
/** @nocollapse */
50+
static ɵprov = /** @pureOrBreakMyCode */ defineInjectable({
51+
token: CachedInjectorService,
52+
providedIn: 'environment',
53+
factory: () => new CachedInjectorService(),
54+
});
55+
}

packages/core/src/defer/instructions.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
import {setActiveConsumer} from '@angular/core/primitives/signals';
1010

11-
import {InjectionToken, Injector} from '../di';
11+
import {CachedInjectorService} from '../cached_injector_service';
12+
import {EnvironmentInjector, InjectionToken, Injector} from '../di';
13+
import {internalImportProvidersFrom} from '../di/provider_collection';
1214
import {RuntimeError, RuntimeErrorCode} from '../errors';
1315
import {findMatchingDehydratedView} from '../hydration/views';
1416
import {populateDehydratedViewsInLContainer} from '../linker/view_container_ref';
@@ -145,6 +147,7 @@ export function ɵɵdefer(
145147
dependencyResolverFn: dependencyResolverFn ?? null,
146148
loadingState: DeferDependenciesLoadingState.NOT_STARTED,
147149
loadingPromise: null,
150+
providers: null,
148151
};
149152
enableTimerScheduling?.(tView, tDetails, placeholderConfigIndex, loadingConfigIndex);
150153
setTDeferBlockDetails(tView, adjustedIndex, tDetails);
@@ -518,9 +521,29 @@ function applyDeferBlockState(
518521
const viewIndex = 0;
519522

520523
removeLViewFromLContainer(lContainer, viewIndex);
524+
525+
let injector: Injector|undefined;
526+
if (newState === DeferBlockState.Complete) {
527+
// When we render a defer block in completed state, there might be
528+
// newly loaded standalone components used within the block, which may
529+
// import NgModules with providers. In order to make those providers
530+
// available for components declared in that NgModule, we create an instance
531+
// of environment injector to host those providers and pass this injector
532+
// to the logic that creates a view.
533+
const tDetails = getTDeferBlockDetails(hostTView, tNode);
534+
const providers = tDetails.providers;
535+
if (providers && providers.length > 0) {
536+
const parentInjector = hostLView[INJECTOR] as Injector;
537+
const parentEnvInjector = parentInjector.get(EnvironmentInjector);
538+
injector =
539+
parentEnvInjector.get(CachedInjectorService)
540+
.getOrCreateInjector(
541+
tDetails, parentEnvInjector, providers, ngDevMode ? 'DeferBlock Injector' : '');
542+
}
543+
}
521544
const dehydratedView = findMatchingDehydratedView(lContainer, activeBlockTNode.tView!.ssrId);
522545
const embeddedLView =
523-
createAndRenderEmbeddedLView(hostLView, activeBlockTNode, null, {dehydratedView});
546+
createAndRenderEmbeddedLView(hostLView, activeBlockTNode, null, {dehydratedView, injector});
524547
addLViewToLContainer(
525548
lContainer, embeddedLView, viewIndex, shouldAddViewToDom(activeBlockTNode, dehydratedView));
526549
markViewDirty(embeddedLView);
@@ -725,6 +748,12 @@ export function triggerResourceLoading(tDetails: TDeferBlockDetails, lView: LVie
725748
if (directiveDefs.length > 0) {
726749
primaryBlockTView.directiveRegistry =
727750
addDepsToRegistry<DirectiveDefList>(primaryBlockTView.directiveRegistry, directiveDefs);
751+
752+
// Extract providers from all NgModules imported by standalone components
753+
// used within this defer block.
754+
const directiveTypes = directiveDefs.map(def => def.type);
755+
const providers = internalImportProvidersFrom(false, ...directiveTypes);
756+
tDetails.providers = providers;
728757
}
729758
if (pipeDefs.length > 0) {
730759
primaryBlockTView.pipeRegistry =

packages/core/src/defer/interfaces.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import type {Provider} from '../di/interface/provider';
910
import type {DependencyType} from '../render3/interfaces/definition';
1011

1112
/**
@@ -109,6 +110,12 @@ export interface TDeferBlockDetails {
109110
* which all await the same set of dependencies.
110111
*/
111112
loadingPromise: Promise<unknown>|null;
113+
114+
/**
115+
* List of providers collected from all NgModules that were imported by
116+
* standalone components used within this defer block.
117+
*/
118+
providers: Provider[]|null;
112119
}
113120

114121
/**

packages/core/test/acceptance/defer_spec.ts

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID} from '@angular/common';
10-
import {ApplicationRef, Attribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, createComponent, DebugElement, Directive, EnvironmentInjector, ErrorHandler, getDebugNode, inject, Input, NgZone, Pipe, PipeTransform, PLATFORM_ID, QueryList, Type, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR} from '@angular/core';
10+
import {ApplicationRef, Attribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, createComponent, DebugElement, Directive, EnvironmentInjector, ErrorHandler, getDebugNode, inject, Injectable, InjectionToken, Input, NgModule, NgZone, Pipe, PipeTransform, PLATFORM_ID, QueryList, Type, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR} from '@angular/core';
1111
import {getComponentDef} from '@angular/core/src/render3/definition';
1212
import {ComponentFixture, DeferBlockBehavior, fakeAsync, flush, TestBed, tick} from '@angular/core/testing';
1313

@@ -3979,4 +3979,176 @@ describe('@defer', () => {
39793979
.toHaveBeenCalledWith('focusin', jasmine.any(Function), jasmine.any(Object));
39803980
});
39813981
});
3982+
3983+
describe('DI', () => {
3984+
it('should provide access to tokens from a parent component', async () => {
3985+
const TokenA = new InjectionToken('A');
3986+
const TokenB = new InjectionToken('B');
3987+
3988+
@Component({
3989+
standalone: true,
3990+
selector: 'parent-cmp',
3991+
template: '<ng-content />',
3992+
providers: [{provide: TokenA, useValue: 'TokenA.ParentCmp'}],
3993+
})
3994+
class ParentCmp {
3995+
}
3996+
3997+
@Component({
3998+
standalone: true,
3999+
selector: 'child-cmp',
4000+
template: 'Token A: {{ parentTokenA }} | Token B: {{ parentTokenB }}',
4001+
})
4002+
class ChildCmp {
4003+
parentTokenA = inject(TokenA);
4004+
parentTokenB = inject(TokenB);
4005+
}
4006+
4007+
@Component({
4008+
standalone: true,
4009+
selector: 'app-root',
4010+
template: `
4011+
<parent-cmp>
4012+
@defer (when isVisible) {
4013+
<child-cmp />
4014+
}
4015+
</parent-cmp>
4016+
`,
4017+
imports: [ChildCmp, ParentCmp],
4018+
providers: [{provide: TokenB, useValue: 'TokenB.RootCmp'}]
4019+
})
4020+
class RootCmp {
4021+
isVisible = true;
4022+
}
4023+
4024+
const deferDepsInterceptor = {
4025+
intercept() {
4026+
return () => {
4027+
return [dynamicImportOf(ChildCmp)];
4028+
};
4029+
}
4030+
};
4031+
4032+
TestBed.configureTestingModule({
4033+
providers: [
4034+
{provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor},
4035+
],
4036+
deferBlockBehavior: DeferBlockBehavior.Playthrough,
4037+
});
4038+
4039+
const fixture = TestBed.createComponent(RootCmp);
4040+
fixture.detectChanges();
4041+
4042+
await allPendingDynamicImports();
4043+
fixture.detectChanges();
4044+
4045+
// Verify that tokens from parent components are available for injection
4046+
// inside a component within a `@defer` block.
4047+
const tokenA = 'TokenA.ParentCmp';
4048+
const tokenB = 'TokenB.RootCmp';
4049+
4050+
expect(fixture.nativeElement.innerHTML)
4051+
.toContain(`<child-cmp>Token A: ${tokenA} | Token B: ${tokenB}</child-cmp>`);
4052+
});
4053+
});
4054+
4055+
describe('NgModules', () => {
4056+
it('should provide access to tokens from imported NgModules', async () => {
4057+
let serviceInitCount = 0;
4058+
4059+
const TokenA = new InjectionToken('');
4060+
4061+
@Injectable()
4062+
class Service {
4063+
id = 'ChartsModule.Service';
4064+
constructor() {
4065+
serviceInitCount++;
4066+
}
4067+
}
4068+
4069+
@Component({
4070+
selector: 'chart',
4071+
template: 'Service:{{ svc.id }}|TokenA:{{ tokenA }}',
4072+
})
4073+
class Chart {
4074+
svc = inject(Service);
4075+
tokenA = inject(TokenA);
4076+
}
4077+
4078+
@NgModule({
4079+
providers: [Service],
4080+
declarations: [Chart],
4081+
exports: [Chart],
4082+
})
4083+
class ChartsModule {
4084+
}
4085+
4086+
@Component({
4087+
selector: 'chart-collection',
4088+
template: '<chart />',
4089+
standalone: true,
4090+
imports: [ChartsModule],
4091+
})
4092+
class ChartCollectionComponent {
4093+
}
4094+
4095+
@Component({
4096+
selector: 'app-root',
4097+
standalone: true,
4098+
template: `
4099+
@for(item of items; track $index) {
4100+
@defer (when isVisible) {
4101+
<chart-collection />
4102+
}
4103+
}
4104+
`,
4105+
imports: [ChartCollectionComponent],
4106+
providers: [{provide: TokenA, useValue: 'MyCmp.A'}]
4107+
})
4108+
class MyCmp {
4109+
items = [1, 2, 3];
4110+
isVisible = true;
4111+
}
4112+
4113+
const deferDepsInterceptor = {
4114+
intercept() {
4115+
return () => {
4116+
return [dynamicImportOf(ChartCollectionComponent)];
4117+
};
4118+
}
4119+
};
4120+
4121+
TestBed.configureTestingModule({
4122+
providers: [
4123+
{provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor},
4124+
],
4125+
deferBlockBehavior: DeferBlockBehavior.Playthrough,
4126+
});
4127+
4128+
clearDirectiveDefs(MyCmp);
4129+
4130+
const fixture = TestBed.createComponent(MyCmp);
4131+
fixture.detectChanges();
4132+
4133+
await allPendingDynamicImports();
4134+
fixture.detectChanges();
4135+
4136+
// Verify that the `Service` injectable was initialized only once,
4137+
// even though it was injected in 3 instances of the `<chart>` component,
4138+
// used within defer blocks.
4139+
expect(serviceInitCount).toBe(1);
4140+
expect(fixture.nativeElement.querySelectorAll('chart').length).toBe(3);
4141+
4142+
// Verify that a service defined within an NgModule can inject services
4143+
// provided within the same NgModule.
4144+
const serviceFromNgModule = 'Service:ChartsModule.Service';
4145+
4146+
// Make sure sure that a nested `<chart>` component from the defer block
4147+
// can inject tokens provided in parent component (that contains `@defer`
4148+
// in its template).
4149+
const tokenFromRootComponent = 'TokenA:MyCmp.A';
4150+
expect(fixture.nativeElement.innerHTML)
4151+
.toContain(`<chart>${serviceFromNgModule}|${tokenFromRootComponent}</chart>`);
4152+
});
4153+
});
39824154
});

packages/core/test/bundling/defer/bundle.golden_symbols.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@
6868
{
6969
"name": "CSP_NONCE"
7070
},
71+
{
72+
"name": "CachedInjectorService"
73+
},
7174
{
7275
"name": "ChainedInjector"
7376
},
@@ -680,6 +683,9 @@
680683
{
681684
"name": "createElementRef"
682685
},
686+
{
687+
"name": "createEnvironmentInjector"
688+
},
683689
{
684690
"name": "createErrorClass"
685691
},
@@ -1073,6 +1079,9 @@
10731079
{
10741080
"name": "init_bypass"
10751081
},
1082+
{
1083+
"name": "init_cached_injector_service"
1084+
},
10761085
{
10771086
"name": "init_change_detection"
10781087
},

0 commit comments

Comments
 (0)