Skip to content

Commit

Permalink
fix(core): collect providers from NgModules while rendering @defer
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
AndrewKushnir authored and dylhunn committed Feb 23, 2024
1 parent 0d95ae5 commit dcb9deb
Show file tree
Hide file tree
Showing 5 changed files with 275 additions and 3 deletions.
55 changes: 55 additions & 0 deletions packages/core/src/cached_injector_service.ts
@@ -0,0 +1,55 @@
/**
* @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 {ɵɵdefineInjectable as defineInjectable} from './di/interface/defs';
import {Provider} from './di/interface/provider';
import {EnvironmentInjector} from './di/r3_injector';
import {OnDestroy} from './interface/lifecycle_hooks';
import {createEnvironmentInjector} from './render3/ng_module_ref';

/**
* A service used by the framework to create and cache injector instances.
*
* This service is used to create a single injector instance for each defer
* block definition, to avoid creating an injector for each defer block instance
* of a certain type.
*/
export class CachedInjectorService implements OnDestroy {
private cachedInjectors = new Map<unknown, EnvironmentInjector|null>();

getOrCreateInjector(
key: unknown, parentInjector: EnvironmentInjector, providers: Provider[],
debugName?: string) {
if (!this.cachedInjectors.has(key)) {
const injector = providers.length > 0 ?
createEnvironmentInjector(providers, parentInjector, debugName) :
null;
this.cachedInjectors.set(key, injector);
}
return this.cachedInjectors.get(key)!;
}

ngOnDestroy() {
try {
for (const injector of this.cachedInjectors.values()) {
if (injector !== null) {
injector.destroy();
}
}
} finally {
this.cachedInjectors.clear();
}
}

/** @nocollapse */
static ɵprov = /** @pureOrBreakMyCode */ defineInjectable({
token: CachedInjectorService,
providedIn: 'environment',
factory: () => new CachedInjectorService(),
});
}
33 changes: 31 additions & 2 deletions packages/core/src/defer/instructions.ts
Expand Up @@ -8,7 +8,9 @@

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

import {InjectionToken, Injector} from '../di';
import {CachedInjectorService} from '../cached_injector_service';
import {EnvironmentInjector, InjectionToken, Injector} from '../di';
import {internalImportProvidersFrom} from '../di/provider_collection';
import {RuntimeError, RuntimeErrorCode} from '../errors';
import {findMatchingDehydratedView} from '../hydration/views';
import {populateDehydratedViewsInLContainer} from '../linker/view_container_ref';
Expand Down Expand Up @@ -145,6 +147,7 @@ export function ɵɵdefer(
dependencyResolverFn: dependencyResolverFn ?? null,
loadingState: DeferDependenciesLoadingState.NOT_STARTED,
loadingPromise: null,
providers: null,
};
enableTimerScheduling?.(tView, tDetails, placeholderConfigIndex, loadingConfigIndex);
setTDeferBlockDetails(tView, adjustedIndex, tDetails);
Expand Down Expand Up @@ -518,9 +521,29 @@ function applyDeferBlockState(
const viewIndex = 0;

removeLViewFromLContainer(lContainer, viewIndex);

let injector: Injector|undefined;
if (newState === DeferBlockState.Complete) {
// When we render a defer block in completed state, there might be
// newly loaded standalone components used within the block, which may
// import NgModules with providers. In order to make those providers
// available for components declared in that NgModule, we create an instance
// of environment injector to host those providers and pass this injector
// to the logic that creates a view.
const tDetails = getTDeferBlockDetails(hostTView, tNode);
const providers = tDetails.providers;
if (providers && providers.length > 0) {
const parentInjector = hostLView[INJECTOR] as Injector;
const parentEnvInjector = parentInjector.get(EnvironmentInjector);
injector =
parentEnvInjector.get(CachedInjectorService)
.getOrCreateInjector(
tDetails, parentEnvInjector, providers, ngDevMode ? 'DeferBlock Injector' : '');
}
}
const dehydratedView = findMatchingDehydratedView(lContainer, activeBlockTNode.tView!.ssrId);
const embeddedLView =
createAndRenderEmbeddedLView(hostLView, activeBlockTNode, null, {dehydratedView});
createAndRenderEmbeddedLView(hostLView, activeBlockTNode, null, {dehydratedView, injector});
addLViewToLContainer(
lContainer, embeddedLView, viewIndex, shouldAddViewToDom(activeBlockTNode, dehydratedView));
markViewDirty(embeddedLView);
Expand Down Expand Up @@ -725,6 +748,12 @@ export function triggerResourceLoading(tDetails: TDeferBlockDetails, lView: LVie
if (directiveDefs.length > 0) {
primaryBlockTView.directiveRegistry =
addDepsToRegistry<DirectiveDefList>(primaryBlockTView.directiveRegistry, directiveDefs);

// Extract providers from all NgModules imported by standalone components
// used within this defer block.
const directiveTypes = directiveDefs.map(def => def.type);
const providers = internalImportProvidersFrom(false, ...directiveTypes);
tDetails.providers = providers;
}
if (pipeDefs.length > 0) {
primaryBlockTView.pipeRegistry =
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/defer/interfaces.ts
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

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

/**
Expand Down Expand Up @@ -109,6 +110,12 @@ export interface TDeferBlockDetails {
* which all await the same set of dependencies.
*/
loadingPromise: Promise<unknown>|null;

/**
* List of providers collected from all NgModules that were imported by
* standalone components used within this defer block.
*/
providers: Provider[]|null;
}

/**
Expand Down
174 changes: 173 additions & 1 deletion packages/core/test/acceptance/defer_spec.ts
Expand Up @@ -7,7 +7,7 @@
*/

import {ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID} from '@angular/common';
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';
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';
import {getComponentDef} from '@angular/core/src/render3/definition';
import {ComponentFixture, DeferBlockBehavior, fakeAsync, flush, TestBed, tick} from '@angular/core/testing';

Expand Down Expand Up @@ -3979,4 +3979,176 @@ describe('@defer', () => {
.toHaveBeenCalledWith('focusin', jasmine.any(Function), jasmine.any(Object));
});
});

