Skip to content

Commit c25e237

Browse files
committed
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`.
1 parent 193abc0 commit c25e237

2 files changed

Lines changed: 240 additions & 29 deletions

File tree

‎packages/angular/build/src/builders/library/pipeline/bundler.ts‎

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import assert from 'node:assert';
109
import path from 'node:path';
1110
import type { OutputChunk, Plugin, RolldownOutput, RolldownPluginOption } from 'rolldown';
1211
import { loadRolldown } from '../../../utils/load-rolldown';
@@ -19,6 +18,28 @@ import {
1918
createMemoryOutputFile,
2019
} from './utils';
2120

21+
type RolldownPluginDtsModule = typeof import('rolldown-plugin-dts', {
22+
with: { 'resolution-mode': 'import' },
23+
});
24+
25+
let rolldownPluginDtsPromise: Promise<RolldownPluginDtsModule> | undefined;
26+
27+
/**
28+
* Dynamically loads the `rolldown-plugin-dts` module after ensuring that
29+
* Rolldown's native binding has been initialized via `loadRolldown()`.
30+
*
31+
* @returns A promise resolving to the `rolldown-plugin-dts` module namespace.
32+
*/
33+
function loadRolldownPluginDts(): Promise<RolldownPluginDtsModule> {
34+
return (rolldownPluginDtsPromise ??= (async () => {
35+
// Ensure Rolldown's native binding is initialized via `loadRolldown()` before evaluating
36+
// `rolldown-plugin-dts`, which has top-level imports of `rolldown/experimental`.
37+
await loadRolldown();
38+
39+
return import('rolldown-plugin-dts' as string) as Promise<RolldownPluginDtsModule>;
40+
})());
41+
}
42+
2243
/**
2344
* Output of the entry point bundling process.
2445
*/
@@ -337,10 +358,11 @@ async function executeMultiBundle(
337358
context: 'this',
338359
input,
339360
cwd: workspaceRoot,
361+
tsconfig: false,
340362
plugins,
341-
treeshake: false,
363+
treeshake: isDts,
342364
resolve: { symlinks: !preserveSymlinks },
343-
checks: { circularDependency: false },
365+
checks: { circularDependency: false, pluginTimings: false },
344366
onLog(level, log) {
345367
// Ignore specified warning codes
346368
if (level === 'warn') {
@@ -426,39 +448,22 @@ async function bundleAllDts(
426448
};
427449
}
428450

429-
// Ensure Rolldown's native binding is initialized via `loadRolldown()` before evaluating
430-
// `rolldown-plugin-dts`, which has top-level imports of `rolldown/experimental`.
431-
await loadRolldown();
432-
const { dts } = (await import('rolldown-plugin-dts' as string)) as typeof import(
433-
'rolldown-plugin-dts',
434-
{ with: { 'resolution-mode': 'import' } }
435-
);
451+
const { createFakeJsPlugin } = await loadRolldownPluginDts();
436452

437453
const dtsSourcemap = !!options.declarationMap;
438-
// Filter out `rolldown-plugin-dts:resolver` because all `.d.ts` files are already emitted
439-
// in-memory by the Angular/TypeScript compilation and resolved via `createMemoryFileLoaderPlugin`.
440-
// The default `rolldown-plugin-dts:resolver` plugin performs filesystem resolution (`oxc-resolver`)
441-
// and calls `this.load()` on on-disk `.ts` source files, which is unnecessary and causes a
442-
// significant performance regression across multi-entry builds.
443-
const rawDtsPlugins = dts({
444-
dtsInput: true,
445-
tsconfig: false,
454+
// Directly instantiate `createFakeJsPlugin` instead of calling `dts({ dtsInput: true, ... })`
455+
// to avoid `createDtsResolvePlugin` unconditionally instantiating a native `oxc-resolver`
456+
// (`ResolverFactory`) on every build. All `.d.ts` files are already emitted in-memory by the
457+
// Angular/TypeScript compilation and resolved via `createMemoryFileLoaderPlugin`.
458+
const fakeJsPlugin = createFakeJsPlugin({
446459
sourcemap: dtsSourcemap,
447-
generator: 'oxc',
460+
cjsDefault: false,
461+
sideEffects: false,
448462
});
449463

450-
const dtsPlugins = rawDtsPlugins.filter(
451-
(plugin) => plugin.name !== 'rolldown-plugin-dts:resolver',
452-
);
453-
454-
assert(
455-
dtsPlugins.length < rawDtsPlugins.length,
456-
'Expected "rolldown-plugin-dts:resolver" plugin to be present in rolldown-plugin-dts.',
457-
);
458-
459464
return executeMultiBundle(
460465
resolveEntryInputMap(entryPoints, true),
461-
[createMemoryFileLoaderPlugin(dtsFiles, DTS_EXTENSIONS, dtsSourcemap), ...dtsPlugins],
466+
[createMemoryFileLoaderPlugin(dtsFiles, DTS_EXTENSIONS, dtsSourcemap), fakeJsPlugin],
462467
'd.ts',
463468
options,
464469
);
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../types';
10+
import { bundleEntryPoints } from './bundler';
11+
12+
describe('bundleEntryPoints', () => {
13+
const workspaceRoot = '/workspace/my-lib';
14+
15+
function createOptions(entryPoints: Map<string, NormalizedEntryPoint>): NormalizedLibraryOptions {
16+
return {
17+
workspaceRoot,
18+
projectRoot: workspaceRoot,
19+
packageName: 'my-lib',
20+
packageJson: { name: 'my-lib' },
21+
outputPath: `${workspaceRoot}/dist`,
22+
deleteOutputPath: true,
23+
packageJsonPath: `${workspaceRoot}/package.json`,
24+
tsConfigPath: `${workspaceRoot}/tsconfig.lib.json`,
25+
entryPoints,
26+
inlineStyleLanguage: 'css',
27+
styleIncludePaths: [],
28+
assets: [],
29+
compilationMode: 'partial',
30+
declarationMap: false,
31+
allowedNonPeerDependencies: [],
32+
keepLifecycleScripts: false,
33+
watch: false,
34+
preserveSymlinks: false,
35+
progress: false,
36+
colors: false,
37+
cacheOptions: {
38+
enabled: false,
39+
basePath: '',
40+
path: '',
41+
cacheId: '',
42+
} as unknown as NormalizedLibraryOptions['cacheOptions'],
43+
};
44+
}
45+
46+
it('should tree-shake unexported internal types in .d.ts when using export *', async () => {
47+
const primaryEntry: NormalizedEntryPoint = {
48+
subpath: '.',
49+
name: 'my-lib',
50+
displayName: 'my-lib',
51+
bundleName: 'my-lib',
52+
entryFilePath: `${workspaceRoot}/src/public-api.ts`,
53+
isPrimary: true,
54+
};
55+
const secondaryEntry: NormalizedEntryPoint = {
56+
subpath: './secondary',
57+
name: 'my-lib/secondary',
58+
displayName: 'my-lib/secondary',
59+
bundleName: 'my-lib-secondary',
60+
entryFilePath: `${workspaceRoot}/secondary/src/public-api.ts`,
61+
isPrimary: false,
62+
};
63+
64+
const entryPoints = new Map<string, NormalizedEntryPoint>([
65+
['my-lib', primaryEntry],
66+
['my-lib/secondary', secondaryEntry],
67+
]);
68+
69+
const esmFiles = new Map<string, string>([
70+
[`${workspaceRoot}/src/public-api.js`, 'export const a = 1;'],
71+
[`${workspaceRoot}/secondary/src/public-api.js`, 'export const b = 2;'],
72+
]);
73+
74+
const dtsFiles = new Map<string, string>([
75+
[
76+
`${workspaceRoot}/src/internal.d.ts`,
77+
'export interface PublicA { id: string; }\ninterface UnexportedInternalA { secret: number; }',
78+
],
79+
[
80+
`${workspaceRoot}/src/public-api.d.ts`,
81+
'export * from "./internal.js";\nexport declare const a = 1;',
82+
],
83+
[
84+
`${workspaceRoot}/secondary/src/internal.d.ts`,
85+
'export interface PublicB { flag: boolean; }\ninterface UnexportedInternalB { hidden: string; }',
86+
],
87+
[
88+
`${workspaceRoot}/secondary/src/public-api.d.ts`,
89+
'export * from "./internal.js";\nexport declare const b = 2;',
90+
],
91+
]);
92+
93+
const output = await bundleEntryPoints(
94+
[
95+
{ entryPoint: primaryEntry, hasEsmChanges: true, hasDtsChanges: true },
96+
{ entryPoint: secondaryEntry, hasEsmChanges: true, hasDtsChanges: true },
97+
],
98+
esmFiles,
99+
dtsFiles,
100+
createOptions(entryPoints),
101+
);
102+
103+
expect(output.filesToEmit).toContain(
104+
jasmine.objectContaining({
105+
path: 'types/my-lib.d.ts',
106+
contents: jasmine.stringContaining('PublicA'),
107+
}),
108+
);
109+
expect(output.filesToEmit).not.toContain(
110+
jasmine.objectContaining({
111+
path: 'types/my-lib.d.ts',
112+
contents: jasmine.stringContaining('UnexportedInternalA'),
113+
}),
114+
);
115+
116+
expect(output.filesToEmit).toContain(
117+
jasmine.objectContaining({
118+
path: 'types/my-lib-secondary.d.ts',
119+
contents: jasmine.stringContaining('PublicB'),
120+
}),
121+
);
122+
expect(output.filesToEmit).not.toContain(
123+
jasmine.objectContaining({
124+
path: 'types/my-lib-secondary.d.ts',
125+
contents: jasmine.stringContaining('UnexportedInternalB'),
126+
}),
127+
);
128+
});
129+
130+
it('should preserve declare global and module augmentations in side-effect .d.ts imports', async () => {
131+
const primaryEntry: NormalizedEntryPoint = {
132+
subpath: '.',
133+
name: 'my-lib',
134+
displayName: 'my-lib',
135+
bundleName: 'my-lib',
136+
entryFilePath: `${workspaceRoot}/src/public-api.ts`,
137+
isPrimary: true,
138+
};
139+
const entryPoints = new Map<string, NormalizedEntryPoint>([['my-lib', primaryEntry]]);
140+
141+
const esmFiles = new Map<string, string>([
142+
[`${workspaceRoot}/src/public-api.js`, 'export const a = 1;'],
143+
]);
144+
const dtsFiles = new Map<string, string>([
145+
[
146+
`${workspaceRoot}/src/globals.d.ts`,
147+
'export {};\ndeclare global {\n interface Window {\n myCustomGlobal: boolean;\n }\n}\n',
148+
],
149+
[
150+
`${workspaceRoot}/src/augment.d.ts`,
151+
"declare module '@angular/core' {\n interface Component {\n augmentedProp?: string;\n }\n}\nexport {};\n",
152+
],
153+
[
154+
`${workspaceRoot}/src/public-api.d.ts`,
155+
'import "./globals.js";\nexport * from "./augment.js";\nexport declare const a = 1;\n',
156+
],
157+
]);
158+
159+
const output = await bundleEntryPoints(
160+
[{ entryPoint: primaryEntry, hasEsmChanges: true, hasDtsChanges: true }],
161+
esmFiles,
162+
dtsFiles,
163+
createOptions(entryPoints),
164+
);
165+
166+
const dtsFile = output.filesToEmit.find((f) => f.path === 'types/my-lib.d.ts');
167+
expect(dtsFile?.contents).toContain('myCustomGlobal');
168+
expect(dtsFile?.contents).toContain('augmentedProp');
169+
});
170+
171+
it('should preserve legal comments in emitted FESM2022 .mjs bundles', async () => {
172+
const primaryEntry: NormalizedEntryPoint = {
173+
subpath: '.',
174+
name: 'my-lib',
175+
displayName: 'my-lib',
176+
bundleName: 'my-lib',
177+
entryFilePath: `${workspaceRoot}/src/public-api.ts`,
178+
isPrimary: true,
179+
};
180+
const entryPoints = new Map<string, NormalizedEntryPoint>([['my-lib', primaryEntry]]);
181+
182+
const esmFiles = new Map<string, string>([
183+
[
184+
`${workspaceRoot}/src/public-api.js`,
185+
'/*! @license MIT Custom Header */\nexport const VALUE = 42;',
186+
],
187+
]);
188+
const dtsFiles = new Map<string, string>([
189+
[`${workspaceRoot}/src/public-api.d.ts`, 'export declare const VALUE = 42;'],
190+
]);
191+
192+
const output = await bundleEntryPoints(
193+
[{ entryPoint: primaryEntry, hasEsmChanges: true, hasDtsChanges: true }],
194+
esmFiles,
195+
dtsFiles,
196+
createOptions(entryPoints),
197+
);
198+
199+
expect(output.filesToEmit).toContain(
200+
jasmine.objectContaining({
201+
path: 'fesm2022/my-lib.mjs',
202+
contents: jasmine.stringContaining('/*! @license MIT Custom Header */'),
203+
}),
204+
);
205+
});
206+
});

0 commit comments

Comments
 (0)