Skip to content

Commit

Permalink
fix(core): apply TestBed provider overrides to @defer dependencies (#…
Browse files Browse the repository at this point in the history
…54667)

This commit updates TestBed logic to take into account situations when dependencies loaded within `@defer` blocks may import NgModules with providers. For such components, we invoke provider override function, which recursively inspects and applies the necessary changes.

PR Close #54667
  • Loading branch information
AndrewKushnir authored and pkozlowski-opensource committed Mar 5, 2024
1 parent b558a01 commit 586cc24
Show file tree
Hide file tree
Showing 2 changed files with 111 additions and 8 deletions.
86 changes: 81 additions & 5 deletions packages/core/test/test_bed_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/

import {APP_INITIALIZER, ChangeDetectorRef, Compiler, Component, Directive, ElementRef, ErrorHandler, getNgModuleById, inject, Inject, Injectable, InjectFlags, InjectionToken, InjectOptions, Injector, Input, LOCALE_ID, ModuleWithProviders, NgModule, Optional, Pipe, Type, ViewChild, ɵsetClassMetadata as setClassMetadata, ɵɵdefineComponent as defineComponent, ɵɵdefineInjector as defineInjector, ɵɵdefineNgModule as defineNgModule, ɵɵelementEnd as elementEnd, ɵɵelementStart as elementStart, ɵɵsetNgModuleScope as setNgModuleScope, ɵɵtext as text} from '@angular/core';
import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id';
import {APP_INITIALIZER, ChangeDetectorRef, Compiler, Component, Directive, ElementRef, ErrorHandler, getNgModuleById, inject, Inject, Injectable, InjectFlags, InjectionToken, InjectOptions, Injector, Input, LOCALE_ID, ModuleWithProviders, NgModule, Optional, Pipe, PLATFORM_ID, Type, ViewChild, ɵsetClassMetadata as setClassMetadata, ɵɵdefineComponent as defineComponent, ɵɵdefineInjector as defineInjector, ɵɵdefineNgModule as defineNgModule, ɵɵelementEnd as elementEnd, ɵɵelementStart as elementStart, ɵɵsetNgModuleScope as setNgModuleScope, ɵɵtext as text} from '@angular/core';
import {DeferBlockBehavior} from '@angular/core/testing';
import {TestBed, TestBedImpl} from '@angular/core/testing/src/test_bed';
import {By} from '@angular/platform-browser';
Expand Down Expand Up @@ -1655,15 +1656,90 @@ describe('TestBed', () => {
.toBe('Override of a root template! Override of a nested template! CmpA!');
});

it('should allow import overrides on components with async metadata', async () => {
it('should override providers on dependencies of dynamically loaded components', async () => {
function timer(delay: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), delay);
});
}

@Injectable({providedIn: 'root'})
class ImportantService {
value = 'original';
}

@NgModule({
providers: [ImportantService],
})
class ThisModuleProvidesService {
}

@Component({
standalone: true,
selector: 'cmp-a',
template: 'CmpA!',
selector: 'child',
imports: [ThisModuleProvidesService],
template: '<h1>{{value}}</h1>',
})
class CmpA {
class ChildCmp {
service = inject(ImportantService);
value = this.service.value;
}

@Component({
standalone: true,
selector: 'parent',
imports: [ChildCmp],
template: `
@defer (when true) {
<child />
}
`,
})
class ParentCmp {
}

const deferrableDependencies = [ChildCmp];
setClassMetadataAsync(
ParentCmp,
function() {
const promises: Array<Promise<Type<unknown>>> = deferrableDependencies.map(
// Emulates a dynamic import, e.g. `import('./cmp-a').then(m => m.CmpA)`
dep => new Promise((resolve) => setTimeout(() => resolve(dep))));
return promises;
},
function(...deferrableSymbols) {
setClassMetadata(
ParentCmp, [{
type: Component,
args: [{
selector: 'parent',
standalone: true,
imports: [...deferrableSymbols],
template: `<div>root cmp!</div>`,
}]
}],
null, null);
});

// Set `PLATFORM_ID` to a browser platform value to trigger defer loading
// while running tests in Node.
const COMMON_PROVIDERS = [{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}];

TestBed.configureTestingModule({imports: [ParentCmp], providers: [COMMON_PROVIDERS]});
TestBed.overrideProvider(ImportantService, {useValue: {value: 'overridden'}});

await TestBed.compileComponents();

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

await timer(10);
fixture.detectChanges();

expect(fixture.nativeElement.textContent).toContain('overridden');
});

