From be51f81b4864603994d6fef662318add3aa2b691 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:32:03 -0400 Subject: [PATCH 1/2] fix(@ngtools/webpack): elide non-value aliases and remaining type-only AST constructs Follow up to #34582 to cover remaining pure-type import elision cases and partial specifier removal in `replaceBootstrap`: - Elide imported alias symbols that do not resolve to a runtime value (or traverse a type-only alias declaration), handling non-`type` interface re-exports (`export { Foo }`) and interface parameter types under `emitDecoratorMetadata`. - Only rewrite a partially elided import declaration when a runtime value import must be removed or when the declaration was already replaced by a previous transformer. TypeScript skips its own import elision for declarations replaced by a transformer, so leaving other declarations untouched avoids retaining specifiers that TypeScript would have otherwise elided (such as inlined `const enum` imports). - Skip all non-`ExpressionWithTypeArguments` `TypeNode`s when `emitDecoratorMetadata` is disabled, and unconditionally skip `TypeQueryNode` (`typeof X`), `ImportTypeNode`, `IndexSignatureDeclaration`, ambient `declare` statements, and external `export ... from '...'` declarations. - Recursively visit child AST nodes when applying `elideImports` removals in `replaceBootstrap` so individual `ImportSpecifier`s are removed when `platformBrowserDynamic` shares an import with other used runtime exports. --- .../ngtools/webpack/src/ivy/transformation.ts | 12 +- .../webpack/src/ivy/transformation_spec.ts | 103 ++++++ .../webpack/src/transformers/elide_imports.ts | 95 ++++- .../src/transformers/elide_imports_spec.ts | 344 ++++++++++++++++++ 4 files changed, 542 insertions(+), 12 deletions(-) create mode 100644 packages/ngtools/webpack/src/ivy/transformation_spec.ts diff --git a/packages/ngtools/webpack/src/ivy/transformation.ts b/packages/ngtools/webpack/src/ivy/transformation.ts index 00b6378ad6a6..07754fe85749 100644 --- a/packages/ngtools/webpack/src/ivy/transformation.ts +++ b/packages/ngtools/webpack/src/ivy/transformation.ts @@ -149,7 +149,17 @@ export function replaceBootstrap( if (removals.size > 0) { updatedSourceFile = ts.visitEachChild( updatedSourceFile, - (node) => (removals.has(node) ? undefined : node), + function visitForRemoval(node): ts.Node | undefined { + if (removals.has(node)) { + return undefined; + } + + return ts.isImportDeclaration(node) || + ts.isImportClause(node) || + ts.isNamedImports(node) + ? ts.visitEachChild(node, visitForRemoval, context) + : node; + }, context, ); } diff --git a/packages/ngtools/webpack/src/ivy/transformation_spec.ts b/packages/ngtools/webpack/src/ivy/transformation_spec.ts new file mode 100644 index 000000000000..fcafbaf4ef1a --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/transformation_spec.ts @@ -0,0 +1,103 @@ +/** + * @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.dev/license + */ + +import ts from 'typescript'; +import { replaceBootstrap } from './transformation'; + +describe('replaceBootstrap', () => { + function transformCode( + files: Record, + entryFile = '/project/src/main.ts', + ): string { + const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noLib: true, + }; + + const host: ts.CompilerHost = { + getSourceFile: (fileName, languageVersion) => + files[fileName] !== undefined + ? ts.createSourceFile(fileName, files[fileName], languageVersion, true) + : undefined, + getDefaultLibFileName: () => '/lib.d.ts', + writeFile: () => undefined, + getCurrentDirectory: () => '/project', + getCanonicalFileName: (fileName) => fileName, + useCaseSensitiveFileNames: () => true, + getNewLine: () => '\n', + fileExists: (fileName) => files[fileName] !== undefined, + readFile: (fileName) => files[fileName], + }; + + const program = ts.createProgram([entryFile], compilerOptions, host); + let output = ''; + const sourceFile = program.getSourceFile(entryFile); + expect(sourceFile).toBeDefined(); + + program.emit( + sourceFile, + (_fileName, text) => { + output = text; + }, + undefined, + false, + { before: [replaceBootstrap(() => program.getTypeChecker())] }, + ); + + return output; + } + + const platformBrowserDynamicStub = ` + export declare function platformBrowserDynamic(extraProviders?: unknown[]): { + bootstrapModule(moduleType: unknown): Promise; + }; + export const VERSION = '0.0.0'; + export interface PlatformHelper {} + `; + + it('replaces platformBrowserDynamic and removes its import when no other specifiers remain', () => { + const output = transformCode({ + '/project/node_modules/@angular/platform-browser-dynamic/index.d.ts': + platformBrowserDynamicStub, + '/project/src/app.module.ts': 'export class AppModule {}', + '/project/src/main.ts': ` + import { platformBrowserDynamic, PlatformHelper } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app.module'; + + export { PlatformHelper }; + platformBrowserDynamic().bootstrapModule(AppModule); + `, + }); + + expect(output).toContain(`import * as __NgCli_bootstrap_1 from "@angular/platform-browser";`); + expect(output).toContain(`__NgCli_bootstrap_1.platformBrowser().bootstrapModule(AppModule);`); + expect(output).not.toContain('@angular/platform-browser-dynamic'); + }); + + it('removes only platformBrowserDynamic specifier when another runtime export from the same module is used', () => { + const output = transformCode({ + '/project/node_modules/@angular/platform-browser-dynamic/index.d.ts': + platformBrowserDynamicStub, + '/project/src/app.module.ts': 'export class AppModule {}', + '/project/src/main.ts': ` + import { platformBrowserDynamic, VERSION, PlatformHelper } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app.module'; + + export type { PlatformHelper }; + export const version = VERSION; + platformBrowserDynamic().bootstrapModule(AppModule); + `, + }); + + expect(output).toContain(`import { VERSION } from '@angular/platform-browser-dynamic';`); + expect(output).not.toContain('PlatformHelper'); + expect(output).toContain(`__NgCli_bootstrap_1.platformBrowser().bootstrapModule(AppModule);`); + }); +}); diff --git a/packages/ngtools/webpack/src/transformers/elide_imports.ts b/packages/ngtools/webpack/src/transformers/elide_imports.ts index e0f109b78f9e..91b2f648f685 100644 --- a/packages/ngtools/webpack/src/transformers/elide_imports.ts +++ b/packages/ngtools/webpack/src/transformers/elide_imports.ts @@ -54,17 +54,30 @@ export function elideImports( return; } - // Type reference imports do not need to be emitted when emitDecoratorMetadata is disabled. - if (ts.isTypeReferenceNode(node) && !compilerOptions.emitDecoratorMetadata) { + // Type nodes do not need to be emitted when `emitDecoratorMetadata` is disabled. + // `ExpressionWithTypeArguments` is excluded as it also represents runtime values such as + // `class ... extends Base` heritage clauses and instantiation expressions (`Map`). + if ( + ts.isTypeNode(node) && + !ts.isExpressionWithTypeArguments(node) && + !compilerOptions.emitDecoratorMetadata + ) { return; } - // Pure type declarations and type-only exports are erased at runtime. + // Pure type declarations, type queries, ambient statements, external re-exports, + // and type-only exports are erased or do not reference local imports at runtime. if ( ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isTypeParameterDeclaration(node) || - (ts.isExportDeclaration(node) && node.isTypeOnly) || + ts.isTypeQueryNode(node) || + ts.isImportTypeNode(node) || + ts.isIndexSignatureDeclaration(node) || + (ts.isStatement(node) && + ts.canHaveModifiers(node) && + ts.getModifiers(node)?.some((m) => m.kind === ts.SyntaxKind.DeclareKeyword)) || + (ts.isExportDeclaration(node) && (node.isTypeOnly || node.moduleSpecifier !== undefined)) || (ts.isExportSpecifier(node) && node.isTypeOnly) ) { return; @@ -101,15 +114,30 @@ export function elideImports( return importNodeRemovals; } - const isUnused = (node: ts.Identifier) => { + /** + * Determines how an imported binding should be handled: + * - `keep`: the binding is still referenced. + * - `value`: the binding is a runtime value that is no longer referenced. + * - `type`: the binding does not exist at runtime and is always elided by TypeScript. + */ + const getRemovalKind = (node: ts.Identifier): 'keep' | 'value' | 'type' => { // Do not remove JSX factory imports if (node.text === compilerOptions.jsxFactory) { - return false; + return 'keep'; } const symbol = typeChecker.getSymbolAtLocation(node); + if (!symbol) { + return 'keep'; + } + + // Note: with `verbatimModuleSyntax`, TypeScript reports an error for imports of non-value aliases + // that do not use a `type` modifier (TS1484/TS1485), so no special handling is needed for it. + if ((symbol.flags & ts.SymbolFlags.Alias) !== 0 && !isValueAlias(symbol, typeChecker)) { + return 'type'; + } - return symbol && !usedSymbols.has(symbol); + return usedSymbols.has(symbol) ? 'keep' : 'value'; }; for (const node of imports) { @@ -122,12 +150,17 @@ export function elideImports( if (namedBindings && ts.isNamespaceImport(namedBindings)) { // "import * as XYZ from 'abc';" - if (isUnused(namedBindings.name)) { + if (getRemovalKind(namedBindings.name) !== 'keep') { importNodeRemovals.add(node); } } else { const specifierNodeRemovals = []; let clausesCount = 0; + // TypeScript skips its own import elision for declarations replaced by a transformer. + // A declaration is therefore only partially rewritten when a runtime value import must be + // removed, or when it was already replaced by a previous transformer. Otherwise, it is left + // untouched so that TypeScript continues to elide any type-only specifiers itself. + let requiresRewrite = !ts.isParseTreeNode(node); // "import { XYZ, ... } from 'abc';" if (namedBindings && ts.isNamedImports(namedBindings)) { @@ -135,7 +168,9 @@ export function elideImports( clausesCount += namedBindings.elements.length; for (const specifier of namedBindings.elements) { - if (specifier.isTypeOnly || isUnused(specifier.name)) { + const removalKind = specifier.isTypeOnly ? 'type' : getRemovalKind(specifier.name); + if (removalKind !== 'keep') { + requiresRewrite ||= removalKind === 'value'; removedClausesCount++; // in case we don't have any more namedImports we should remove the parent ie the {} const nodeToRemove = @@ -150,14 +185,18 @@ export function elideImports( if (node.importClause.name) { clausesCount++; - if (node.importClause.isTypeOnly || isUnused(node.importClause.name)) { + const removalKind = node.importClause.isTypeOnly + ? 'type' + : getRemovalKind(node.importClause.name); + if (removalKind !== 'keep') { + requiresRewrite ||= removalKind === 'value'; specifierNodeRemovals.push(node.importClause.name); } } if (specifierNodeRemovals.length === clausesCount) { importNodeRemovals.add(node); - } else { + } else if (requiresRewrite) { for (const specifierNodeRemoval of specifierNodeRemovals) { importNodeRemovals.add(specifierNodeRemoval); } @@ -167,3 +206,37 @@ export function elideImports( return importNodeRemovals; } + +/** + * Determines whether an import alias resolves to a runtime value. Aliases that only resolve to + * types, or that pass through a type-only import or export declaration anywhere along the alias + * chain, are always elided by TypeScript. + */ +function isValueAlias(symbol: ts.Symbol, typeChecker: ts.TypeChecker): boolean { + const seen = new Set(); + let current = symbol; + + while ((current.flags & ts.SymbolFlags.Alias) !== 0) { + if (seen.has(current)) { + // Circular alias (a TypeScript error) — assume runtime value so we don't over-elide. + return true; + } + seen.add(current); + + if ( + current.declarations?.length && + current.declarations.every(ts.isTypeOnlyImportOrExportDeclaration) + ) { + return false; + } + + const next = typeChecker.getImmediateAliasedSymbol(current); + if (!next) { + // Unresolved alias (e.g., missing module) — assume runtime value so we don't over-elide. + return true; + } + current = next; + } + + return (current.flags & ts.SymbolFlags.Value) !== 0; +} diff --git a/packages/ngtools/webpack/src/transformers/elide_imports_spec.ts b/packages/ngtools/webpack/src/transformers/elide_imports_spec.ts index 8c9f73f6f471..6fe212135a9b 100644 --- a/packages/ngtools/webpack/src/transformers/elide_imports_spec.ts +++ b/packages/ngtools/webpack/src/transformers/elide_imports_spec.ts @@ -79,6 +79,23 @@ describe('elide_imports', () => { 'jsx.ts': ` export function createElement() {} `, + 'mixed.ts': ` + export class MixedService { } + export interface MixedInterface { value: string } + export const enum MixedEnum { A = 1 } + `, + 'generic.ts': ` + export interface Generic { value: T } + `, + 'type-reexport.ts': ` + export type { Service } from './service'; + export { promise } from './const'; + `, + 'type-import-reexport.ts': ` + import type { Service2 } from './service'; + export { Service2 }; + export { take } from './const'; + `, }; it('should remove unused imports', () => { @@ -455,6 +472,79 @@ describe('elide_imports', () => { expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); }); + + it('should elide specifiers used only in non-type interface re-exports, type aliases, type parameters, typeof queries, or ambient declarations', () => { + const input = tags.stripIndent` + import { Decorator } from './decorator'; + import { OnChanges, SimpleChanges } from './type'; + import { Service, Service2 } from './service'; + import { animations, promise, take } from './const'; + + export { SimpleChanges }; + export type ServiceCtor = typeof Service2; + export { type take }; + declare class AmbientSubService extends Service2 {} + + const used = promise; + + @Decorator() + export class Foo { + prop?: typeof animations; + constructor(param: Service) { + } + } + + ${dummyNode} + `; + + const output = tags.stripIndent` + import { __decorate } from "tslib"; + import { Decorator } from './decorator'; + import { promise } from './const'; + + const used = promise; + + let Foo = class Foo { constructor(param) { } }; + Foo = __decorate([ Decorator() ], Foo); + export { Foo }; + `; + + const { program, compilerHost } = createTypescriptContext( + input, + additionalFiles, + true, + extraCompilerOptions, + ); + const result = transformTypescript(undefined, [transformer(program)], program, compilerHost); + + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + + it('should not retain inlined const enum imports when only non-value specifiers are removed from a declaration', () => { + const input = tags.stripIndent` + import { MixedEnum, MixedInterface } from './mixed'; + + export const value = MixedEnum.A; + export let other: MixedInterface | undefined; + + ${dummyNode} + `; + + const output = tags.stripIndent` + export const value = 1 /* MixedEnum.A */; + export let other; + `; + + const { program, compilerHost } = createTypescriptContext( + input, + additionalFiles, + true, + extraCompilerOptions, + ); + const result = transformTypescript(undefined, [transformer(program)], program, compilerHost); + + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); }); it('keeps jsxFactory imports when configured', () => { @@ -506,6 +596,87 @@ describe('elide_imports', () => { expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); }); + it('should remove non-value specifiers from import declarations replaced by a previous transformer', () => { + const input = tags.stripIndent` + import { MixedService, MixedInterface } from './mixed'; + + export const service = MixedService; + export let value: MixedInterface | undefined; + + ${dummyNode} + `; + + const output = tags.stripIndent` + import { MixedService } from './mixed'; + + export const service = MixedService; + export let value; + `; + + // Simulates a previous transformer replacing the import declarations. TypeScript does not + // perform its own import elision for import declarations replaced by a transformer. + const replaceImports = (context: ts.TransformationContext) => (sourceFile: ts.SourceFile) => + ts.visitEachChild( + sourceFile, + (node) => { + if ( + !ts.isImportDeclaration(node) || + !node.importClause?.namedBindings || + !ts.isNamedImports(node.importClause.namedBindings) + ) { + return node; + } + + return context.factory.updateImportDeclaration( + node, + node.modifiers, + context.factory.updateImportClause( + node.importClause, + node.importClause.phaseModifier, + node.importClause.name, + context.factory.updateNamedImports(node.importClause.namedBindings, [ + ...node.importClause.namedBindings.elements, + ]), + ), + node.moduleSpecifier, + node.attributes, + ); + }, + context, + ); + + const { program, compilerHost } = createTypescriptContext(input, additionalFiles, true); + const result = transformTypescript( + undefined, + [replaceImports, transformer(program)], + program, + compilerHost, + ); + + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + + it('should retain used imports from unresolved modules', () => { + const input = tags.stripIndent` + import { missing, unused } from './missing'; + + const used = missing; + + ${dummyNode} + `; + + const output = tags.stripIndent` + import { missing } from './missing'; + + const used = missing; + `; + + const { program, compilerHost } = createTypescriptContext(input, additionalFiles); + const result = transformTypescript(undefined, [transformer(program)], program, compilerHost); + + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + describe('should not elide imports decorator type references when emitDecoratorMetadata is true', () => { const extraCompilerOptions: ts.CompilerOptions = { emitDecoratorMetadata: true, @@ -799,6 +970,179 @@ describe('elide_imports', () => { expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); }); + it('should elide non-value interface imports and typeof queries even when emitDecoratorMetadata is true', () => { + const input = tags.stripIndent` + import { Decorator } from './decorator'; + import { Service } from './service'; + import { OnChanges, SimpleChanges } from './type'; + import { animations, promise } from './const'; + + const used = promise; + + @Decorator() + class Foo implements OnChanges { + @Decorator() + prop?: typeof animations; + + constructor(param: Service, changes: SimpleChanges) { } + ngOnChanges(changes: SimpleChanges) { } + } + + ${dummyNode} + `; + + const output = tags.stripIndent` + import { __decorate, __metadata } from "tslib"; + import { Decorator } from './decorator'; + import { Service } from './service'; + import { promise } from './const'; + + const used = promise; + + let Foo = class Foo { + constructor(param, changes) { } + ngOnChanges(changes) { } + }; + + __decorate([ Decorator(), __metadata("design:type", Object) ], Foo.prototype, "prop", void 0); + Foo = __decorate([ Decorator(), __metadata("design:paramtypes", [Service, Object]) ], Foo); + `; + + const { program, compilerHost } = createTypescriptContext( + input, + additionalFiles, + true, + extraCompilerOptions, + ); + const result = transformTypescript(undefined, [transformer(program)], program, compilerHost); + + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + + it('should not retain value imports that TypeScript would elide when only non-value specifiers are removed from a declaration', () => { + const input = tags.stripIndent` + import { MixedService, MixedInterface } from './mixed'; + + export class Foo { + service?: MixedService; + value?: MixedInterface; + } + + ${dummyNode} + `; + + const output = tags.stripIndent` + export class Foo { } + `; + + const { program, compilerHost } = createTypescriptContext( + input, + additionalFiles, + true, + extraCompilerOptions, + ); + const result = transformTypescript(undefined, [transformer(program)], program, compilerHost); + + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + + it('should not retain inlined const enum imports when only non-value specifiers are removed from a declaration', () => { + const input = tags.stripIndent` + import { MixedEnum, MixedInterface } from './mixed'; + + export const value = MixedEnum.A; + export let other: MixedInterface | undefined; + + ${dummyNode} + `; + + const output = tags.stripIndent` + export const value = 1 /* MixedEnum.A */; + export let other; + `; + + const { program, compilerHost } = createTypescriptContext( + input, + additionalFiles, + true, + extraCompilerOptions, + ); + const result = transformTypescript(undefined, [transformer(program)], program, compilerHost); + + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + + it('should elide imports that resolve through type-only re-exports', () => { + const input = tags.stripIndent` + import { Decorator } from './decorator'; + import { Service, promise } from './type-reexport'; + import { Service2, take } from './type-import-reexport'; + + @Decorator() + export class Foo { + constructor(param: Service, param2: Service2) { } + } + + const remove = [promise, take]; + `; + + const output = tags.stripIndent` + import { __decorate, __metadata } from "tslib"; + import { Decorator } from './decorator'; + + let Foo = class Foo { constructor(param, param2) { } }; + Foo = __decorate([ Decorator(), __metadata("design:paramtypes", [Function, Function]) ], Foo); + export { Foo }; + `; + + const { program, compilerHost } = createTypescriptContext( + input, + additionalFiles, + true, + extraCompilerOptions, + ); + const result = transformTypescript(undefined, [transformer(program)], program, compilerHost); + + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + + it('should elide imports only used in import type arguments or index signatures', () => { + const input = tags.stripIndent` + import { Decorator } from './decorator'; + import { Service, Service2 } from './service'; + + export class IndexSignatureHolder { + [key: string]: Service; + } + + export class Foo { + @Decorator() + prop?: import('./generic').Generic; + } + + const remove = Service2; + `; + + const output = tags.stripIndent` + import { __decorate, __metadata } from "tslib"; + import { Decorator } from './decorator'; + + export class IndexSignatureHolder { } + export class Foo { } + __decorate([ Decorator(), __metadata("design:type", Object) ], Foo.prototype, "prop", void 0); + `; + + const { program, compilerHost } = createTypescriptContext( + input, + additionalFiles, + true, + extraCompilerOptions, + ); + const result = transformTypescript(undefined, [transformer(program)], program, compilerHost); + + expect(tags.oneLine`${result}`).toEqual(tags.oneLine`${output}`); + }); + describe('NGTSC - ShorthandPropertyAssignment to PropertyAssignment', () => { const transformShorthandPropertyAssignment = ( context: ts.TransformationContext, From 19752d3bca13be1c4378970a06ca23fc258a3c75 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:32:03 -0400 Subject: [PATCH 2/2] fix(@angular/build): elide non-value aliases and remaining type-only AST constructs Follow up to #34582 to apply the same import elision fixes made to `@ngtools/webpack` to the `replaceBootstrap` transformer used by the application builder: - Elide imported alias symbols that do not resolve to a runtime value (or traverse a type-only alias declaration), handling non-`type` interface re-exports (`export { Foo }`) and interface parameter types under `emitDecoratorMetadata`. - Only rewrite a partially elided import declaration when a runtime value import must be removed or when the declaration was already replaced by a previous transformer, so TypeScript's own import elision continues to apply to all other declarations. - Skip all non-`ExpressionWithTypeArguments` `TypeNode`s when `emitDecoratorMetadata` is disabled, and unconditionally skip `TypeQueryNode` (`typeof X`), `ImportTypeNode`, `IndexSignatureDeclaration`, ambient `declare` statements, and external `export ... from '...'` declarations. - Recursively visit child AST nodes when applying import removals in `replaceBootstrap` so individual `ImportSpecifier`s are removed when `platformBrowserDynamic` shares an import with other used runtime exports. --- .../transformers/jit-bootstrap-transformer.ts | 107 ++++++++++++++++-- .../jit-bootstrap-transformer_spec.ts | 103 +++++++++++++++++ 2 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer_spec.ts diff --git a/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer.ts b/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer.ts index 68f572f2e8b6..4dbaf010f3a2 100644 --- a/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer.ts +++ b/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer.ts @@ -71,7 +71,17 @@ export function replaceBootstrap( if (removals.size > 0) { updatedSourceFile = ts.visitEachChild( updatedSourceFile, - (node) => (removals.has(node) ? undefined : node), + function visitForRemoval(node): ts.Node | undefined { + if (removals.has(node)) { + return undefined; + } + + return ts.isImportDeclaration(node) || + ts.isImportClause(node) || + ts.isNamedImports(node) + ? ts.visitEachChild(node, visitForRemoval, context) + : node; + }, context, ); } @@ -137,17 +147,30 @@ export function elideImports( return; } - // Type reference imports do not need to be emitted when emitDecoratorMetadata is disabled. - if (ts.isTypeReferenceNode(node) && !compilerOptions.emitDecoratorMetadata) { + // Type nodes do not need to be emitted when `emitDecoratorMetadata` is disabled. + // `ExpressionWithTypeArguments` is excluded as it also represents runtime values such as + // `class ... extends Base` heritage clauses and instantiation expressions (`Map`). + if ( + ts.isTypeNode(node) && + !ts.isExpressionWithTypeArguments(node) && + !compilerOptions.emitDecoratorMetadata + ) { return; } - // Pure type declarations and type-only exports are erased at runtime. + // Pure type declarations, type queries, ambient statements, external re-exports, + // and type-only exports are erased or do not reference local imports at runtime. if ( ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isTypeParameterDeclaration(node) || - (ts.isExportDeclaration(node) && node.isTypeOnly) || + ts.isTypeQueryNode(node) || + ts.isImportTypeNode(node) || + ts.isIndexSignatureDeclaration(node) || + (ts.isStatement(node) && + ts.canHaveModifiers(node) && + ts.getModifiers(node)?.some((m) => m.kind === ts.SyntaxKind.DeclareKeyword)) || + (ts.isExportDeclaration(node) && (node.isTypeOnly || node.moduleSpecifier !== undefined)) || (ts.isExportSpecifier(node) && node.isTypeOnly) ) { return; @@ -184,15 +207,30 @@ export function elideImports( return importNodeRemovals; } - const isUnused = (node: ts.Identifier) => { + /** + * Determines how an imported binding should be handled: + * - `keep`: the binding is still referenced. + * - `value`: the binding is a runtime value that is no longer referenced. + * - `type`: the binding does not exist at runtime and is always elided by TypeScript. + */ + const getRemovalKind = (node: ts.Identifier): 'keep' | 'value' | 'type' => { // Do not remove JSX factory imports if (node.text === compilerOptions.jsxFactory) { - return false; + return 'keep'; } const symbol = typeChecker.getSymbolAtLocation(node); + if (!symbol) { + return 'keep'; + } + + // Note: with `verbatimModuleSyntax`, TypeScript reports an error for imports of non-value aliases + // that do not use a `type` modifier (TS1484/TS1485), so no special handling is needed for it. + if ((symbol.flags & ts.SymbolFlags.Alias) !== 0 && !isValueAlias(symbol, typeChecker)) { + return 'type'; + } - return symbol && !usedSymbols.has(symbol); + return usedSymbols.has(symbol) ? 'keep' : 'value'; }; for (const node of imports) { @@ -205,12 +243,17 @@ export function elideImports( if (namedBindings && ts.isNamespaceImport(namedBindings)) { // "import * as XYZ from 'abc';" - if (isUnused(namedBindings.name)) { + if (getRemovalKind(namedBindings.name) !== 'keep') { importNodeRemovals.add(node); } } else { const specifierNodeRemovals = []; let clausesCount = 0; + // TypeScript skips its own import elision for declarations replaced by a transformer. + // A declaration is therefore only partially rewritten when a runtime value import must be + // removed, or when it was already replaced by a previous transformer. Otherwise, it is left + // untouched so that TypeScript continues to elide any type-only specifiers itself. + let requiresRewrite = !ts.isParseTreeNode(node); // "import { XYZ, ... } from 'abc';" if (namedBindings && ts.isNamedImports(namedBindings)) { @@ -218,7 +261,9 @@ export function elideImports( clausesCount += namedBindings.elements.length; for (const specifier of namedBindings.elements) { - if (specifier.isTypeOnly || isUnused(specifier.name)) { + const removalKind = specifier.isTypeOnly ? 'type' : getRemovalKind(specifier.name); + if (removalKind !== 'keep') { + requiresRewrite ||= removalKind === 'value'; removedClausesCount++; // in case we don't have any more namedImports we should remove the parent ie the {} const nodeToRemove = @@ -233,14 +278,18 @@ export function elideImports( if (node.importClause.name) { clausesCount++; - if (node.importClause.isTypeOnly || isUnused(node.importClause.name)) { + const removalKind = node.importClause.isTypeOnly + ? 'type' + : getRemovalKind(node.importClause.name); + if (removalKind !== 'keep') { + requiresRewrite ||= removalKind === 'value'; specifierNodeRemovals.push(node.importClause.name); } } if (specifierNodeRemovals.length === clausesCount) { importNodeRemovals.add(node); - } else { + } else if (requiresRewrite) { for (const specifierNodeRemoval of specifierNodeRemovals) { importNodeRemovals.add(specifierNodeRemoval); } @@ -250,3 +299,37 @@ export function elideImports( return importNodeRemovals; } + +/** + * Determines whether an import alias resolves to a runtime value. Aliases that only resolve to + * types, or that pass through a type-only import or export declaration anywhere along the alias + * chain, are always elided by TypeScript. + */ +function isValueAlias(symbol: ts.Symbol, typeChecker: ts.TypeChecker): boolean { + const seen = new Set(); + let current = symbol; + + while ((current.flags & ts.SymbolFlags.Alias) !== 0) { + if (seen.has(current)) { + // Circular alias (a TypeScript error) — assume runtime value so we don't over-elide. + return true; + } + seen.add(current); + + if ( + current.declarations?.length && + current.declarations.every(ts.isTypeOnlyImportOrExportDeclaration) + ) { + return false; + } + + const next = typeChecker.getImmediateAliasedSymbol(current); + if (!next) { + // Unresolved alias (e.g., missing module) — assume runtime value so we don't over-elide. + return true; + } + current = next; + } + + return (current.flags & ts.SymbolFlags.Value) !== 0; +} diff --git a/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer_spec.ts b/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer_spec.ts new file mode 100644 index 000000000000..cb81319e0db3 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer_spec.ts @@ -0,0 +1,103 @@ +/** + * @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.dev/license + */ + +import ts from 'typescript'; +import { replaceBootstrap } from './jit-bootstrap-transformer'; + +describe('replaceBootstrap', () => { + function transformCode( + files: Record, + entryFile = '/project/src/main.ts', + ): string { + const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noLib: true, + }; + + const host: ts.CompilerHost = { + getSourceFile: (fileName, languageVersion) => + files[fileName] !== undefined + ? ts.createSourceFile(fileName, files[fileName], languageVersion, true) + : undefined, + getDefaultLibFileName: () => '/lib.d.ts', + writeFile: () => undefined, + getCurrentDirectory: () => '/project', + getCanonicalFileName: (fileName) => fileName, + useCaseSensitiveFileNames: () => true, + getNewLine: () => '\n', + fileExists: (fileName) => files[fileName] !== undefined, + readFile: (fileName) => files[fileName], + }; + + const program = ts.createProgram([entryFile], compilerOptions, host); + let output = ''; + const sourceFile = program.getSourceFile(entryFile); + expect(sourceFile).toBeDefined(); + + program.emit( + sourceFile, + (_fileName, text) => { + output = text; + }, + undefined, + false, + { before: [replaceBootstrap(() => program.getTypeChecker())] }, + ); + + return output; + } + + const platformBrowserDynamicStub = ` + export declare function platformBrowserDynamic(extraProviders?: unknown[]): { + bootstrapModule(moduleType: unknown): Promise; + }; + export const VERSION = '0.0.0'; + export interface PlatformHelper {} + `; + + it('replaces platformBrowserDynamic and removes its import when no other specifiers remain', () => { + const output = transformCode({ + '/project/node_modules/@angular/platform-browser-dynamic/index.d.ts': + platformBrowserDynamicStub, + '/project/src/app.module.ts': 'export class AppModule {}', + '/project/src/main.ts': ` + import { platformBrowserDynamic, PlatformHelper } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app.module'; + + export { PlatformHelper }; + platformBrowserDynamic().bootstrapModule(AppModule); + `, + }); + + expect(output).toContain(`import * as __NgCli_bootstrap_1 from "@angular/platform-browser";`); + expect(output).toContain(`__NgCli_bootstrap_1.platformBrowser().bootstrapModule(AppModule);`); + expect(output).not.toContain('@angular/platform-browser-dynamic'); + }); + + it('removes only platformBrowserDynamic specifier when another runtime export from the same module is used', () => { + const output = transformCode({ + '/project/node_modules/@angular/platform-browser-dynamic/index.d.ts': + platformBrowserDynamicStub, + '/project/src/app.module.ts': 'export class AppModule {}', + '/project/src/main.ts': ` + import { platformBrowserDynamic, VERSION, PlatformHelper } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app.module'; + + export type { PlatformHelper }; + export const version = VERSION; + platformBrowserDynamic().bootstrapModule(AppModule); + `, + }); + + expect(output).toContain(`import { VERSION } from '@angular/platform-browser-dynamic';`); + expect(output).not.toContain('PlatformHelper'); + expect(output).toContain(`__NgCli_bootstrap_1.platformBrowser().bootstrapModule(AppModule);`); + }); +});