From 57123524a2e1481987eaf239d2ae7f1216291864 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Mon, 13 Nov 2023 22:08:26 -0800 Subject: [PATCH] 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 --- packages/core/src/cached_injector_service.ts | 55 ++++++ packages/core/src/defer/instructions.ts | 33 +++- packages/core/src/defer/interfaces.ts | 7 + packages/core/test/acceptance/defer_spec.ts | 174 +++++++++++++++++- .../bundling/defer/bundle.golden_symbols.json | 9 + 5 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/cached_injector_service.ts diff --git a/packages/core/src/cached_injector_service.ts b/packages/core/src/cached_injector_service.ts new file mode 100644 index 0000000000000..a03fbf552be0c --- /dev/null +++ b/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(); + + 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(), + }); +} diff --git a/packages/core/src/defer/instructions.ts b/packages/core/src/defer/instructions.ts index 26e7bac4a42d5..67b5d0373e202 100644 --- a/packages/core/src/defer/instructions.ts +++ b/packages/core/src/defer/instructions.ts @@ -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'; @@ -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); @@ -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); @@ -725,6 +748,12 @@ export function triggerResourceLoading(tDetails: TDeferBlockDetails, lView: LVie if (directiveDefs.length > 0) { primaryBlockTView.directiveRegistry = addDepsToRegistry(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 = diff --git a/packages/core/src/defer/interfaces.ts b/packages/core/src/defer/interfaces.ts index 7062bed0d34ef..c47cbf74f7898 100644 --- a/packages/core/src/defer/interfaces.ts +++ b/packages/core/src/defer/interfaces.ts @@ -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'; /** @@ -109,6 +110,12 @@ export interface TDeferBlockDetails { * which all await the same set of dependencies. */ loadingPromise: Promise|null; + + /** + * List of providers collected from all NgModules that were imported by + * standalone components used within this defer block. + */ + providers: Provider[]|null; } /** diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index 1d3882c468acc..e023a40546ce9 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -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'; @@ -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: '', + 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: ` + + @defer (when isVisible) { + + } + + `, + 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(`Token A: ${tokenA} | Token B: ${tokenB}`); + }); + }); + + 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: '', + standalone: true, + imports: [ChartsModule], + }) + class ChartCollectionComponent { + } + + @Component({ + selector: 'app-root', + standalone: true, + template: ` + @for(item of items; track $index) { + @defer (when isVisible) { + + } + } + `, + 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 `` 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 `` 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(`${serviceFromNgModule}|${tokenFromRootComponent}`); + }); + }); }); diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 28e9137257d64..3a38dba5c1701 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -68,6 +68,9 @@ { "name": "CSP_NONCE" }, + { + "name": "CachedInjectorService" + }, { "name": "ChainedInjector" }, @@ -680,6 +683,9 @@ { "name": "createElementRef" }, + { + "name": "createEnvironmentInjector" + }, { "name": "createErrorClass" }, @@ -1073,6 +1079,9 @@ { "name": "init_bypass" }, + { + "name": "init_cached_injector_service" + }, { "name": "init_change_detection" },