describe('DI', () => {
it('should provide access to tokens from a parent component', async () => {
const TokenA = new InjectionToken('A');
const TokenB = new InjectionToken('B');

@Component({
standalone: true,
selector: 'parent-cmp',
template: '<ng-content />',
providers: [{provide: TokenA, useValue: 'TokenA.ParentCmp'}],
})
class ParentCmp {
}

@Component({
standalone: true,
selector: 'child-cmp',
template: 'Token A: {{ parentTokenA }} | Token B: {{ parentTokenB }}',
})
class ChildCmp {
parentTokenA = inject(TokenA);
parentTokenB = inject(TokenB);
}

@Component({
standalone: true,
selector: 'app-root',
template: `
<parent-cmp>
@defer (when isVisible) {
<child-cmp />
}
</parent-cmp>
`,
imports: [ChildCmp, ParentCmp],
providers: [{provide: TokenB, useValue: 'TokenB.RootCmp'}]
})
class RootCmp {
isVisible = true;
}

const deferDepsInterceptor = {
intercept() {
return () => {
return [dynamicImportOf(ChildCmp)];
};
}
};

TestBed.configureTestingModule({
providers: [
{provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor},
],
deferBlockBehavior: DeferBlockBehavior.Playthrough,
});

const fixture = TestBed.createComponent(RootCmp);
fixture.detectChanges();

await allPendingDynamicImports();
fixture.detectChanges();

// Verify that tokens from parent components are available for injection
// inside a component within a `@defer` block.
const tokenA = 'TokenA.ParentCmp';
const tokenB = 'TokenB.RootCmp';

expect(fixture.nativeElement.innerHTML)
.toContain(`<child-cmp>Token A: ${tokenA} | Token B: ${tokenB}</child-cmp>`);
});
});

describe('NgModules', () => {
it('should provide access to tokens from imported NgModules', async () => {
let serviceInitCount = 0;

const TokenA = new InjectionToken('');

@Injectable()
class Service {
id = 'ChartsModule.Service';
constructor() {
serviceInitCount++;
}
}

@Component({
selector: 'chart',
template: 'Service:{{ svc.id }}|TokenA:{{ tokenA }}',
})
class Chart {
svc = inject(Service);
tokenA = inject(TokenA);
}

@NgModule({
providers: [Service],
declarations: [Chart],
exports: [Chart],
})
class ChartsModule {
}

@Component({
selector: 'chart-collection',
template: '<chart />',
standalone: true,
imports: [ChartsModule],
})
class ChartCollectionComponent {
}

@Component({
selector: 'app-root',
standalone: true,
template: `
@for(item of items; track $index) {
@defer (when isVisible) {
<chart-collection />
}
}
`,
imports: [ChartCollectionComponent],
providers: [{provide: TokenA, useValue: 'MyCmp.A'}]
})
class MyCmp {
items = [1, 2, 3];
isVisible = true;
}

const deferDepsInterceptor = {
intercept() {
return () => {
return [dynamicImportOf(ChartCollectionComponent)];
};
}
};

TestBed.configureTestingModule({
providers: [
{provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor},
],
deferBlockBehavior: DeferBlockBehavior.Playthrough,
});

clearDirectiveDefs(MyCmp);

const fixture = TestBed.createComponent(MyCmp);
fixture.detectChanges();

await allPendingDynamicImports();
fixture.detectChanges();

// Verify that the `Service` injectable was initialized only once,
// even though it was injected in 3 instances of the `<chart>` component,
// used within defer blocks.
expect(serviceInitCount).toBe(1);
expect(fixture.nativeElement.querySelectorAll('chart').length).toBe(3);

// Verify that a service defined within an NgModule can inject services
// provided within the same NgModule.
const serviceFromNgModule = 'Service:ChartsModule.Service';

// Make sure sure that a nested `<chart>` component from the defer block
// can inject tokens provided in parent component (that contains `@defer`
// in its template).
const tokenFromRootComponent = 'TokenA:MyCmp.A';
expect(fixture.nativeElement.innerHTML)
.toContain(`<chart>${serviceFromNgModule}|${tokenFromRootComponent}</chart>`);
});
});
});
9 changes: 9 additions & 0 deletions packages/core/test/bundling/defer/bundle.golden_symbols.json
Expand Up @@ -68,6 +68,9 @@
{
"name": "CSP_NONCE"
},
{
"name": "CachedInjectorService"
},
{
"name": "ChainedInjector"
},
Expand Down Expand Up @@ -680,6 +683,9 @@
{
"name": "createElementRef"
},
{
"name": "createEnvironmentInjector"
},
{
"name": "createErrorClass"
},
Expand Down Expand Up @@ -1073,6 +1079,9 @@
{
"name": "init_bypass"
},
{
"name": "init_cached_injector_service"
},
{
"name": "init_change_detection"
},
Expand Down

0 comments on commit dcb9deb

Please sign in to comment.