diff --git a/packages/core/src/cached_injector_service.ts b/packages/core/src/cached_injector_service.ts new file mode 100644 index 00000000000..a03fbf552be --- /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 26e7bac4a42..67b5d0373e2 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 7062bed0d34..c47cbf74f78 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 1d3882c468a..e023a40546c 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 1a49a240c6b..13047bf05d8 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" },