it('should allow import overrides on components with async metadata', async () => {
const NestedAotComponent = getAOTCompiledComponent('nested-cmp', [], []);
const RootAotComponent = getAOTCompiledComponent('root', [], []);

Expand Down
33 changes: 30 additions & 3 deletions packages/core/testing/src/test_bed_compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export class TestBedCompiler {
private pendingDirectives = new Set<Type<any>>();
private pendingPipes = new Set<Type<any>>();

// Set of components with async metadata, i.e. components with `@defer` blocks
// in their templates.
private componentsWithAsyncMetadata = new Set<Type<unknown>>();

// Keep track of all components and directives, so we can patch Providers onto defs later.
private seenComponents = new Set<Type<any>>();
private seenDirectives = new Set<Type<any>>();
Expand Down Expand Up @@ -177,6 +181,10 @@ export class TestBedCompiler {
this.verifyNoStandaloneFlagOverrides(component, override);
this.resolvers.component.addOverride(component, override);
this.pendingComponents.add(component);

// If this is a component with async metadata (i.e. a component with a `@defer` block
// in a template) - store it for future processing.
this.maybeRegisterComponentWithAsyncMetadata(component);
}

overrideDirective(directive: Type<any>, override: MetadataOverride<Directive>): void {
Expand Down Expand Up @@ -265,18 +273,26 @@ export class TestBedCompiler {
}

private async resolvePendingComponentsWithAsyncMetadata() {
if (this.pendingComponents.size === 0) return;
if (this.componentsWithAsyncMetadata.size === 0) return;

const promises = [];
for (const component of this.pendingComponents) {
for (const component of this.componentsWithAsyncMetadata) {
const asyncMetadataFn = getAsyncClassMetadataFn(component);
if (asyncMetadataFn) {
promises.push(asyncMetadataFn());
}
}
this.componentsWithAsyncMetadata.clear();

const resolvedDeps = await Promise.all(promises);
this.queueTypesFromModulesArray(resolvedDeps.flat(2));
const flatResolvedDeps = resolvedDeps.flat(2);
this.queueTypesFromModulesArray(flatResolvedDeps);

// Loaded standalone components might contain imports of NgModules
// with providers, make sure we override providers there too.
for (const component of flatResolvedDeps) {
this.applyProviderOverridesInScope(component);
}
}

async compileComponents(): Promise<void> {
Expand Down Expand Up @@ -590,7 +606,18 @@ export class TestBedCompiler {
compileNgModuleDefs(ngModule as NgModuleType<any>, metadata);
}

private maybeRegisterComponentWithAsyncMetadata(type: Type<unknown>) {
const asyncMetadataFn = getAsyncClassMetadataFn(type);
if (asyncMetadataFn) {
this.componentsWithAsyncMetadata.add(type);
}
}

private queueType(type: Type<any>, moduleType: Type<any>|TestingModuleOverride|null): void {
// If this is a component with async metadata (i.e. a component with a `@defer` block
// in a template) - store it for future processing.
this.maybeRegisterComponentWithAsyncMetadata(type);

const component = this.resolvers.component.resolve(type);
if (component) {
// Check whether a give Type has respective NG def (ɵcmp) and compile if def is
Expand Down

0 comments on commit 586cc24

Please sign in to comment.