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 53f3d22
Show file tree
Hide file tree
Showing 5 changed files with 203 additions and 10 deletions.
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 your test.`);
}

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

Expand Down
27 changes: 20 additions & 7 deletions packages/core/src/render3/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ 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 {
return (type as any)[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 +40,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
128 changes: 128 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,133 @@ describe('TestBed', () => {
});
});

describe('defer blocks', () => {
it('should handle async metadata correctly', async () => {
@Component({
standalone: true,
selector: 'cmp-a',
template: 'CmpA!',
})
class CmpA {
}

/**
* Function returns a class that represents AOT-compiled version of the following Component:
*
* @Component({
* standalone: true|false,
* imports: [...], // for standalone only
* selector: 'comp',
* 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 = (standalone: boolean = false, dependencies: any[] = []) => {
class NestedComponentClass {
static ɵfac = () => new NestedComponentClass();
static ɵcmp = defineComponent({
standalone,
type: NestedComponentClass,
selectors: [['nested-cmp']],
decls: 2,
vars: 0,
dependencies,
consts: [['dir']],
template:
(rf: any, ctx: any) => {
if (rf & 1) {
elementStart(0, 'div', 0);
text(1, 'Nested cmp!');
elementEnd();
}
}
});
}
setClassMetadataAsync(
NestedComponentClass,
function() {
// Emulates a dynamic import: `import('./cmp-a').then(m => m.CmpA)`;
return [new Promise((resolve) => setTimeout(() => resolve(CmpA)))];
},
function(CmpA) {
setClassMetadata(
NestedComponentClass, [{
type: Component,
args: [{
selector: 'nested-cmp',
standalone: true,
imports: [CmpA],
template: `
{#defer}
<cmp-a />
{/defer}
`,
}]
}],
null, null);
});

class ComponentClass {
static ɵfac = () => new ComponentClass();
static ɵcmp = defineComponent({
standalone,
type: ComponentClass,
selectors: [['comp']],
decls: 2,
vars: 0,
dependencies,
consts: [['dir']],
template:
(rf: any, ctx: any) => {
if (rf & 1) {
elementStart(0, 'div', 0);
text(1, 'Some template');
elementEnd();
}
}
});
}
setClassMetadataAsync(
ComponentClass,
function() {
// Emulates a dynamic import: `import('./cmp-a').then(m => m.CmpA)`;
return [new Promise((resolve) => setTimeout(() => resolve(NestedComponentClass)))];
},
function(NestedComponentClass) {
setClassMetadata(
ComponentClass, [{
type: Component,
args: [{
selector: 'comp',
standalone: true,
imports: [NestedComponentClass],
template: `
{#defer}
<nested-cmp />
{/defer}
`,
}]
}],
null, null);
});
return ComponentClass;
};

const AotComponent = getAOTCompiledComponent(true);

TestBed.configureTestingModule({imports: [AotComponent]});
TestBed.overrideComponent(
AotComponent, {set: {template: `Override of a template! <nested-cmp />`}});
await TestBed.compileComponents();
const fixture = TestBed.createComponent(AotComponent);
fixture.detectChanges();

expect(fixture.nativeElement.textContent).toBe('Override of a template! Nested 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 your 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 53f3d22

Please sign in to comment.