diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts index 8ffd1238e7de99..c033fa8e76c39d 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -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).node, deferrableSymbols); diff --git a/packages/compiler/src/render3/r3_class_metadata_compiler.ts b/packages/compiler/src/render3/r3_class_metadata_compiler.ts index 36c8a2a6e41912..ac25865c6d5cda 100644 --- a/packages/compiler/src/render3/r3_class_metadata_compiler.ts +++ b/packages/compiler/src/render3/r3_class_metadata_compiler.ts @@ -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): 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); } diff --git a/packages/core/src/render3/jit/directive.ts b/packages/core/src/render3/jit/directive.ts index 4d63659d9ea6f0..747bf5200d310d 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 this 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..1cb3a7804e48cf 100644 --- a/packages/core/src/render3/metadata.ts +++ b/packages/core/src/render3/metadata.ts @@ -22,6 +22,15 @@ 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 { + 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). @@ -32,14 +41,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/render3/jit/directive_spec.ts b/packages/core/test/render3/jit/directive_spec.ts index 5b41d82ec0a478..6edeaa8f577ef5 100644 --- a/packages/core/test/render3/jit/directive_spec.ts +++ b/packages/core/test/render3/jit/directive_spec.ts @@ -7,9 +7,9 @@ */ import {Directive, HostListener, Input} from '@angular/core'; -import {setClassMetadata} from '@angular/core/src/render3/metadata'; import {convertToR3QueryMetadata, directiveMetadata, extendsDirectlyFromObject} from '../../../src/render3/jit/directive'; +import {setClassMetadata} from '../../../src/render3/metadata'; describe('jit directive helper functions', () => { describe('extendsDirectlyFromObject', () => { diff --git a/packages/core/test/test_bed_spec.ts b/packages/core/test/test_bed_spec.ts index de832b4f5de6bb..43dbd8b7d90f48 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,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> = [], + deferrableDependencies: Array> = []) => { + 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>> = 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: `
root cmp!
`, + }] + }], + 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! `}}); + TestBed.overrideComponent( + NestedAotComponent, {set: {template: `Override of a nested template! `}}); + + 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! `, + } + }); + + 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', () => { /** diff --git a/packages/core/testing/src/test_bed.ts b/packages/core/testing/src/test_bed.ts index 9d6d69d6e14ed6..62f9d197def827 100644 --- a/packages/core/testing/src/test_bed.ts +++ b/packages/core/testing/src/test_bed.ts @@ -38,6 +38,8 @@ import { ɵstringify as stringify } from '@angular/core'; +import { getAsyncClassMetadata } from '../../src/render3/metadata'; + /* clang-format on */ import {ComponentFixture} from './component_fixture'; @@ -592,6 +594,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) { diff --git a/packages/core/testing/src/test_bed_compiler.ts b/packages/core/testing/src/test_bed_compiler.ts index 4a22195d51096c..b38707cbc35a28 100644 --- a/packages/core/testing/src/test_bed_compiler.ts +++ b/packages/core/testing/src/test_bed_compiler.ts @@ -12,6 +12,7 @@ import {ApplicationInitStatus, Compiler, COMPILER_OPTIONS, Component, Directive, import {clearResolutionOfComponentResourcesQueue, isComponentDefPendingResolution, resolveComponentResources, restoreComponentResolutionQueue} from '../../src/metadata/resource_loading'; import {ComponentDef, ComponentType} from '../../src/render3'; import {generateStandaloneInDeclarationsError} from '../../src/render3/jit/module'; +import {getAsyncClassMetadata} from '../../src/render3/metadata'; import {MetadataOverride} from './metadata_override'; import {ComponentResolver, DirectiveResolver, NgModuleResolver, PipeResolver, Resolver} from './resolvers'; @@ -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)) {