From a9f563f043ea809bfcf815745b278b447ea66be4 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 19 Feb 2024 10:40:39 +0100 Subject: [PATCH] refactor(compiler-cli): move defer block tests into separate file (#54499) Splits the tests for `@defer` blocks out into a separate file since the `ngtsc_spec.ts` is getting quite large. PR Close #54499 --- .../compiler-cli/test/ngtsc/defer_spec.ts | 1278 +++++++++++++++++ .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 1261 ---------------- 2 files changed, 1278 insertions(+), 1261 deletions(-) create mode 100644 packages/compiler-cli/test/ngtsc/defer_spec.ts diff --git a/packages/compiler-cli/test/ngtsc/defer_spec.ts b/packages/compiler-cli/test/ngtsc/defer_spec.ts new file mode 100644 index 00000000000..dac5cf6a1de --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/defer_spec.ts @@ -0,0 +1,1278 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ErrorCode, ngErrorCode} from '@angular/compiler-cli/src/ngtsc/diagnostics'; + +import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing'; +import {loadStandardTestFiles} from '../../src/ngtsc/testing'; + +import {NgtscTestEnvironment} from './env'; + +const testFiles = loadStandardTestFiles(); + +runInEachFileSystem(() => { + describe('ngtsc @defer block', () => { + let env!: NgtscTestEnvironment; + + beforeEach(() => { + env = NgtscTestEnvironment.setup(testFiles); + env.tsconfig(); + }); + + it('should handle deferred blocks', () => { + env.write('cmp-a.ts', ` + import { Component } from '@angular/core'; + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { CmpA } from './cmp-a'; + + @Component({ + selector: 'local-dep', + standalone: true, + template: 'Local dependency', + }) + export class LocalDep {} + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: \` + @defer { + + + } + \`, + }) + export class TestCmp {} + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + expect(jsContents).toContain('() => [import("./cmp-a").then(m => m.CmpA), LocalDep]'); + + // The `CmpA` symbol wasn't referenced elsewhere, so it can be defer-loaded + // via dynamic imports and an original import can be removed. + expect(jsContents).not.toContain('import { CmpA }'); + }); + + it('should include timer scheduler function when ' + + '`after` or `minimum` parameters are used', + () => { + env.write('cmp-a.ts', ` + import { Component } from '@angular/core'; + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { CmpA } from './cmp-a'; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA], + template: \` + @defer { + + } @loading (after 500ms; minimum 300ms) { + Loading... + } + \`, + }) + export class TestCmp {} + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + expect(jsContents) + .toContain( + 'ɵɵdefer(2, 0, TestCmp_Defer_2_DepsFn, 1, null, null, 0, null, i0.ɵɵdeferEnableTimerScheduling)'); + }); + + describe('imports', () => { + it('should retain regular imports when symbol is eagerly referenced', () => { + env.write('cmp-a.ts', ` + import { Component } from '@angular/core'; + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { CmpA } from './cmp-a'; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA], + template: \` + @defer { + + } + \`, + }) + export class TestCmp { + constructor() { + // This line retains the regular import of CmpA, + // since it's eagerly referenced in the code. + console.log(CmpA); + } + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + + // The dependency function doesn't have a dynamic import, because `CmpA` + // was eagerly referenced in component's code, thus regular import can not be removed. + expect(jsContents).toContain('() => [CmpA]'); + expect(jsContents).toContain('import { CmpA }'); + }); + + it('should retain regular imports when one of the symbols is eagerly referenced', () => { + env.write('cmp-a.ts', ` + import { Component } from '@angular/core'; + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + + @Component({ + standalone: true, + selector: 'cmp-b', + template: 'CmpB!' + }) + export class CmpB {} + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { CmpA, CmpB } from './cmp-a'; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, CmpB], + template: \` + @defer { + + + } + \`, + }) + export class TestCmp { + constructor() { + // This line retains the regular import of CmpA, + // since it's eagerly referenced in the code. + console.log(CmpA); + } + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + + // The dependency function doesn't have a dynamic import, because `CmpA` + // was eagerly referenced in component's code, thus regular import can not be removed. + // This also affects `CmpB`, since it was extracted from the same import. + expect(jsContents).toContain('() => [CmpA, CmpB]'); + expect(jsContents).toContain('import { CmpA, CmpB }'); + }); + + it('should drop regular imports when none of the symbols are eagerly referenced', () => { + env.write('cmp-a.ts', ` + import { Component } from '@angular/core'; + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + + @Component({ + standalone: true, + selector: 'cmp-b', + template: 'CmpB!' + }) + export class CmpB {} + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { CmpA, CmpB } from './cmp-a'; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, CmpB], + template: \` + @defer { + + + } + \`, + }) + export class TestCmp {} + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + + // Both `CmpA` and `CmpB` were used inside the defer block and were not + // referenced elsewhere, so we generate dynamic imports and drop a regular one. + expect(jsContents) + .toContain( + '() => [' + + 'import("./cmp-a").then(m => m.CmpA), ' + + 'import("./cmp-a").then(m => m.CmpB)]'); + expect(jsContents).not.toContain('import { CmpA, CmpB }'); + }); + + it('should lazy-load dependency referenced with a fowrardRef', () => { + env.write('cmp-a.ts', ` + import { Component } from '@angular/core'; + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + `); + + env.write('/test.ts', ` + import { Component, forwardRef } from '@angular/core'; + import { CmpA } from './cmp-a'; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [forwardRef(() => CmpA)], + template: \` + @defer { + + } + \`, + }) + export class TestCmp {} + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + expect(jsContents).toContain('() => [import("./cmp-a").then(m => m.CmpA)]'); + + // The `CmpA` symbol wasn't referenced elsewhere, so it can be defer-loaded + // via dynamic imports and an original import can be removed. + expect(jsContents).not.toContain('import { CmpA }'); + }); + + it('should drop imports when one is deferrable and the rest are type-only imports', () => { + env.write('cmp-a.ts', ` + import { Component } from '@angular/core'; + + export class Foo {} + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { CmpA, type Foo } from './cmp-a'; + + export const foo: Foo = {}; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA], + template: \` + @defer { + + } + \`, + }) + export class TestCmp {} + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + expect(jsContents).toContain('() => [import("./cmp-a").then(m => m.CmpA)]'); + expect(jsContents).not.toContain('import { CmpA }'); + }); + + it('should drop multiple imports to the same file when one is deferrable and the other has a single type-only element', + () => { + env.write('cmp-a.ts', ` + import { Component } from '@angular/core'; + + export class Foo {} + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { CmpA } from './cmp-a'; + import { type Foo } from './cmp-a'; + + export const foo: Foo = {}; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA], + template: \` + @defer { + + } + \`, + }) + export class TestCmp {} + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + expect(jsContents).toContain('() => [import("./cmp-a").then(m => m.CmpA)]'); + expect(jsContents).not.toContain('import { CmpA }'); + }); + + it('should drop multiple imports to the same file when one is deferrable and the other is type-only at the declaration level', + () => { + env.write('cmp-a.ts', ` + import { Component } from '@angular/core'; + + export class Foo {} + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { CmpA } from './cmp-a'; + import type { Foo, CmpA as CmpAlias } from './cmp-a'; + + export const foo: Foo|CmpAlias = {}; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA], + template: \` + @defer { + + } + \`, + }) + export class TestCmp {} + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + expect(jsContents).toContain('() => [import("./cmp-a").then(m => m.CmpA)]'); + expect(jsContents).not.toContain('import { CmpA }'); + }); + + it('should drop multiple imports to the same file when one is deferrable and the other is a type-only import of all symbols', + () => { + env.write('cmp-a.ts', ` + import { Component } from '@angular/core'; + + export class Foo {} + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { CmpA } from './cmp-a'; + import type * as allCmpA from './cmp-a'; + + export const foo: allCmpA.Foo|allCmpA.CmpA = {}; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA], + template: \` + @defer { + + } + \`, + }) + export class TestCmp {} + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + expect(jsContents).toContain('() => [import("./cmp-a").then(m => m.CmpA)]'); + expect(jsContents).not.toContain('import { CmpA }'); + }); + + it('should drop multiple imports of deferrable symbols from the same file', () => { + env.write('cmps.ts', ` + import { Component } from '@angular/core'; + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + + @Component({ + standalone: true, + selector: 'cmp-b', + template: 'CmpB!' + }) + export class CmpB {} + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { CmpA } from './cmps'; + import { CmpB } from './cmps'; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, CmpB], + template: \` + @defer { + + + } + \`, + }) + export class TestCmp {} + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + expect(jsContents) + .toContain( + '() => [import("./cmps").then(m => m.CmpA), import("./cmps").then(m => m.CmpB)]'); + expect(jsContents).not.toContain('import { CmpA }'); + expect(jsContents).not.toContain('import { CmpB }'); + }); + + it('should handle deferred dependencies imported through a default import', () => { + env.write('cmp-a.ts', ` + import { Component } from '@angular/core'; + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export default class CmpA {} + `); + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import CmpA from './cmp-a'; + @Component({ + selector: 'local-dep', + standalone: true, + template: 'Local dependency', + }) + export class LocalDep {} + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: \` + @defer { + + + } + \`, + }) + export class TestCmp {} + `); + env.driveMain(); + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + expect(jsContents) + .toContain( + 'const TestCmp_Defer_1_DepsFn = () => [import("./cmp-a").then(m => m.default), LocalDep];'); + expect(jsContents) + .toContain( + 'i0.ɵsetClassMetadataAsync(TestCmp, () => [import("./cmp-a").then(m => m.default)]'); + // The `CmpA` symbol wasn't referenced elsewhere, so it can be defer-loaded + // via dynamic imports and an original import can be removed. + expect(jsContents).not.toContain('import CmpA'); + }); + }); + + it('should detect pipe used in the `when` trigger as an eager dependency', () => { + env.write('test-pipe.ts', ` + import { Pipe } from '@angular/core'; + + @Pipe({name: 'test', standalone: true}) + export class TestPipe { + transform() { + return 1; + } + } + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { TestPipe } from './test-pipe'; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [TestPipe], + template: '@defer (when 1 | test) { hello }', + }) + export class TestCmp { + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('dependencies: [TestPipe]'); + }); + + it('should detect pipe used in the `prefetch when` trigger as an eager dependency', () => { + env.write('test-pipe.ts', ` + import { Pipe } from '@angular/core'; + + @Pipe({name: 'test', standalone: true}) + export class TestPipe { + transform() { + return 1; + } + } + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { TestPipe } from './test-pipe'; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [TestPipe], + template: '@defer (when 1 | test) { hello }', + }) + export class TestCmp { + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('dependencies: [TestPipe]'); + }); + + it('should detect pipe used both in a trigger and the deferred content as eager', () => { + env.write('test-pipe.ts', ` + import { Pipe } from '@angular/core'; + + @Pipe({name: 'test', standalone: true}) + export class TestPipe { + transform() { + return 1; + } + } + `); + + env.write('/test.ts', ` + import { Component } from '@angular/core'; + import { TestPipe } from './test-pipe'; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [TestPipe], + template: '@defer (when 1 | test) { {{1 | test}} }', + }) + export class TestCmp { + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('dependencies: [TestPipe]'); + }); + + describe('@Component.deferredImports', () => { + beforeEach(() => { + env.tsconfig({onlyExplicitDeferDependencyImports: true}); + }); + + it('should handle `@Component.deferredImports` field', () => { + env.write('deferred-a.ts', ` + import {Component} from '@angular/core'; + + @Component({ + standalone: true, + selector: 'deferred-cmp-a', + template: 'DeferredCmpA contents', + }) + export class DeferredCmpA { + } + `); + + env.write('deferred-b.ts', ` + import {Component} from '@angular/core'; + + @Component({ + standalone: true, + selector: 'deferred-cmp-b', + template: 'DeferredCmpB contents', + }) + export class DeferredCmpB { + } + `); + + env.write('test.ts', ` + import {Component} from '@angular/core'; + import {DeferredCmpA} from './deferred-a'; + import {DeferredCmpB} from './deferred-b'; + + @Component({ + standalone: true, + deferredImports: [DeferredCmpA, DeferredCmpB], + template: \` + @defer { + + } + @defer { + + } + \`, + }) + export class AppCmp { + } + `); + + env.driveMain(); + const jsContents = env.getContents('test.js'); + + // Expect that all deferrableImports become dynamic imports. + expect(jsContents) + .toContain( + 'const AppCmp_Defer_1_DepsFn = () => [' + + 'import("./deferred-a").then(m => m.DeferredCmpA)];'); + expect(jsContents) + .toContain( + 'const AppCmp_Defer_4_DepsFn = () => [' + + 'import("./deferred-b").then(m => m.DeferredCmpB)];'); + + // Make sure there are no eager imports present in the output. + expect(jsContents).not.toContain(`from './deferred-a'`); + expect(jsContents).not.toContain(`from './deferred-b'`); + + // Defer instructions have different dependency functions in full mode. + expect(jsContents).toContain('ɵɵdefer(1, 0, AppCmp_Defer_1_DepsFn);'); + expect(jsContents).toContain('ɵɵdefer(4, 3, AppCmp_Defer_4_DepsFn);'); + + // Expect `ɵsetClassMetadataAsync` to contain dynamic imports too. + expect(jsContents) + .toContain( + 'ɵsetClassMetadataAsync(AppCmp, () => [' + + 'import("./deferred-a").then(m => m.DeferredCmpA), ' + + 'import("./deferred-b").then(m => m.DeferredCmpB)], ' + + '(DeferredCmpA, DeferredCmpB) => {'); + }); + + it('should handle defer blocks that rely on deps from `deferredImports` and `imports`', + () => { + env.write('eager-a.ts', ` + import {Component} from '@angular/core'; + + @Component({ + standalone: true, + selector: 'eager-cmp-a', + template: 'EagerCmpA contents', + }) + export class EagerCmpA { + } + `); + + env.write('deferred-a.ts', ` + import {Component} from '@angular/core'; + + @Component({ + standalone: true, + selector: 'deferred-cmp-a', + template: 'DeferredCmpA contents', + }) + export class DeferredCmpA { + } + `); + + env.write('deferred-b.ts', ` + import {Component} from '@angular/core'; + + @Component({ + standalone: true, + selector: 'deferred-cmp-b', + template: 'DeferredCmpB contents', + }) + export class DeferredCmpB { + } + `); + + env.write('test.ts', ` + import {Component} from '@angular/core'; + import {DeferredCmpA} from './deferred-a'; + import {DeferredCmpB} from './deferred-b'; + import {EagerCmpA} from './eager-a'; + + @Component({ + standalone: true, + imports: [EagerCmpA], + deferredImports: [DeferredCmpA, DeferredCmpB], + template: \` + @defer { + + + } + @defer { + + + } + \`, + }) + export class AppCmp { + } + `); + + env.driveMain(); + const jsContents = env.getContents('test.js'); + + // Expect that all deferrableImports to become dynamic imports. + // Other imported symbols remain eager. + expect(jsContents) + .toContain( + 'const AppCmp_Defer_1_DepsFn = () => [' + + 'import("./deferred-a").then(m => m.DeferredCmpA), ' + + 'EagerCmpA];'); + expect(jsContents) + .toContain( + 'const AppCmp_Defer_4_DepsFn = () => [' + + 'import("./deferred-b").then(m => m.DeferredCmpB), ' + + 'EagerCmpA];'); + + // Make sure there are no eager imports present in the output. + expect(jsContents).not.toContain(`from './deferred-a'`); + expect(jsContents).not.toContain(`from './deferred-b'`); + + // Eager dependencies retain their imports. + expect(jsContents).toContain(`from './eager-a';`); + + // Defer blocks would have their own dependency functions in full mode. + expect(jsContents).toContain('ɵɵdefer(1, 0, AppCmp_Defer_1_DepsFn);'); + expect(jsContents).toContain('ɵɵdefer(4, 3, AppCmp_Defer_4_DepsFn);'); + + // Expect `ɵsetClassMetadataAsync` to contain dynamic imports too. + expect(jsContents) + .toContain( + 'ɵsetClassMetadataAsync(AppCmp, () => [' + + 'import("./deferred-a").then(m => m.DeferredCmpA), ' + + 'import("./deferred-b").then(m => m.DeferredCmpB)], ' + + '(DeferredCmpA, DeferredCmpB) => {'); + }); + + describe('error handling', () => { + it('should produce an error when unsupported type (@Injectable) is used in `deferredImports`', + () => { + env.write('test.ts', ` + import {Component, Injectable} from '@angular/core'; + @Injectable() + class MyInjectable {} + @Component({ + standalone: true, + deferredImports: [MyInjectable], + template: '', + }) + export class AppCmp { + } + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.COMPONENT_UNKNOWN_DEFERRED_IMPORT)); + }); + + it('should produce an error when unsupported type (@NgModule) is used in `deferredImports`', + () => { + env.write('test.ts', ` + import {Component, NgModule} from '@angular/core'; + @NgModule() + class MyModule {} + @Component({ + standalone: true, + deferredImports: [MyModule], + template: '', + }) + export class AppCmp { + } + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.COMPONENT_UNKNOWN_DEFERRED_IMPORT)); + }); + + it('should produce an error when components from `deferredImports` are used outside of defer blocks', + () => { + env.write('deferred-a.ts', ` + import {Component} from '@angular/core'; + @Component({ + standalone: true, + selector: 'deferred-cmp-a', + template: 'DeferredCmpA contents', + }) + export class DeferredCmpA { + } + `); + + env.write('deferred-b.ts', ` + import {Component} from '@angular/core'; + @Component({ + standalone: true, + selector: 'deferred-cmp-b', + template: 'DeferredCmpB contents', + }) + export class DeferredCmpB { + } + `); + + env.write('test.ts', ` + import {Component} from '@angular/core'; + import {DeferredCmpA} from './deferred-a'; + import {DeferredCmpB} from './deferred-b'; + @Component({ + standalone: true, + deferredImports: [DeferredCmpA, DeferredCmpB], + template: \` + + @defer { + + } + \`, + }) + export class AppCmp { + } + `); + + const diags = env.driveDiagnostics(); + + expect(diags.length).toBe(1); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.DEFERRED_DIRECTIVE_USED_EAGERLY)); + }); + + it('should produce an error the same component is referenced in both `deferredImports` and `imports`', + () => { + env.write('deferred-a.ts', ` + import {Component} from '@angular/core'; + @Component({ + standalone: true, + selector: 'deferred-cmp-a', + template: 'DeferredCmpA contents', + }) + export class DeferredCmpA { + } + `); + + env.write('test.ts', ` + import {Component} from '@angular/core'; + import {DeferredCmpA} from './deferred-a'; + @Component({ + standalone: true, + deferredImports: [DeferredCmpA], + imports: [DeferredCmpA], + template: \` + @defer { + + } + \`, + }) + export class AppCmp {} + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].code) + .toBe(ngErrorCode(ErrorCode.DEFERRED_DEPENDENCY_IMPORTED_EAGERLY)); + }); + + it('should produce an error when pipes from `deferredImports` are used outside of defer blocks', + () => { + env.write('deferred-pipe-a.ts', ` + import {Pipe} from '@angular/core'; + @Pipe({ + standalone: true, + name: 'deferredPipeA' + }) + export class DeferredPipeA { + transform() {} + } + `); + + env.write('deferred-pipe-b.ts', ` + import {Pipe} from '@angular/core'; + @Pipe({ + standalone: true, + name: 'deferredPipeB' + }) + export class DeferredPipeB { + transform() {} + } + `); + + env.write('test.ts', ` + import {Component} from '@angular/core'; + import {DeferredPipeA} from './deferred-pipe-a'; + import {DeferredPipeB} from './deferred-pipe-b'; + @Component({ + standalone: true, + deferredImports: [DeferredPipeA, DeferredPipeB], + template: \` + {{ 'Eager' | deferredPipeA }} + @defer { + {{ 'Deferred' | deferredPipeB }} + } + \`, + }) + export class AppCmp {} + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.DEFERRED_PIPE_USED_EAGERLY)); + }); + + it('should not produce an error when a deferred block is wrapped in a conditional', () => { + env.write('deferred-a.ts', ` + import {Component} from '@angular/core'; + @Component({ + standalone: true, + selector: 'deferred-cmp-a', + template: 'DeferredCmpA contents', + }) + export class DeferredCmpA { + } + `); + + env.write('test.ts', ` + import {Component} from '@angular/core'; + import {DeferredCmpA} from './deferred-a'; + @Component({ + standalone: true, + deferredImports: [DeferredCmpA], + template: \` + @if (true) { + @if (true) { + @if (true) { + @defer { + + } + } + } + } + \`, + }) + export class AppCmp { + condition = true; + } + `); + + const diags = env.driveDiagnostics(); + expect(diags).toEqual([]); + }); + + it('should not produce an error when a dependency is wrapped in a condition inside of a deferred block', + () => { + env.write('deferred-a.ts', ` + import {Component} from '@angular/core'; + @Component({ + standalone: true, + selector: 'deferred-cmp-a', + template: 'DeferredCmpA contents', + }) + export class DeferredCmpA { + } + `); + + env.write('test.ts', ` + import {Component} from '@angular/core'; + import {DeferredCmpA} from './deferred-a'; + @Component({ + standalone: true, + deferredImports: [DeferredCmpA], + template: \` + @defer { + @if (true) { + @if (true) { + @if (true) { + + } + } + } + } + \`, + }) + export class AppCmp { + condition = true; + } + `); + + const diags = env.driveDiagnostics(); + expect(diags).toEqual([]); + }); + }); + }); + + describe('setClassMetadataAsync', () => { + it('should generate setClassMetadataAsync for components with defer blocks', () => { + env.write('cmp-a.ts', ` + import {Component} from '@angular/core'; + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + `); + + env.write('/test.ts', ` + import {Component} from '@angular/core'; + import {CmpA} from './cmp-a'; + + @Component({ + selector: 'local-dep', + standalone: true, + template: 'Local dependency', + }) + export class LocalDep {} + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: \` + @defer { + + + } + \`, + }) + export class TestCmp {} + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + expect(jsContents) + .toContain( + // ngDevMode check is present + '(() => { (typeof ngDevMode === "undefined" || ngDevMode) && ' + + // Main `setClassMetadataAsync` call + 'i0.ɵsetClassMetadataAsync(TestCmp, ' + + // Dependency loading function (note: no local `LocalDep` here) + '() => [import("./cmp-a").then(m => m.CmpA)], ' + + // Callback that invokes `setClassMetadata` at the end + 'CmpA => { i0.ɵsetClassMetadata(TestCmp'); + }); + + it('should *not* generate setClassMetadataAsync for components with defer blocks ' + + 'when dependencies are eagerly referenced as well', + () => { + env.write('cmp-a.ts', ` + import {Component} from '@angular/core'; + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export class CmpA {} + `); + + env.write('/test.ts', ` + import {Component} from '@angular/core'; + import {CmpA} from './cmp-a'; + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA], + template: \` + @defer { + + } + \`, + }) + export class TestCmp { + constructor() { + // This eager reference retains 'CmpA' symbol as eager. + console.log(CmpA); + } + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + // Dependency function eagerly references `CmpA`. + expect(jsContents).toContain('() => [CmpA]'); + + // The `setClassMetadataAsync` wasn't generated, since there are no deferrable + // symbols. + expect(jsContents).not.toContain('setClassMetadataAsync'); + + // But the regular `setClassMetadata` is present. + expect(jsContents).toContain('setClassMetadata'); + }); + }); + + it('should generate setClassMetadataAsync for default imports', () => { + env.write('cmp-a.ts', ` + import {Component} from '@angular/core'; + + @Component({ + standalone: true, + selector: 'cmp-a', + template: 'CmpA!' + }) + export default class CmpA {} + `); + + env.write('/test.ts', ` + import {Component} from '@angular/core'; + import CmpA from './cmp-a'; + + @Component({ + selector: 'local-dep', + standalone: true, + template: 'Local dependency', + }) + export class LocalDep {} + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: \` + @defer { + + + } + \`, + }) + export class TestCmp {} + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); + expect(jsContents) + .toContain( + // ngDevMode check is present + '(() => { (typeof ngDevMode === "undefined" || ngDevMode) && ' + + // Main `setClassMetadataAsync` call + 'i0.ɵsetClassMetadataAsync(TestCmp, ' + + // Dependency loading function (note: no local `LocalDep` here) + '() => [import("./cmp-a").then(m => m.default)], ' + + // Callback that invokes `setClassMetadata` at the end + 'CmpA => { i0.ɵsetClassMetadata(TestCmp'); + }); + }); +}); diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index a3bf0a978ee..00880a99617 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -9047,1267 +9047,6 @@ function allTests(os: string) { }); }); - describe('deferred blocks', () => { - it('should handle deferred blocks', () => { - env.write('cmp-a.ts', ` - import { Component } from '@angular/core'; - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { CmpA } from './cmp-a'; - - @Component({ - selector: 'local-dep', - standalone: true, - template: 'Local dependency', - }) - export class LocalDep {} - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA, LocalDep], - template: \` - @defer { - - - } - \`, - }) - export class TestCmp {} - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - expect(jsContents).toContain('() => [import("./cmp-a").then(m => m.CmpA), LocalDep]'); - - // The `CmpA` symbol wasn't referenced elsewhere, so it can be defer-loaded - // via dynamic imports and an original import can be removed. - expect(jsContents).not.toContain('import { CmpA }'); - }); - - it('should include timer scheduler function when ' + - '`after` or `minimum` parameters are used', - () => { - env.write('cmp-a.ts', ` - import { Component } from '@angular/core'; - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { CmpA } from './cmp-a'; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA], - template: \` - @defer { - - } @loading (after 500ms; minimum 300ms) { - Loading... - } - \`, - }) - export class TestCmp {} - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - expect(jsContents) - .toContain( - 'ɵɵdefer(2, 0, TestCmp_Defer_2_DepsFn, 1, null, null, 0, null, i0.ɵɵdeferEnableTimerScheduling)'); - }); - - describe('imports', () => { - it('should retain regular imports when symbol is eagerly referenced', () => { - env.write('cmp-a.ts', ` - import { Component } from '@angular/core'; - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { CmpA } from './cmp-a'; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA], - template: \` - @defer { - - } - \`, - }) - export class TestCmp { - constructor() { - // This line retains the regular import of CmpA, - // since it's eagerly referenced in the code. - console.log(CmpA); - } - } - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - - // The dependency function doesn't have a dynamic import, because `CmpA` - // was eagerly referenced in component's code, thus regular import can not be removed. - expect(jsContents).toContain('() => [CmpA]'); - expect(jsContents).toContain('import { CmpA }'); - }); - - it('should retain regular imports when one of the symbols is eagerly referenced', () => { - env.write('cmp-a.ts', ` - import { Component } from '@angular/core'; - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - - @Component({ - standalone: true, - selector: 'cmp-b', - template: 'CmpB!' - }) - export class CmpB {} - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { CmpA, CmpB } from './cmp-a'; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA, CmpB], - template: \` - @defer { - - - } - \`, - }) - export class TestCmp { - constructor() { - // This line retains the regular import of CmpA, - // since it's eagerly referenced in the code. - console.log(CmpA); - } - } - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - - // The dependency function doesn't have a dynamic import, because `CmpA` - // was eagerly referenced in component's code, thus regular import can not be removed. - // This also affects `CmpB`, since it was extracted from the same import. - expect(jsContents).toContain('() => [CmpA, CmpB]'); - expect(jsContents).toContain('import { CmpA, CmpB }'); - }); - - it('should drop regular imports when none of the symbols are eagerly referenced', () => { - env.write('cmp-a.ts', ` - import { Component } from '@angular/core'; - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - - @Component({ - standalone: true, - selector: 'cmp-b', - template: 'CmpB!' - }) - export class CmpB {} - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { CmpA, CmpB } from './cmp-a'; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA, CmpB], - template: \` - @defer { - - - } - \`, - }) - export class TestCmp {} - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - - // Both `CmpA` and `CmpB` were used inside the defer block and were not - // referenced elsewhere, so we generate dynamic imports and drop a regular one. - expect(jsContents) - .toContain( - '() => [' + - 'import("./cmp-a").then(m => m.CmpA), ' + - 'import("./cmp-a").then(m => m.CmpB)]'); - expect(jsContents).not.toContain('import { CmpA, CmpB }'); - }); - - it('should lazy-load dependency referenced with a fowrardRef', () => { - env.write('cmp-a.ts', ` - import { Component } from '@angular/core'; - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - `); - - env.write('/test.ts', ` - import { Component, forwardRef } from '@angular/core'; - import { CmpA } from './cmp-a'; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [forwardRef(() => CmpA)], - template: \` - @defer { - - } - \`, - }) - export class TestCmp {} - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - expect(jsContents).toContain('() => [import("./cmp-a").then(m => m.CmpA)]'); - - // The `CmpA` symbol wasn't referenced elsewhere, so it can be defer-loaded - // via dynamic imports and an original import can be removed. - expect(jsContents).not.toContain('import { CmpA }'); - }); - - it('should drop imports when one is deferrable and the rest are type-only imports', () => { - env.write('cmp-a.ts', ` - import { Component } from '@angular/core'; - - export class Foo {} - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { CmpA, type Foo } from './cmp-a'; - - export const foo: Foo = {}; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA], - template: \` - @defer { - - } - \`, - }) - export class TestCmp {} - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - expect(jsContents).toContain('() => [import("./cmp-a").then(m => m.CmpA)]'); - expect(jsContents).not.toContain('import { CmpA }'); - }); - - it('should drop multiple imports to the same file when one is deferrable and the other has a single type-only element', - () => { - env.write('cmp-a.ts', ` - import { Component } from '@angular/core'; - - export class Foo {} - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { CmpA } from './cmp-a'; - import { type Foo } from './cmp-a'; - - export const foo: Foo = {}; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA], - template: \` - @defer { - - } - \`, - }) - export class TestCmp {} - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - expect(jsContents).toContain('() => [import("./cmp-a").then(m => m.CmpA)]'); - expect(jsContents).not.toContain('import { CmpA }'); - }); - - it('should drop multiple imports to the same file when one is deferrable and the other is type-only at the declaration level', - () => { - env.write('cmp-a.ts', ` - import { Component } from '@angular/core'; - - export class Foo {} - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { CmpA } from './cmp-a'; - import type { Foo, CmpA as CmpAlias } from './cmp-a'; - - export const foo: Foo|CmpAlias = {}; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA], - template: \` - @defer { - - } - \`, - }) - export class TestCmp {} - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - expect(jsContents).toContain('() => [import("./cmp-a").then(m => m.CmpA)]'); - expect(jsContents).not.toContain('import { CmpA }'); - }); - - it('should drop multiple imports to the same file when one is deferrable and the other is a type-only import of all symbols', - () => { - env.write('cmp-a.ts', ` - import { Component } from '@angular/core'; - - export class Foo {} - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { CmpA } from './cmp-a'; - import type * as allCmpA from './cmp-a'; - - export const foo: allCmpA.Foo|allCmpA.CmpA = {}; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA], - template: \` - @defer { - - } - \`, - }) - export class TestCmp {} - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - expect(jsContents).toContain('() => [import("./cmp-a").then(m => m.CmpA)]'); - expect(jsContents).not.toContain('import { CmpA }'); - }); - - it('should drop multiple imports of deferrable symbols from the same file', () => { - env.write('cmps.ts', ` - import { Component } from '@angular/core'; - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - - @Component({ - standalone: true, - selector: 'cmp-b', - template: 'CmpB!' - }) - export class CmpB {} - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { CmpA } from './cmps'; - import { CmpB } from './cmps'; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA, CmpB], - template: \` - @defer { - - - } - \`, - }) - export class TestCmp {} - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - expect(jsContents) - .toContain( - '() => [import("./cmps").then(m => m.CmpA), import("./cmps").then(m => m.CmpB)]'); - expect(jsContents).not.toContain('import { CmpA }'); - expect(jsContents).not.toContain('import { CmpB }'); - }); - - it('should handle deferred dependencies imported through a default import', () => { - env.write('cmp-a.ts', ` - import { Component } from '@angular/core'; - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export default class CmpA {} - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import CmpA from './cmp-a'; - - @Component({ - selector: 'local-dep', - standalone: true, - template: 'Local dependency', - }) - export class LocalDep {} - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA, LocalDep], - template: \` - @defer { - - - } - \`, - }) - export class TestCmp {} - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - expect(jsContents) - .toContain( - 'const TestCmp_Defer_1_DepsFn = () => [import("./cmp-a").then(m => m.default), LocalDep];'); - expect(jsContents) - .toContain( - 'i0.ɵsetClassMetadataAsync(TestCmp, () => [import("./cmp-a").then(m => m.default)]'); - - // The `CmpA` symbol wasn't referenced elsewhere, so it can be defer-loaded - // via dynamic imports and an original import can be removed. - expect(jsContents).not.toContain('import CmpA'); - }); - }); - - it('should detect pipe used in the `when` trigger as an eager dependency', () => { - env.write('test-pipe.ts', ` - import { Pipe } from '@angular/core'; - - @Pipe({name: 'test', standalone: true}) - export class TestPipe { - transform() { - return 1; - } - } - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { TestPipe } from './test-pipe'; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [TestPipe], - template: '@defer (when 1 | test) { hello }', - }) - export class TestCmp { - } - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('dependencies: [TestPipe]'); - }); - - it('should detect pipe used in the `prefetch when` trigger as an eager dependency', () => { - env.write('test-pipe.ts', ` - import { Pipe } from '@angular/core'; - - @Pipe({name: 'test', standalone: true}) - export class TestPipe { - transform() { - return 1; - } - } - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { TestPipe } from './test-pipe'; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [TestPipe], - template: '@defer (when 1 | test) { hello }', - }) - export class TestCmp { - } - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('dependencies: [TestPipe]'); - }); - - it('should detect pipe used both in a trigger and the deferred content as eager', () => { - env.write('test-pipe.ts', ` - import { Pipe } from '@angular/core'; - - @Pipe({name: 'test', standalone: true}) - export class TestPipe { - transform() { - return 1; - } - } - `); - - env.write('/test.ts', ` - import { Component } from '@angular/core'; - import { TestPipe } from './test-pipe'; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [TestPipe], - template: '@defer (when 1 | test) { {{1 | test}} }', - }) - export class TestCmp { - } - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('dependencies: [TestPipe]'); - }); - - describe('@Component.deferredImports', () => { - beforeEach(() => { - env.tsconfig({onlyExplicitDeferDependencyImports: true}); - }); - - it('should handle `@Component.deferredImports` field', () => { - env.write('deferred-a.ts', ` - import {Component} from '@angular/core'; - - @Component({ - standalone: true, - selector: 'deferred-cmp-a', - template: 'DeferredCmpA contents', - }) - export class DeferredCmpA { - } - `); - - env.write('deferred-b.ts', ` - import {Component} from '@angular/core'; - - @Component({ - standalone: true, - selector: 'deferred-cmp-b', - template: 'DeferredCmpB contents', - }) - export class DeferredCmpB { - } - `); - - env.write('test.ts', ` - import {Component} from '@angular/core'; - import {DeferredCmpA} from './deferred-a'; - import {DeferredCmpB} from './deferred-b'; - - @Component({ - standalone: true, - deferredImports: [DeferredCmpA, DeferredCmpB], - template: \` - @defer { - - } - @defer { - - } - \`, - }) - export class AppCmp { - } - `); - - env.driveMain(); - const jsContents = env.getContents('test.js'); - - // Expect that all deferrableImports become dynamic imports. - expect(jsContents) - .toContain( - 'const AppCmp_Defer_1_DepsFn = () => [' + - 'import("./deferred-a").then(m => m.DeferredCmpA)];'); - expect(jsContents) - .toContain( - 'const AppCmp_Defer_4_DepsFn = () => [' + - 'import("./deferred-b").then(m => m.DeferredCmpB)];'); - - // Make sure there are no eager imports present in the output. - expect(jsContents).not.toContain(`from './deferred-a'`); - expect(jsContents).not.toContain(`from './deferred-b'`); - - // Defer instructions have different dependency functions in full mode. - expect(jsContents).toContain('ɵɵdefer(1, 0, AppCmp_Defer_1_DepsFn);'); - expect(jsContents).toContain('ɵɵdefer(4, 3, AppCmp_Defer_4_DepsFn);'); - - // Expect `ɵsetClassMetadataAsync` to contain dynamic imports too. - expect(jsContents) - .toContain( - 'ɵsetClassMetadataAsync(AppCmp, () => [' + - 'import("./deferred-a").then(m => m.DeferredCmpA), ' + - 'import("./deferred-b").then(m => m.DeferredCmpB)], ' + - '(DeferredCmpA, DeferredCmpB) => {'); - }); - - it('should handle defer blocks that rely on deps from `deferredImports` and `imports`', - () => { - env.write('eager-a.ts', ` - import {Component} from '@angular/core'; - - @Component({ - standalone: true, - selector: 'eager-cmp-a', - template: 'EagerCmpA contents', - }) - export class EagerCmpA { - } - `); - - env.write('deferred-a.ts', ` - import {Component} from '@angular/core'; - - @Component({ - standalone: true, - selector: 'deferred-cmp-a', - template: 'DeferredCmpA contents', - }) - export class DeferredCmpA { - } - `); - - env.write('deferred-b.ts', ` - import {Component} from '@angular/core'; - - @Component({ - standalone: true, - selector: 'deferred-cmp-b', - template: 'DeferredCmpB contents', - }) - export class DeferredCmpB { - } - `); - - env.write('test.ts', ` - import {Component} from '@angular/core'; - import {DeferredCmpA} from './deferred-a'; - import {DeferredCmpB} from './deferred-b'; - import {EagerCmpA} from './eager-a'; - - @Component({ - standalone: true, - imports: [EagerCmpA], - deferredImports: [DeferredCmpA, DeferredCmpB], - template: \` - @defer { - - - } - @defer { - - - } - \`, - }) - export class AppCmp { - } - `); - - env.driveMain(); - const jsContents = env.getContents('test.js'); - - // Expect that all deferrableImports to become dynamic imports. - // Other imported symbols remain eager. - expect(jsContents) - .toContain( - 'const AppCmp_Defer_1_DepsFn = () => [' + - 'import("./deferred-a").then(m => m.DeferredCmpA), ' + - 'EagerCmpA];'); - expect(jsContents) - .toContain( - 'const AppCmp_Defer_4_DepsFn = () => [' + - 'import("./deferred-b").then(m => m.DeferredCmpB), ' + - 'EagerCmpA];'); - - // Make sure there are no eager imports present in the output. - expect(jsContents).not.toContain(`from './deferred-a'`); - expect(jsContents).not.toContain(`from './deferred-b'`); - - // Eager dependencies retain their imports. - expect(jsContents).toContain(`from './eager-a';`); - - // Defer blocks would have their own dependency functions in full mode. - expect(jsContents).toContain('ɵɵdefer(1, 0, AppCmp_Defer_1_DepsFn);'); - expect(jsContents).toContain('ɵɵdefer(4, 3, AppCmp_Defer_4_DepsFn);'); - - // Expect `ɵsetClassMetadataAsync` to contain dynamic imports too. - expect(jsContents) - .toContain( - 'ɵsetClassMetadataAsync(AppCmp, () => [' + - 'import("./deferred-a").then(m => m.DeferredCmpA), ' + - 'import("./deferred-b").then(m => m.DeferredCmpB)], ' + - '(DeferredCmpA, DeferredCmpB) => {'); - }); - - describe('error handling', () => { - it('should produce an error when unsupported type (@Injectable) is used in `deferredImports`', - () => { - env.write('test.ts', ` - import {Component, Injectable} from '@angular/core'; - @Injectable() - class MyInjectable {} - @Component({ - standalone: true, - deferredImports: [MyInjectable], - template: '', - }) - export class AppCmp { - } - `); - - const diags = env.driveDiagnostics(); - expect(diags.length).toBe(1); - expect(diags[0].code).toBe(ngErrorCode(ErrorCode.COMPONENT_UNKNOWN_DEFERRED_IMPORT)); - }); - - it('should produce an error when unsupported type (@NgModule) is used in `deferredImports`', - () => { - env.write('test.ts', ` - import {Component, NgModule} from '@angular/core'; - @NgModule() - class MyModule {} - @Component({ - standalone: true, - deferredImports: [MyModule], - template: '', - }) - export class AppCmp { - } - `); - - const diags = env.driveDiagnostics(); - expect(diags.length).toBe(1); - expect(diags[0].code).toBe(ngErrorCode(ErrorCode.COMPONENT_UNKNOWN_DEFERRED_IMPORT)); - }); - - it('should produce an error when components from `deferredImports` are used outside of defer blocks', - () => { - env.write('deferred-a.ts', ` - import {Component} from '@angular/core'; - @Component({ - standalone: true, - selector: 'deferred-cmp-a', - template: 'DeferredCmpA contents', - }) - export class DeferredCmpA { - } - `); - - env.write('deferred-b.ts', ` - import {Component} from '@angular/core'; - @Component({ - standalone: true, - selector: 'deferred-cmp-b', - template: 'DeferredCmpB contents', - }) - export class DeferredCmpB { - } - `); - - env.write('test.ts', ` - import {Component} from '@angular/core'; - import {DeferredCmpA} from './deferred-a'; - import {DeferredCmpB} from './deferred-b'; - @Component({ - standalone: true, - deferredImports: [DeferredCmpA, DeferredCmpB], - template: \` - - @defer { - - } - \`, - }) - export class AppCmp { - } - `); - - const diags = env.driveDiagnostics(); - - expect(diags.length).toBe(1); - expect(diags[0].code).toBe(ngErrorCode(ErrorCode.DEFERRED_DIRECTIVE_USED_EAGERLY)); - }); - - it('should produce an error the same component is referenced in both `deferredImports` and `imports`', - () => { - env.write('deferred-a.ts', ` - import {Component} from '@angular/core'; - @Component({ - standalone: true, - selector: 'deferred-cmp-a', - template: 'DeferredCmpA contents', - }) - export class DeferredCmpA { - } - `); - - env.write('test.ts', ` - import {Component} from '@angular/core'; - import {DeferredCmpA} from './deferred-a'; - @Component({ - standalone: true, - deferredImports: [DeferredCmpA], - imports: [DeferredCmpA], - template: \` - @defer { - - } - \`, - }) - export class AppCmp {} - `); - - const diags = env.driveDiagnostics(); - expect(diags.length).toBe(1); - expect(diags[0].code) - .toBe(ngErrorCode(ErrorCode.DEFERRED_DEPENDENCY_IMPORTED_EAGERLY)); - }); - - it('should produce an error when pipes from `deferredImports` are used outside of defer blocks', - () => { - env.write('deferred-pipe-a.ts', ` - import {Pipe} from '@angular/core'; - @Pipe({ - standalone: true, - name: 'deferredPipeA' - }) - export class DeferredPipeA { - transform() {} - } - `); - - env.write('deferred-pipe-b.ts', ` - import {Pipe} from '@angular/core'; - @Pipe({ - standalone: true, - name: 'deferredPipeB' - }) - export class DeferredPipeB { - transform() {} - } - `); - - env.write('test.ts', ` - import {Component} from '@angular/core'; - import {DeferredPipeA} from './deferred-pipe-a'; - import {DeferredPipeB} from './deferred-pipe-b'; - @Component({ - standalone: true, - deferredImports: [DeferredPipeA, DeferredPipeB], - template: \` - {{ 'Eager' | deferredPipeA }} - @defer { - {{ 'Deferred' | deferredPipeB }} - } - \`, - }) - export class AppCmp {} - `); - - const diags = env.driveDiagnostics(); - expect(diags.length).toBe(1); - expect(diags[0].code).toBe(ngErrorCode(ErrorCode.DEFERRED_PIPE_USED_EAGERLY)); - }); - - it('should not produce an error when a deferred block is wrapped in a conditional', - () => { - env.write('deferred-a.ts', ` - import {Component} from '@angular/core'; - @Component({ - standalone: true, - selector: 'deferred-cmp-a', - template: 'DeferredCmpA contents', - }) - export class DeferredCmpA { - } - `); - - env.write('test.ts', ` - import {Component} from '@angular/core'; - import {DeferredCmpA} from './deferred-a'; - @Component({ - standalone: true, - deferredImports: [DeferredCmpA], - template: \` - @if (true) { - @if (true) { - @if (true) { - @defer { - - } - } - } - } - \`, - }) - export class AppCmp { - condition = true; - } - `); - - const diags = env.driveDiagnostics(); - expect(diags).toEqual([]); - }); - - it('should not produce an error when a dependency is wrapped in a condition inside of a deferred block', - () => { - env.write('deferred-a.ts', ` - import {Component} from '@angular/core'; - @Component({ - standalone: true, - selector: 'deferred-cmp-a', - template: 'DeferredCmpA contents', - }) - export class DeferredCmpA { - } - `); - - env.write('test.ts', ` - import {Component} from '@angular/core'; - import {DeferredCmpA} from './deferred-a'; - @Component({ - standalone: true, - deferredImports: [DeferredCmpA], - template: \` - @defer { - @if (true) { - @if (true) { - @if (true) { - - } - } - } - } - \`, - }) - export class AppCmp { - condition = true; - } - `); - - const diags = env.driveDiagnostics(); - expect(diags).toEqual([]); - }); - }); - }); - - describe('setClassMetadataAsync', () => { - it('should generate setClassMetadataAsync for components with defer blocks', () => { - env.write('cmp-a.ts', ` - import {Component} from '@angular/core'; - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - `); - - env.write('/test.ts', ` - import {Component} from '@angular/core'; - import {CmpA} from './cmp-a'; - - @Component({ - selector: 'local-dep', - standalone: true, - template: 'Local dependency', - }) - export class LocalDep {} - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA, LocalDep], - template: \` - @defer { - - - } - \`, - }) - export class TestCmp {} - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - expect(jsContents) - .toContain( - // ngDevMode check is present - '(() => { (typeof ngDevMode === "undefined" || ngDevMode) && ' + - // Main `setClassMetadataAsync` call - 'i0.ɵsetClassMetadataAsync(TestCmp, ' + - // Dependency loading function (note: no local `LocalDep` here) - '() => [import("./cmp-a").then(m => m.CmpA)], ' + - // Callback that invokes `setClassMetadata` at the end - 'CmpA => { i0.ɵsetClassMetadata(TestCmp'); - }); - - it('should *not* generate setClassMetadataAsync for components with defer blocks ' + - 'when dependencies are eagerly referenced as well', - () => { - env.write('cmp-a.ts', ` - import {Component} from '@angular/core'; - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export class CmpA {} - `); - - env.write('/test.ts', ` - import {Component} from '@angular/core'; - import {CmpA} from './cmp-a'; - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA], - template: \` - @defer { - - } - \`, - }) - export class TestCmp { - constructor() { - // This eager reference retains 'CmpA' symbol as eager. - console.log(CmpA); - } - } - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - // Dependency function eagerly references `CmpA`. - expect(jsContents).toContain('() => [CmpA]'); - - // The `setClassMetadataAsync` wasn't generated, since there are no deferrable - // symbols. - expect(jsContents).not.toContain('setClassMetadataAsync'); - - // But the regular `setClassMetadata` is present. - expect(jsContents).toContain('setClassMetadata'); - }); - }); - - it('should generate setClassMetadataAsync for default imports', () => { - env.write('cmp-a.ts', ` - import {Component} from '@angular/core'; - - @Component({ - standalone: true, - selector: 'cmp-a', - template: 'CmpA!' - }) - export default class CmpA {} - `); - - env.write('/test.ts', ` - import {Component} from '@angular/core'; - import CmpA from './cmp-a'; - - @Component({ - selector: 'local-dep', - standalone: true, - template: 'Local dependency', - }) - export class LocalDep {} - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [CmpA, LocalDep], - template: \` - @defer { - - - } - \`, - }) - export class TestCmp {} - `); - - env.driveMain(); - - const jsContents = env.getContents('test.js'); - - expect(jsContents).toContain('ɵɵdefer(1, 0, TestCmp_Defer_1_DepsFn)'); - expect(jsContents) - .toContain( - // ngDevMode check is present - '(() => { (typeof ngDevMode === "undefined" || ngDevMode) && ' + - // Main `setClassMetadataAsync` call - 'i0.ɵsetClassMetadataAsync(TestCmp, ' + - // Dependency loading function (note: no local `LocalDep` here) - '() => [import("./cmp-a").then(m => m.default)], ' + - // Callback that invokes `setClassMetadata` at the end - 'CmpA => { i0.ɵsetClassMetadata(TestCmp'); - }); - }); - describe('debug info', () => { it('should set forbidOrphanRendering debug info for component when the option forbidOrphanComponents is set', () => {