Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Comment thread
clydin marked this conversation as resolved.
Resolved
context,
);
}
Expand Down Expand Up @@ -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<T>` heritage clauses and instantiation expressions (`Map<string, T>`).
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;
Expand Down Expand Up @@ -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) {
Expand All @@ -205,20 +243,27 @@ 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)) {
let removedClausesCount = 0;
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 =
Expand All @@ -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);
}
Expand All @@ -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<ts.Symbol>();
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;
}
Original file line number Diff line number Diff line change
@@ -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<string, string>,
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<unknown>;
};
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);`);
});
});
12 changes: 11 additions & 1 deletion packages/ngtools/webpack/src/ivy/transformation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Comment thread
clydin marked this conversation as resolved.
Resolved
context,
);
}
Expand Down
Loading
Loading