Skip to content

Commit

Permalink
refactor(core): update TestBed to handle async component metadata
Browse files Browse the repository at this point in the history
This commit updates TestBed to wait for async component metadata resolution before compiling components.
Async metadata is added by the compiler in case a component uses defer blocks, which contain deferrable
symbols.
  • Loading branch information
AndrewKushnir committed Aug 1, 2023
1 parent 2a66e90 commit 78cfb1b
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1009,7 +1009,7 @@ export class ComponentDecoratorHandler implements

if (analysis.classMetadata) {
// Drop references to existing imports for deferrable symbols that should be present
// in the `setClassMetadataAsync` call. Otherwise, the import declaration gets retained.
// in the `setClassMetadataAsync` call. Otherwise, an import declaration gets retained.
const deferrableSymbols = new Set(deferrableTypes.keys());
const rewrittenDecoratorsNode = removeIdentifierReferences(
(analysis.classMetadata.decorators as WrappedNodeExpr<ts.Node>).node, deferrableSymbols);
Expand Down
4 changes: 2 additions & 2 deletions packages/compiler/src/render3/r3_class_metadata_compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,13 @@ export function compileClassMetadata(metadata: R3ClassMetadata): o.Expression {
* });
* ```
*
* Similarly to the `setClassMetadata` call, it's wrapped into the `ngDevMode`
* Similar to the `setClassMetadata` call, it's wrapped into the `ngDevMode`
* check to tree-shake away this code in production mode.
*/
export function compileComponentClassMetadata(
metadata: R3ClassMetadata, deferrableTypes: Map<string, string>): o.Expression {
if (!deferrableTypes || deferrableTypes.size === 0) {
// If there are no deferrable symbols - just generate the `setClassMetadata` call.
// If there are no deferrable symbols - just generate a regular `setClassMetadata` call.
return compileClassMetadata(metadata);
}

Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/render3/jit/directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {initNgDevMode} from '../../util/ng_dev_mode';
import {getComponentDef, getDirectiveDef, getNgModuleDef, getPipeDef} from '../definition';
import {NG_COMP_DEF, NG_DIR_DEF, NG_FACTORY_DEF} from '../fields';
import {ComponentDef, ComponentType, DirectiveDefList, PipeDefList} from '../interfaces/definition';
import {getAsyncClassMetadata} from '../metadata';
import {stringifyForError} from '../util/stringify_utils';

import {angularCoreEnv} from './environment';
Expand Down Expand Up @@ -59,6 +60,12 @@ export function compileComponent(type: Type<any>, metadata: Component): void {

let ngComponentDef: ComponentDef<unknown>|null = null;

if (getAsyncClassMetadata(type)) {
throw new Error(
`Component '${type.name}' has unresolved metadata. ` +
`Please call \`await TestBed.compileComponents()\` before running this test.`);
}

// Metadata may have resources which need to be resolved.
maybeQueueResolutionOfComponentResources(type, metadata);

