Skip to content
Open
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
63 changes: 34 additions & 29 deletions packages/angular/build/src/builders/library/pipeline/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,6 +18,28 @@ import {
createMemoryOutputFile,
} from './utils';

type RolldownPluginDtsModule = typeof import('rolldown-plugin-dts', {
with: { 'resolution-mode': 'import' },
});

let rolldownPluginDtsPromise: Promise<RolldownPluginDtsModule> | 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<RolldownPluginDtsModule> {
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<RolldownPluginDtsModule>;
})());
}

/**
* Output of the entry point bundling process.
*/
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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,
);
Expand Down
165 changes: 165 additions & 0 deletions packages/angular/build/src/builders/library/pipeline/bundler_spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, NormalizedEntryPoint>): 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<string, NormalizedEntryPoint>([
['my-lib', primaryEntry],
['my-lib/secondary', secondaryEntry],
]);

const esmFiles = new Map<string, string>([
[`${workspaceRoot}/src/public-api.js`, 'export const a = 1;'],
[`${workspaceRoot}/secondary/src/public-api.js`, 'export const b = 2;'],
]);

const dtsFiles = new Map<string, string>([
[
`${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<string, NormalizedEntryPoint>([['my-lib', primaryEntry]]);

const esmFiles = new Map<string, string>([
[
`${workspaceRoot}/src/public-api.js`,
'/*! @license MIT Custom Header */\nexport const VALUE = 42;',
],
]);
const dtsFiles = new Map<string, string>([
[`${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 */'),
}),
);
});
});
Loading