diff --git a/packages/compiler-cli/test/ngtsc/local_compilation_spec.ts b/packages/compiler-cli/test/ngtsc/local_compilation_spec.ts index d1448e8c5a268..4e38f058af932 100644 --- a/packages/compiler-cli/test/ngtsc/local_compilation_spec.ts +++ b/packages/compiler-cli/test/ngtsc/local_compilation_spec.ts @@ -1146,5 +1146,218 @@ runInEachFileSystem(() => { 'inputs: { x: [i0.ɵɵInputFlags.HasDecoratorInputTransform, "x", "x", v => v + \'TRANSFORMED!\'] }'); }); }); + + describe('@defer', () => { + 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 in local compilation mode + // are located in a single function (since we can't detect in + // the local mode which components belong to which block). + expect(jsContents) + .toContain( + 'const AppCmp_DeferFn = () => [' + + 'import("./deferred-a").then(m => m.DeferredCmpA), ' + + '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'`); + + // All defer instructions use the same dependency function. + expect(jsContents).toContain('ɵɵdefer(1, 0, AppCmp_DeferFn);'); + expect(jsContents).toContain('ɵɵdefer(4, 3, AppCmp_DeferFn);'); + + // 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 `@Component.imports` 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('test.ts', ` + import {Component} from '@angular/core'; + import {DeferredCmpA} from './deferred-a'; + @Component({ + standalone: true, + imports: [DeferredCmpA], + template: \` + @defer { + + } + \`, + }) + export class AppCmp { + } + `); + + env.driveMain(); + const jsContents = env.getContents('test.js'); + + // In local compilation mode we can't detect which components + // belong to `@defer` blocks, thus can't determine whether corresponding + // classes can be defer-loaded. In this case we retain eager imports + // and do not generate defer dependency functions for `@defer` instructions. + + // Eager imports are retained in the output. + expect(jsContents).toContain(`from './deferred-a'`); + + // Defer instructions do not have a dependency function, + // since all dependencies were defined in `@Component.imports`. + expect(jsContents).toContain('ɵɵdefer(1, 0);'); + + // Expect `ɵsetClassMetadata` (sync) to be generated. + expect(jsContents).toContain('ɵsetClassMetadata(AppCmp,'); + }); + + 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 in local compilation mode + // are located in a single function (since we can't detect in + // the local mode which components belong to which block). + // Eager dependencies are **not* included here. + expect(jsContents) + .toContain( + 'const AppCmp_DeferFn = () => [' + + 'import("./deferred-a").then(m => m.DeferredCmpA), ' + + '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'`); + + // Eager dependencies retain their imports. + expect(jsContents).toContain(`from './eager-a';`); + + // All defer instructions use the same dependency function. + expect(jsContents).toContain('ɵɵdefer(1, 0, AppCmp_DeferFn);'); + expect(jsContents).toContain('ɵɵdefer(4, 3, AppCmp_DeferFn);'); + + // 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) => {'); + }); + }); }); }); diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index 2f49ee6505afd..fbaac611217fa 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -9544,6 +9544,358 @@ function allTests(os: string) { 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)); + }); + }); + }); + describe('setClassMetadataAsync', () => { it('should generate setClassMetadataAsync for components with defer blocks', () => { env.write('cmp-a.ts', `