Expand Down
28 changes: 21 additions & 7 deletions packages/core/src/render3/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ interface TypeWithMetadata extends Type<any> {
*/
const ASYNC_COMPONENT_METADATA = '__ngAsyncComponentMetadata__';

/**
* If a given component has unresolved async metadata - this function returns a reference to
* a Promise that represents dependency loading. Otherwise - this function returns `null`.
*/
export function getAsyncClassMetadata(type: Type<unknown>): Promise<Array<Type<unknown>>>|null {
const componentClass = type as any; // cast to `any`, so that we can monkey-patch it
return componentClass[ASYNC_COMPONENT_METADATA] ?? null;
}

/**
* Handles the process of applying metadata info to a component class in case
* component template had `{#defer}` blocks (thus some dependencies became deferrable).
Expand All @@ -32,14 +41,19 @@ const ASYNC_COMPONENT_METADATA = '__ngAsyncComponentMetadata__';
*/
export function setClassMetadataAsync(
type: Type<any>, dependencyLoaderFn: () => Array<Promise<Type<unknown>>>,
metadataSetterFn: (...types: Type<unknown>[]) => void): void {
metadataSetterFn: (...types: Type<unknown>[]) => void): Promise<Array<Type<unknown>>> {
const componentClass = type as any; // cast to `any`, so that we can monkey-patch it
componentClass[ASYNC_COMPONENT_METADATA] = Promise.all(dependencyLoaderFn()).then(deps => {
metadataSetterFn(...deps);
// Metadata is now set, reset field value to indicate that this component
// can by used/compiled synchronously.
componentClass[ASYNC_COMPONENT_METADATA] = null;
});
componentClass[ASYNC_COMPONENT_METADATA] =
Promise.all(dependencyLoaderFn()).then(dependencies => {
metadataSetterFn(...dependencies);
// Metadata is now set, reset field value to indicate that this component
// can by used/compiled synchronously.
componentClass[ASYNC_COMPONENT_METADATA] = null;

return dependencies;
});

return componentClass[ASYNC_COMPONENT_METADATA];
}

/**
Expand Down
122 changes: 122 additions & 0 deletions packages/core/test/test_bed_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {TestBed, TestBedImpl} from '@angular/core/testing/src/test_bed';
import {By} from '@angular/platform-browser';
import {expect} from '@angular/platform-browser/testing/src/matchers';

import {setClassMetadataAsync} from '../src/render3/metadata';
import {TEARDOWN_TESTING_MODULE_ON_DESTROY_DEFAULT, THROW_ON_UNKNOWN_ELEMENTS_DEFAULT, THROW_ON_UNKNOWN_PROPERTIES_DEFAULT} from '../testing/src/test_bed_common';

const NAME = new InjectionToken<string>('name');
Expand Down Expand Up @@ -1460,6 +1461,127 @@ describe('TestBed', () => {
});
});

describe('defer blocks', () => {
/**
* Function returns a class that represents AOT-compiled version of the following Component:
*
* @Component({
* standalone: true,
* imports: [...],
* selector: '...',
* template: '...',
* })
* class ComponentClass {}
*
* This is needed to closer match the behavior of AOT pre-compiled components (compiled
* outside of TestBed) for cases when `{#defer}` blocks are used.
*/
const getAOTCompiledComponent =
(selector: string, dependencies: Array<Type<unknown>> = [],
deferrableDependencies: Array<Type<unknown>> = []) => {
class ComponentClass {
static ɵfac = () => new ComponentClass();
static ɵcmp = defineComponent({
standalone: true,
type: ComponentClass,
selectors: [[selector]],
decls: 2,
vars: 0,
dependencies,
consts: [['dir']],
template:
(rf: any, ctx: any) => {
if (rf & 1) {
elementStart(0, 'div', 0);
text(1, `${selector} cmp!`);
elementEnd();
}
}
});
}
setClassMetadataAsync(
ComponentClass,
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(
ComponentClass, [{
type: Component,
args: [{
selector,
standalone: true,
imports: [...dependencies, ...deferrableSymbols],
template: `<div>root cmp!</div>`,
}]
}],
null, null);
});
return ComponentClass;
};

it('should handle async metadata on root and nested components', async () => {
@Component({
standalone: true,
selector: 'cmp-a',
template: 'CmpA!',
})
class CmpA {
}

const NestedAotComponent = getAOTCompiledComponent('nested-cmp', [], [CmpA]);
const RootAotComponent = getAOTCompiledComponent('root', [], [NestedAotComponent]);

TestBed.configureTestingModule({imports: [RootAotComponent]});

TestBed.overrideComponent(
RootAotComponent, {set: {template: `Override of a root template! <nested-cmp />`}});
TestBed.overrideComponent(
NestedAotComponent, {set: {template: `Override of a nested template! <cmp-a />`}});

await TestBed.compileComponents();

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

expect(fixture.nativeElement.textContent)
.toBe('Override of a root template! Override of a nested template! CmpA!');
});

it('should allow import overrides on components with async metadata', async () => {
@Component({
standalone: true,
selector: 'cmp-a',
template: 'CmpA!',
})
class CmpA {
}

const NestedAotComponent = getAOTCompiledComponent('nested-cmp', [], []);
const RootAotComponent = getAOTCompiledComponent('root', [], []);

TestBed.configureTestingModule({imports: [RootAotComponent]});

TestBed.overrideComponent(RootAotComponent, {
set: {
// Adding an import that was not present originally
imports: [NestedAotComponent],
template: `Override of a root template! <nested-cmp />`,
}
});

await TestBed.compileComponents();

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

expect(fixture.nativeElement.textContent)
.toBe('Override of a root template! nested-cmp cmp!');
});
});

