diff --git a/packages/angular/build/src/builders/library/pipeline/bundler.ts b/packages/angular/build/src/builders/library/pipeline/bundler.ts index eccd4cd04e12..ce5a61f8e051 100644 --- a/packages/angular/build/src/builders/library/pipeline/bundler.ts +++ b/packages/angular/build/src/builders/library/pipeline/bundler.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import assert from 'node:assert'; import path from 'node:path'; import type { OutputChunk, Plugin, RolldownOutput, RolldownPluginOption } from 'rolldown'; import { loadRolldown } from '../../../utils/load-rolldown'; @@ -19,6 +18,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. */ @@ -337,10 +358,11 @@ async function executeMultiBundle( context: 'this', input, cwd: workspaceRoot, + tsconfig: false, plugins, - treeshake: false, + treeshake: isDts ? { moduleSideEffects: false } : false, resolve: { symlinks: !preserveSymlinks }, - checks: { circularDependency: false }, + checks: { circularDependency: false, pluginTimings: false }, onLog(level, log) { // Ignore specified warning codes if (level === 'warn') { @@ -426,39 +448,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 new file mode 100644 index 000000000000..8e8522a26e19 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/bundler_spec.ts @@ -0,0 +1,165 @@ +/** + * @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 type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../types'; +import { bundleEntryPoints } from './bundler'; + +describe('bundleEntryPoints', () => { + const workspaceRoot = '/workspace/my-lib'; + + function createOptions(entryPoints: Map): NormalizedLibraryOptions { + return { + workspaceRoot, + projectRoot: workspaceRoot, + packageName: 'my-lib', + packageJson: { name: 'my-lib' }, + outputPath: `${workspaceRoot}/dist`, + deleteOutputPath: true, + packageJsonPath: `${workspaceRoot}/package.json`, + tsConfigPath: `${workspaceRoot}/tsconfig.lib.json`, + entryPoints, + inlineStyleLanguage: 'css', + styleIncludePaths: [], + assets: [], + compilationMode: 'partial', + declarationMap: false, + allowedNonPeerDependencies: [], + keepLifecycleScripts: false, + watch: false, + preserveSymlinks: false, + progress: false, + colors: false, + cacheOptions: { + enabled: false, + basePath: '', + path: '', + cacheId: '', + } as unknown as NormalizedLibraryOptions['cacheOptions'], + }; + } + + it('should tree-shake unexported internal types in .d.ts when using export *', async () => { + const primaryEntry: NormalizedEntryPoint = { + subpath: '.', + name: 'my-lib', + displayName: 'my-lib', + bundleName: 'my-lib', + entryFilePath: `${workspaceRoot}/src/public-api.ts`, + isPrimary: true, + }; + const secondaryEntry: NormalizedEntryPoint = { + subpath: './secondary', + name: 'my-lib/secondary', + displayName: 'my-lib/secondary', + bundleName: 'my-lib-secondary', + entryFilePath: `${workspaceRoot}/secondary/src/public-api.ts`, + isPrimary: false, + }; + + const entryPoints = new Map([ + ['my-lib', primaryEntry], + ['my-lib/secondary', secondaryEntry], + ]); + + const esmFiles = new Map([ + [`${workspaceRoot}/src/public-api.js`, 'export const a = 1;'], + [`${workspaceRoot}/secondary/src/public-api.js`, 'export const b = 2;'], + ]); + + const dtsFiles = new Map([ + [ + `${workspaceRoot}/src/internal.d.ts`, + 'export interface PublicA { id: string; }\ninterface UnexportedInternalA { secret: number; }', + ], + [ + `${workspaceRoot}/src/public-api.d.ts`, + 'export * from "./internal.js";\nexport declare const a = 1;', + ], + [ + `${workspaceRoot}/secondary/src/internal.d.ts`, + 'export interface PublicB { flag: boolean; }\ninterface UnexportedInternalB { hidden: string; }', + ], + [ + `${workspaceRoot}/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(entryPoints), + ); + + 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 legal comments in emitted FESM2022 .mjs bundles', async () => { + const primaryEntry: NormalizedEntryPoint = { + subpath: '.', + name: 'my-lib', + displayName: 'my-lib', + bundleName: 'my-lib', + entryFilePath: `${workspaceRoot}/src/public-api.ts`, + isPrimary: true, + }; + const entryPoints = new Map([['my-lib', primaryEntry]]); + + const esmFiles = new Map([ + [ + `${workspaceRoot}/src/public-api.js`, + '/*! @license MIT Custom Header */\nexport const VALUE = 42;', + ], + ]); + const dtsFiles = new Map([ + [`${workspaceRoot}/src/public-api.d.ts`, 'export declare const VALUE = 42;'], + ]); + + const output = await bundleEntryPoints( + [{ entryPoint: primaryEntry, hasEsmChanges: true, hasDtsChanges: true }], + esmFiles, + dtsFiles, + createOptions(entryPoints), + ); + + expect(output.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'fesm2022/my-lib.mjs', + contents: jasmine.stringContaining('/*! @license MIT Custom Header */'), + }), + ); + }); +});