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

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 angular#52876.
  • Loading branch information
AndrewKushnir committed Feb 2, 2024
1 parent 74aa8a3 commit a36117e
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 6 deletions.
55 changes: 55 additions & 0 deletions packages/core/src/cached_injector_service.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
}
43 changes: 38 additions & 5 deletions packages/core/src/defer/instructions.ts
Original file line number Diff line number Diff line change
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 All @@ -24,6 +26,7 @@ import {DirectiveDefList, PipeDefList} from '../render3/interfaces/definition';
import {TContainerNode, TNode} from '../render3/interfaces/node';
import {isDestroyed} from '../render3/interfaces/type_checks';
import {HEADER_OFFSET, INJECTOR, LView, PARENT, TVIEW, TView} from '../render3/interfaces/view';
import {createEnvironmentInjector} from '../render3/ng_module_ref';
import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from '../render3/state';
import {isPlatformBrowser} from '../render3/util/misc_utils';
import {getConstant, getTNode, removeLViewOnDestroy, storeLViewOnDestroy} from '../render3/util/view_utils';
Expand Down Expand Up @@ -144,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 @@ -509,17 +513,40 @@ function applyDeferBlockState(
lDetails[DEFER_BLOCK_STATE] = newState;
const hostTView = hostLView[TVIEW];
const adjustedIndex = stateTmplIndex + HEADER_OFFSET;
const tNode = getTNode(hostTView, adjustedIndex) as TContainerNode;

// TNode that represents a block which should be rendered.
const blockTNode = getTNode(hostTView, adjustedIndex) as TContainerNode;

// There is only 1 view that can be present in an LContainer that
// represents a defer block, so always refer to the first one.
const viewIndex = 0;

removeLViewFromLContainer(lContainer, viewIndex);
const dehydratedView = findMatchingDehydratedView(lContainer, tNode.tView!.ssrId);
const embeddedLView = createAndRenderEmbeddedLView(hostLView, tNode, null, {dehydratedView});

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 (Array.isArray(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, blockTNode.tView!.ssrId);
const embeddedLView =
createAndRenderEmbeddedLView(hostLView, blockTNode, null, {dehydratedView, injector});
addLViewToLContainer(
lContainer, embeddedLView, viewIndex, shouldAddViewToDom(tNode, dehydratedView));
lContainer, embeddedLView, viewIndex, shouldAddViewToDom(blockTNode, dehydratedView));
markViewDirty(embeddedLView);
}
}
Expand Down Expand Up @@ -715,6 +742,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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {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
102 changes: 101 additions & 1 deletion packages/core/test/acceptance/defer_spec.ts
Original file line number Diff line number Diff line change
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,104 @@ describe('@defer', () => {
.toHaveBeenCalledWith('focusin', jasmine.any(Function), jasmine.any(Object));
});
});

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>`);
});
});
});

0 comments on commit a36117e

Please sign in to comment.