From 9165ee56e439341c1d551da1cee123014e9c414d Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:29:36 +0000 Subject: [PATCH 1/2] perf(@angular/build): optimize library dts bundling and enable dts tree-shaking - Enable tree-shaking (`treeshake: isDts`) for `.d.ts` bundles in `executeMultiBundle` so unexported internal types and unused namespace declarations are stripped while preserving modules with `declare global` or `declare module` augmentations. - Cache the `rolldown-plugin-dts` module import promise and invoke `createFakeJsPlugin` directly in `bundleAllDts` instead of calling `dts({ dtsInput: true, ... })`, eliminating redundant per-build `oxc-resolver` (`ResolverFactory`) instantiation. - Disable Rolldown `tsconfig` auto-discovery (`tsconfig: false`) and plugin timing instrumentation (`checks: { circularDependency: false, pluginTimings: false }`) in `executeMultiBundle`. --- .../src/builders/library/pipeline/bundler.ts | 62 ++++---- .../builders/library/pipeline/bundler_spec.ts | 132 ++++++++++++++++++ 2 files changed, 166 insertions(+), 28 deletions(-) diff --git a/packages/angular/build/src/builders/library/pipeline/bundler.ts b/packages/angular/build/src/builders/library/pipeline/bundler.ts index ff67f7ed8237..a7300523d0a9 100644 --- a/packages/angular/build/src/builders/library/pipeline/bundler.ts +++ b/packages/angular/build/src/builders/library/pipeline/bundler.ts @@ -19,6 +19,28 @@ import { createMemoryOutputFile, } from './utils'; +type RolldownPluginDtsModule = typeof import('rolldown-plugin-dts', { + with: { 'resolution-mode': 'import' }, +}); + +let rolldownPluginDtsPromise: Promise | undefined; + +/** + * Dynamically loads the `rolldown-plugin-dts` module after ensuring that + * Rolldown's native binding has been initialized via `loadRolldown()`. + * + * @returns A promise resolving to the `rolldown-plugin-dts` module namespace. + */ +function loadRolldownPluginDts(): Promise { + return (rolldownPluginDtsPromise ??= (async () => { + // Ensure Rolldown's native binding is initialized via `loadRolldown()` before evaluating + // `rolldown-plugin-dts`, which has top-level imports of `rolldown/experimental`. + await loadRolldown(); + + return import('rolldown-plugin-dts' as string) as Promise; + })()); +} + /** * Output of the entry point bundling process. */ @@ -519,10 +541,11 @@ async function executeMultiBundle( context: 'this', input, cwd: workspaceRoot, + tsconfig: false, plugins, - treeshake: false, + treeshake: isDts, resolve: { symlinks: !preserveSymlinks }, - checks: { circularDependency: false }, + checks: { circularDependency: false, pluginTimings: false }, onLog(level, log) { // Ignore specified warning codes if (level === 'warn') { @@ -608,39 +631,22 @@ async function bundleAllDts( }; } - // Ensure Rolldown's native binding is initialized via `loadRolldown()` before evaluating - // `rolldown-plugin-dts`, which has top-level imports of `rolldown/experimental`. - await loadRolldown(); - const { dts } = (await import('rolldown-plugin-dts' as string)) as typeof import( - 'rolldown-plugin-dts', - { with: { 'resolution-mode': 'import' } } - ); + const { createFakeJsPlugin } = await loadRolldownPluginDts(); const dtsSourcemap = !!options.declarationMap; - // Filter out `rolldown-plugin-dts:resolver` because all `.d.ts` files are already emitted - // in-memory by the Angular/TypeScript compilation and resolved via `createMemoryFileLoaderPlugin`. - // The default `rolldown-plugin-dts:resolver` plugin performs filesystem resolution (`oxc-resolver`) - // and calls `this.load()` on on-disk `.ts` source files, which is unnecessary and causes a - // significant performance regression across multi-entry builds. - const rawDtsPlugins = dts({ - dtsInput: true, - tsconfig: false, + // Directly instantiate `createFakeJsPlugin` instead of calling `dts({ dtsInput: true, ... })` + // to avoid `createDtsResolvePlugin` unconditionally instantiating a native `oxc-resolver` + // (`ResolverFactory`) on every build. All `.d.ts` files are already emitted in-memory by the + // Angular/TypeScript compilation and resolved via `createMemoryFileLoaderPlugin`. + const fakeJsPlugin = createFakeJsPlugin({ sourcemap: dtsSourcemap, - generator: 'oxc', + cjsDefault: false, + sideEffects: false, }); - const dtsPlugins = rawDtsPlugins.filter( - (plugin) => plugin.name !== 'rolldown-plugin-dts:resolver', - ); - - assert( - dtsPlugins.length < rawDtsPlugins.length, - 'Expected "rolldown-plugin-dts:resolver" plugin to be present in rolldown-plugin-dts.', - ); - return executeMultiBundle( resolveEntryInputMap(entryPoints, true), - [createMemoryFileLoaderPlugin(dtsFiles, DTS_EXTENSIONS, dtsSourcemap), ...dtsPlugins], + [createMemoryFileLoaderPlugin(dtsFiles, DTS_EXTENSIONS, dtsSourcemap), fakeJsPlugin], 'd.ts', options, ); diff --git a/packages/angular/build/src/builders/library/pipeline/bundler_spec.ts b/packages/angular/build/src/builders/library/pipeline/bundler_spec.ts index 09e3fa5e0619..5dc90cd9650f 100644 --- a/packages/angular/build/src/builders/library/pipeline/bundler_spec.ts +++ b/packages/angular/build/src/builders/library/pipeline/bundler_spec.ts @@ -485,4 +485,136 @@ export declare const SECONDARY: SharedItem;`, ); }); }); + + describe('tree-shaking and comment preservation', () => { + it('should tree-shake unexported internal types in .d.ts when using export *', async () => { + const primaryEntry = createEntryPoint('.', 'my-lib', `${projectRoot}/src/public-api.ts`); + const secondaryEntry = createEntryPoint( + 'secondary', + 'my-lib-secondary', + `${projectRoot}/secondary/src/public-api.ts`, + ); + + const esmFiles = new Map([ + [`${projectRoot}/src/public-api.js`, 'export const a = 1;'], + [`${projectRoot}/secondary/src/public-api.js`, 'export const b = 2;'], + ]); + + const dtsFiles = new Map([ + [ + `${projectRoot}/src/internal.d.ts`, + 'export interface PublicA { id: string; }\ninterface UnexportedInternalA { secret: number; }', + ], + [ + `${projectRoot}/src/public-api.d.ts`, + 'export * from "./internal.js";\nexport declare const a = 1;', + ], + [ + `${projectRoot}/secondary/src/internal.d.ts`, + 'export interface PublicB { flag: boolean; }\ninterface UnexportedInternalB { hidden: string; }', + ], + [ + `${projectRoot}/secondary/src/public-api.d.ts`, + 'export * from "./internal.js";\nexport declare const b = 2;', + ], + ]); + + const output = await bundleEntryPoints( + [ + { entryPoint: primaryEntry, hasEsmChanges: true, hasDtsChanges: true }, + { entryPoint: secondaryEntry, hasEsmChanges: true, hasDtsChanges: true }, + ], + esmFiles, + dtsFiles, + createOptions([primaryEntry, secondaryEntry]), + ); + + expect(output.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'types/my-lib.d.ts', + contents: jasmine.stringContaining('PublicA'), + }), + ); + expect(output.filesToEmit).not.toContain( + jasmine.objectContaining({ + path: 'types/my-lib.d.ts', + contents: jasmine.stringContaining('UnexportedInternalA'), + }), + ); + + expect(output.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'types/my-lib-secondary.d.ts', + contents: jasmine.stringContaining('PublicB'), + }), + ); + expect(output.filesToEmit).not.toContain( + jasmine.objectContaining({ + path: 'types/my-lib-secondary.d.ts', + contents: jasmine.stringContaining('UnexportedInternalB'), + }), + ); + }); + + it('should preserve declare global and module augmentations in side-effect .d.ts imports', async () => { + const primaryEntry = createEntryPoint(); + + const esmFiles = new Map([ + [`${projectRoot}/src/public-api.js`, 'export const a = 1;'], + ]); + const dtsFiles = new Map([ + [ + `${projectRoot}/src/globals.d.ts`, + 'export {};\ndeclare global {\n interface Window {\n myCustomGlobal: boolean;\n }\n}\n', + ], + [ + `${projectRoot}/src/augment.d.ts`, + "declare module '@angular/core' {\n interface Component {\n augmentedProp?: string;\n }\n}\nexport {};\n", + ], + [ + `${projectRoot}/src/public-api.d.ts`, + 'import "./globals.js";\nexport * from "./augment.js";\nexport declare const a = 1;\n', + ], + ]); + + const output = await bundleEntryPoints( + [{ entryPoint: primaryEntry, hasEsmChanges: true, hasDtsChanges: true }], + esmFiles, + dtsFiles, + createOptions([primaryEntry]), + ); + + const dtsFile = output.filesToEmit.find((f) => f.path === 'types/my-lib.d.ts'); + expect(dtsFile?.contents).toContain('myCustomGlobal'); + expect(dtsFile?.contents).toContain('augmentedProp'); + }); + + it('should preserve legal comments in emitted FESM2022 .mjs bundles', async () => { + const primaryEntry = createEntryPoint(); + + const esmFiles = new Map([ + [ + `${projectRoot}/src/public-api.js`, + '/*! @license MIT Custom Header */\nexport const VALUE = 42;', + ], + ]); + const dtsFiles = new Map([ + [`${projectRoot}/src/public-api.d.ts`, 'export declare const VALUE = 42;'], + ]); + + const output = await bundleEntryPoints( + [{ entryPoint: primaryEntry, hasEsmChanges: true, hasDtsChanges: true }], + esmFiles, + dtsFiles, + createOptions([primaryEntry]), + ); + + expect(output.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'fesm2022/my-lib.mjs', + contents: jasmine.stringContaining('/*! @license MIT Custom Header */'), + }), + ); + }); + }); }); From b23690ee1d4e8684930ad43c0d14092b6f745279 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:14:45 +0000 Subject: [PATCH 2/2] fixup! perf(@angular/build): optimize library dts bundling and enable dts tree-shaking --- .../build/src/builders/library/pipeline/bundler.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/angular/build/src/builders/library/pipeline/bundler.ts b/packages/angular/build/src/builders/library/pipeline/bundler.ts index a7300523d0a9..bcf2486565f7 100644 --- a/packages/angular/build/src/builders/library/pipeline/bundler.ts +++ b/packages/angular/build/src/builders/library/pipeline/bundler.ts @@ -159,15 +159,9 @@ function hasNewlyAddedModules( assert(bundle, `Expected bundle output for entry point "${ep.bundleName}".`); const prevIds = getPreviousModuleIds(ep); - if (!prevIds) { + if (!prevIds || !bundle.moduleIds.isSubsetOf(prevIds)) { return true; } - - for (const id of bundle.moduleIds) { - if (!prevIds.has(id)) { - return true; - } - } } return false;