describe('AOT pre-compiled components', () => {
/**
Expand Down
7 changes: 7 additions & 0 deletions packages/core/testing/src/test_bed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
ɵsetUnknownPropertyStrictMode as setUnknownPropertyStrictMode,
ɵstringify as stringify
} from '@angular/core';
import { getAsyncClassMetadata } from '@angular/core/src/render3/metadata';

/* clang-format on */

Expand Down Expand Up @@ -592,6 +593,12 @@ export class TestBedImpl implements TestBed {
const rootElId = `root${_nextRootElementId++}`;
testComponentRenderer.insertRootElement(rootElId);

if (getAsyncClassMetadata(type)) {
throw new Error(
`Component '${type.name}' has unresolved metadata. ` +
`Please call \`await TestBed.compileComponents()\` before running this test.`);
}

const componentDef = (type as any).ɵcmp;

if (!componentDef) {
Expand Down
44 changes: 41 additions & 3 deletions packages/core/testing/src/test_bed_compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import {ResourceLoader} from '@angular/compiler';
import {ApplicationInitStatus, Compiler, COMPILER_OPTIONS, Component, Directive, Injector, InjectorType, LOCALE_ID, ModuleWithComponentFactories, ModuleWithProviders, NgModule, NgModuleFactory, NgZone, Pipe, PlatformRef, Provider, provideZoneChangeDetection, resolveForwardRef, StaticProvider, Type, ɵcompileComponent as compileComponent, ɵcompileDirective as compileDirective, ɵcompileNgModuleDefs as compileNgModuleDefs, ɵcompilePipe as compilePipe, ɵDEFAULT_LOCALE_ID as DEFAULT_LOCALE_ID, ɵDirectiveDef as DirectiveDef, ɵgetInjectableDef as getInjectableDef, ɵInternalEnvironmentProviders as InternalEnvironmentProviders, ɵisEnvironmentProviders as isEnvironmentProviders, ɵNG_COMP_DEF as NG_COMP_DEF, ɵNG_DIR_DEF as NG_DIR_DEF, ɵNG_INJ_DEF as NG_INJ_DEF, ɵNG_MOD_DEF as NG_MOD_DEF, ɵNG_PIPE_DEF as NG_PIPE_DEF, ɵNgModuleFactory as R3NgModuleFactory, ɵNgModuleTransitiveScopes as NgModuleTransitiveScopes, ɵNgModuleType as NgModuleType, ɵpatchComponentDefWithScope as patchComponentDefWithScope, ɵRender3ComponentFactory as ComponentFactory, ɵRender3NgModuleRef as NgModuleRef, ɵsetLocaleId as setLocaleId, ɵtransitiveScopesFor as transitiveScopesFor, ɵɵInjectableDeclaration as InjectableDeclaration} from '@angular/core';
import {getAsyncClassMetadata} from '@angular/core/src/render3/metadata';

import {clearResolutionOfComponentResourcesQueue, isComponentDefPendingResolution, resolveComponentResources, restoreComponentResolutionQueue} from '../../src/metadata/resource_loading';
import {ComponentDef, ComponentType} from '../../src/render3';
Expand All @@ -30,9 +31,11 @@ function isTestingModuleOverride(value: unknown): value is TestingModuleOverride
function assertNoStandaloneComponents(
types: Type<any>[], resolver: Resolver<any>, location: string) {
types.forEach(type => {
const component = resolver.resolve(type);
if (component && component.standalone) {
throw new Error(generateStandaloneInDeclarationsError(type, location));
if (!getAsyncClassMetadata(type)) {
const component = resolver.resolve(type);
if (component && component.standalone) {
throw new Error(generateStandaloneInDeclarationsError(type, location));
}
}
});
}
Expand Down Expand Up @@ -105,6 +108,11 @@ export class TestBedCompiler {
private testModuleType: NgModuleType<any>;
private testModuleRef: NgModuleRef<any>|null = null;

// List of Promises that represent async process to set Component metadata,
// in case those components use `{#defer}` blocks inside.
// private componentsWithAsyncMetadata: Array<Promise<Array<Type<unknown>>>> = [];
private componentsWithAsyncMetadata = new Set<Type<unknown>>();

constructor(private platform: PlatformRef, private additionalModuleTypes: Type<any>|Type<any>[]) {
class DynamicTestModule {}
this.testModuleType = DynamicTestModule as any;
Expand Down Expand Up @@ -249,8 +257,36 @@ export class TestBedCompiler {
this.componentToModuleScope.set(type, TestingModuleOverride.OVERRIDE_TEMPLATE);
}

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

const promises = [];
for (const component of this.componentsWithAsyncMetadata) {
const asyncMetadataPromise = getAsyncClassMetadata(component);
if (asyncMetadataPromise) {
promises.push(asyncMetadataPromise);
}
}
this.componentsWithAsyncMetadata.clear();

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

// If we've got more types with async metadata queued,
// recursively process such components again.
if (this.componentsWithAsyncMetadata.size > 0) {
await this.resolveComponentsWithAsyncMetadata();
}
}

async compileComponents(): Promise<void> {
this.clearComponentResolutionQueue();

// Wait for all async metadata for all components.
if (this.componentsWithAsyncMetadata.size > 0) {
await this.resolveComponentsWithAsyncMetadata();
}

// Run compilers for all queued types.
let needsAsyncResources = this.compileTypesSync();

Expand Down Expand Up @@ -589,6 +625,8 @@ export class TestBedCompiler {
for (const value of arr) {
if (Array.isArray(value)) {
queueTypesFromModulesArrayRecur(value);
} else if (getAsyncClassMetadata(value)) {
this.componentsWithAsyncMetadata.add(value);
} else if (hasNgModuleDef(value)) {
const def = value.ɵmod;
if (processedDefs.has(def)) {
Expand Down

0 comments on commit 78cfb1b

Please sign in to comment.