From 4bb0cce38353238babb85abdb317b26dadf8e71c Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 24 Sep 2025 07:29:04 +0000 Subject: [PATCH 1/2] fix(@angular/build): mark `InjectionToken` as pure for improved tree-shaking `new InjectionToken(...)` is not considered pure by default by build tools, which prevents it from being tree-shaken when unused. This can lead to unused services being included in production bundles if they are provided as a default value for a token. This change marks `new InjectionToken(...)` calls as pure. The `InjectionToken` constructor is side-effect free; its only purpose is to create a token for the dependency injection system. Marking it as pure is safe and allows build tools like esbuild to tree-shake unused tokens and their dependencies. Closes #31270 --- .../babel/plugins/pure-toplevel-functions.ts | 39 ++++++++++++++++--- .../plugins/pure-toplevel-functions_spec.ts | 34 +++++++++++++++- .../esbuild/javascript-transformer-worker.ts | 20 +++++----- 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/packages/angular/build/src/tools/babel/plugins/pure-toplevel-functions.ts b/packages/angular/build/src/tools/babel/plugins/pure-toplevel-functions.ts index ce47977a74e3..5aec104a38d2 100644 --- a/packages/angular/build/src/tools/babel/plugins/pure-toplevel-functions.ts +++ b/packages/angular/build/src/tools/babel/plugins/pure-toplevel-functions.ts @@ -6,12 +6,17 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { PluginObj } from '@babel/core'; +import type { NodePath, PluginObj, PluginPass, types } from '@babel/core'; import annotateAsPure from '@babel/helper-annotate-as-pure'; import * as tslib from 'tslib'; /** - * A cached set of TypeScript helper function names used by the helper name matcher utility function. + * A set of constructor names that are considered to be side-effect free. + */ +const sideEffectFreeConstructors = new Set(['InjectionToken']); + +/** + * A set of TypeScript helper function names used by the helper name matcher utility function. */ const tslibHelpers = new Set(Object.keys(tslib).filter((h) => h.startsWith('__'))); @@ -44,15 +49,23 @@ function isBabelHelperName(name: string): boolean { return babelHelpers.has(name); } +interface ExtendedPluginPass extends PluginPass { + opts: { topLevelSafeMode?: boolean }; +} + /** * A babel plugin factory function for adding the PURE annotation to top-level new and call expressions. - * * @returns A babel plugin object instance. */ export default function (): PluginObj { return { visitor: { - CallExpression(path) { + CallExpression(path: NodePath, state: ExtendedPluginPass) { + const { topLevelSafeMode = false } = state.opts; + if (topLevelSafeMode) { + return; + } + // If the expression has a function parent, it is not top-level if (path.getFunctionParent()) { return; @@ -65,6 +78,7 @@ export default function (): PluginObj { ) { return; } + // Do not annotate TypeScript helpers emitted by the TypeScript compiler or Babel helpers. // They are intended to cause side effects. if ( @@ -76,9 +90,22 @@ export default function (): PluginObj { annotateAsPure(path); }, - NewExpression(path) { + NewExpression(path: NodePath, state: ExtendedPluginPass) { // If the expression has a function parent, it is not top-level - if (!path.getFunctionParent()) { + if (path.getFunctionParent()) { + return; + } + + const { topLevelSafeMode = false } = state.opts; + + if (!topLevelSafeMode) { + annotateAsPure(path); + + return; + } + + const callee = path.get('callee'); + if (callee.isIdentifier() && sideEffectFreeConstructors.has(callee.node.name)) { annotateAsPure(path); } }, diff --git a/packages/angular/build/src/tools/babel/plugins/pure-toplevel-functions_spec.ts b/packages/angular/build/src/tools/babel/plugins/pure-toplevel-functions_spec.ts index 891f794f43d5..0966a67d068a 100644 --- a/packages/angular/build/src/tools/babel/plugins/pure-toplevel-functions_spec.ts +++ b/packages/angular/build/src/tools/babel/plugins/pure-toplevel-functions_spec.ts @@ -7,23 +7,24 @@ */ import { transformSync } from '@babel/core'; -// eslint-disable-next-line import/no-extraneous-dependencies import { format } from 'prettier'; import pureTopLevelPlugin from './pure-toplevel-functions'; function testCase({ input, expected, + options, }: { input: string; expected: string; + options?: { topLevelSafeMode: boolean }; }): jasmine.ImplementationCallback { return async () => { const result = transformSync(input, { configFile: false, babelrc: false, compact: true, - plugins: [pureTopLevelPlugin], + plugins: [[pureTopLevelPlugin, options]], }); if (!result?.code) { fail('Expected babel to return a transform result.'); @@ -152,4 +153,33 @@ describe('pure-toplevel-functions Babel plugin', () => { }; `), ); + + describe('topLevelSafeMode: true', () => { + it( + 'annotates top-level `new InjectionToken` expressions', + testCase({ + input: `const result = new InjectionToken('abc');`, + expected: `const result = /*#__PURE__*/ new InjectionToken('abc');`, + options: { topLevelSafeMode: true }, + }), + ); + + it( + 'does not annotate other top-level `new` expressions', + testCase({ + input: 'const result = new SomeClass();', + expected: 'const result = new SomeClass();', + options: { topLevelSafeMode: true }, + }), + ); + + it( + 'does not annotate top-level function calls', + testCase({ + input: 'const result = someCall();', + expected: 'const result = someCall();', + options: { topLevelSafeMode: true }, + }), + ); + }); }); diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts index 8fa551b38ba2..c9e882850a64 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts @@ -78,21 +78,19 @@ async function transformWithBabel( } if (options.advancedOptimizations) { - const sideEffectFree = options.sideEffects === false; - const safeAngularPackage = - sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename); - const { adjustStaticMembers, adjustTypeScriptEnums, elideAngularMetadata, markTopLevelPure } = await import('../babel/plugins'); - if (safeAngularPackage) { - plugins.push(markTopLevelPure); - } + const sideEffectFree = options.sideEffects === false; + const safeAngularPackage = + sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename); - plugins.push(elideAngularMetadata, adjustTypeScriptEnums, [ - adjustStaticMembers, - { wrapDecorators: sideEffectFree }, - ]); + plugins.push( + [markTopLevelPure, { topLevelSafeMode: !safeAngularPackage }], + elideAngularMetadata, + adjustTypeScriptEnums, + [adjustStaticMembers, { wrapDecorators: sideEffectFree }], + ); } // If no additional transformations are needed, return the data directly From ad5154d01b8e7cb5d56445eeb88bec19ee1a3a09 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 24 Sep 2025 07:29:23 +0000 Subject: [PATCH 2/2] fix(@angular-devkit/build-angular): mark `InjectionToken` as pure for improved tree-shaking `new InjectionToken(...)` is not considered pure by default by build tools, which prevents it from being tree-shaken when unused. This can lead to unused services being included in production bundles if they are provided as a default value for a token. This change marks `new InjectionToken(...)` calls as pure. The `InjectionToken` constructor is side-effect free; its only purpose is to create a token for the dependency injection system. Marking it as pure is safe and allows build tools like esbuild to tree-shake unused tokens and their dependencies. Closes #31270 --- .../src/tools/babel/presets/application.ts | 16 +++++++--------- .../src/tools/babel/webpack-loader.ts | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/tools/babel/presets/application.ts b/packages/angular_devkit/build_angular/src/tools/babel/presets/application.ts index 83ec20a0a8ea..5810269ad386 100644 --- a/packages/angular_devkit/build_angular/src/tools/babel/presets/application.ts +++ b/packages/angular_devkit/build_angular/src/tools/babel/presets/application.ts @@ -57,7 +57,7 @@ export interface ApplicationPresetOptions { inputSourceMap: unknown; }; optimize?: { - pureTopLevel: boolean; + topLevelSafeMode: boolean; wrapDecorators: boolean; }; @@ -220,14 +220,12 @@ export default function (api: unknown, options: ApplicationPresetOptions) { elideAngularMetadata, markTopLevelPure, } = require('@angular/build/private'); - if (options.optimize.pureTopLevel) { - plugins.push(markTopLevelPure); - } - - plugins.push(elideAngularMetadata, adjustTypeScriptEnums, [ - adjustStaticMembers, - { wrapDecorators: options.optimize.wrapDecorators }, - ]); + plugins.push( + [markTopLevelPure, { topLevelSafeMode: options.optimize.topLevelSafeMode }], + elideAngularMetadata, + adjustTypeScriptEnums, + [adjustStaticMembers, { wrapDecorators: options.optimize.wrapDecorators }], + ); } if (options.instrumentCode) { diff --git a/packages/angular_devkit/build_angular/src/tools/babel/webpack-loader.ts b/packages/angular_devkit/build_angular/src/tools/babel/webpack-loader.ts index a3e0746d8ccc..71e0188853d2 100644 --- a/packages/angular_devkit/build_angular/src/tools/babel/webpack-loader.ts +++ b/packages/angular_devkit/build_angular/src/tools/babel/webpack-loader.ts @@ -138,7 +138,7 @@ export default custom(() => { customOptions.optimize = { // Angular packages provide additional tested side effects guarantees and can use // otherwise unsafe optimizations. (@angular/platform-server/init) however has side-effects. - pureTopLevel: AngularPackage && sideEffectFree, + topLevelSafeMode: !(AngularPackage && sideEffectFree), // JavaScript modules that are marked as side effect free are considered to have // no decorators that contain non-local effects. wrapDecorators: sideEffectFree,