From 53f3d227938fb31430cf65e6f8f97bc362d32fa5 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Thu, 27 Jul 2023 18:58:59 -0700 Subject: [PATCH] refactor(core): update TestBed to handle async component metadata 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. --- packages/core/src/render3/jit/directive.ts | 7 + packages/core/src/render3/metadata.ts | 27 +++- packages/core/test/test_bed_spec.ts | 128 ++++++++++++++++++ packages/core/testing/src/test_bed.ts | 7 + .../core/testing/src/test_bed_compiler.ts | 44 +++++- 5 files changed, 203 insertions(+), 10 deletions(-) diff --git a/packages/core/src/render3/jit/directive.ts b/packages/core/src/render3/jit/directive.ts index 4d63659d9ea6f0..3c1e741f24fef0 100644 --- a/packages/core/src/render3/jit/directive.ts +++ b/packages/core/src/render3/jit/directive.ts @@ -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'; @@ -59,6 +60,12 @@ export function compileComponent(type: Type, metadata: Component): void { let ngComponentDef: ComponentDef|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); diff --git a/packages/core/src/render3/metadata.ts b/packages/core/src/render3/metadata.ts index d66a7a02ac8c68..bf72e67bcf6fd2 100644 --- a/packages/core/src/render3/metadata.ts +++ b/packages/core/src/render3/metadata.ts @@ -22,6 +22,14 @@ interface TypeWithMetadata extends Type { */ 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): Promise>>|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). @@ -32,14 +40,19 @@ const ASYNC_COMPONENT_METADATA = '__ngAsyncComponentMetadata__'; */ export function setClassMetadataAsync( type: Type, dependencyLoaderFn: () => Array>>, - metadataSetterFn: (...types: Type[]) => void): void { + metadataSetterFn: (...types: Type[]) => void): Promise>> { 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]; } /** diff --git a/packages/core/test/test_bed_spec.ts b/packages/core/test/test_bed_spec.ts index de832b4f5de6bb..1152fb682e33a5 100644 --- a/packages/core/test/test_bed_spec.ts +++ b/packages/core/test/test_bed_spec.ts @@ -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('name'); @@ -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} + + {/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} + + {/defer} + `, + }] + }], + null, null); + }); + return ComponentClass; + }; + + const AotComponent = getAOTCompiledComponent(true); + + TestBed.configureTestingModule({imports: [AotComponent]}); + TestBed.overrideComponent( + AotComponent, {set: {template: `Override of a template! `}}); + 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', () => { /** diff --git a/packages/core/testing/src/test_bed.ts b/packages/core/testing/src/test_bed.ts index 9d6d69d6e14ed6..9b73120ac53c11 100644 --- a/packages/core/testing/src/test_bed.ts +++ b/packages/core/testing/src/test_bed.ts @@ -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 */ @@ -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) { diff --git a/packages/core/testing/src/test_bed_compiler.ts b/packages/core/testing/src/test_bed_compiler.ts index 4a22195d51096c..d14c878c2536a3 100644 --- a/packages/core/testing/src/test_bed_compiler.ts +++ b/packages/core/testing/src/test_bed_compiler.ts @@ -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'; @@ -30,9 +31,11 @@ function isTestingModuleOverride(value: unknown): value is TestingModuleOverride function assertNoStandaloneComponents( types: Type[], resolver: Resolver, 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)); + } } }); } @@ -105,6 +108,11 @@ export class TestBedCompiler { private testModuleType: NgModuleType; private testModuleRef: NgModuleRef|null = null; + // List of Promises that represent async process to set Component metadata, + // in case those components use `{#defer}` blocks inside. + // private componentsWithAsyncMetadata: Array>>> = []; + private componentsWithAsyncMetadata = new Set>(); + constructor(private platform: PlatformRef, private additionalModuleTypes: Type|Type[]) { class DynamicTestModule {} this.testModuleType = DynamicTestModule as any; @@ -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 { 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(); @@ -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)) {