Skip to content

Commit 5416c9f

Browse files
authored
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 d609a21 commit 5416c9f

2 files changed

Lines changed: 167 additions & 35 deletions

File tree

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

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,28 @@ import {
1919
createMemoryOutputFile,
2020
} from './utils';
2121

22+
type RolldownPluginDtsModule = typeof import('rolldown-plugin-dts', {
23+
with: { 'resolution-mode': 'import' },
24+
});
25+
26+
let rolldownPluginDtsPromise: Promise<RolldownPluginDtsModule> | undefined;
27+
28+
/**
29+
* Dynamically loads the `rolldown-plugin-dts` module after ensuring that
30+
* Rolldown's native binding has been initialized via `loadRolldown()`.
31+
*
32+
* @returns A promise resolving to the `rolldown-plugin-dts` module namespace.
33+
*/
34+
function loadRolldownPluginDts(): Promise<RolldownPluginDtsModule> {
35+
return (rolldownPluginDtsPromise ??= (async () => {
36+
// Ensure Rolldown's native binding is initialized via `loadRolldown()` before evaluating
37+
// `rolldown-plugin-dts`, which has top-level imports of `rolldown/experimental`.
38+
await loadRolldown();
39+
40+
return import('rolldown-plugin-dts' as string) as Promise<RolldownPluginDtsModule>;
41+
})());
42+
}
43+
2244
/**
2345
* Output of the entry point bundling process.
2446
*/
@@ -137,15 +159,9 @@ function hasNewlyAddedModules(
137159
assert(bundle, `Expected bundle output for entry point "${ep.bundleName}".`);
138160

139161
const prevIds = getPreviousModuleIds(ep);
140-
if (!prevIds) {
162+
if (!prevIds || !bundle.moduleIds.isSubsetOf(prevIds)) {
141163
return true;
142164
}
143-
144-
for (const id of bundle.moduleIds) {
145-
if (!prevIds.has(id)) {
146-
return true;
147-
}
148-
}
149165
}
150166

151167
return false;
@@ -519,10 +535,11 @@ async function executeMultiBundle(
519535
context: 'this',
520536
input,
521537
cwd: workspaceRoot,
538+
tsconfig: false,
522539
plugins,
523-
treeshake: false,
540+
treeshake: isDts,
524541
resolve: { symlinks: !preserveSymlinks },
525-
checks: { circularDependency: false },
542+
checks: { circularDependency: false, bundlerTimings: false },
526543
onLog(level, log) {
527544
// Ignore specified warning codes
528545
if (level === 'warn') {
@@ -608,39 +625,22 @@ async function bundleAllDts(
608625
};
609626
}
610627

611-
// Ensure Rolldown's native binding is initialized via `loadRolldown()` before evaluating
612-
// `rolldown-plugin-dts`, which has top-level imports of `rolldown/experimental`.
613-
await loadRolldown();
614-
const { dts } = (await import('rolldown-plugin-dts' as string)) as typeof import(
615-
'rolldown-plugin-dts',
616-
{ with: { 'resolution-mode': 'import' } }
617-
);
628+
const { createFakeJsPlugin } = await loadRolldownPluginDts();
618629

619630
const dtsSourcemap = !!options.declarationMap;
620-
// Filter out `rolldown-plugin-dts:resolver` because all `.d.ts` files are already emitted
621-
// in-memory by the Angular/TypeScript compilation and resolved via `createMemoryFileLoaderPlugin`.
622-
// The default `rolldown-plugin-dts:resolver` plugin performs filesystem resolution (`oxc-resolver`)
623-
// and calls `this.load()` on on-disk `.ts` source files, which is unnecessary and causes a
624-
// significant performance regression across multi-entry builds.
625-
const rawDtsPlugins = dts({
626-
dtsInput: true,
627-
tsconfig: false,
631+
// Directly instantiate `createFakeJsPlugin` instead of calling `dts({ dtsInput: true, ... })`
632+
// to avoid `createDtsResolvePlugin` unconditionally instantiating a native `oxc-resolver`
633+
// (`ResolverFactory`) on every build. All `.d.ts` files are already emitted in-memory by the
634+
// Angular/TypeScript compilation and resolved via `createMemoryFileLoaderPlugin`.
635+
const fakeJsPlugin = createFakeJsPlugin({
628636
sourcemap: dtsSourcemap,
629-
generator: 'oxc',
637+
cjsDefault: false,
638+
sideEffects: false,
630639
});
631640

632-
const dtsPlugins = rawDtsPlugins.filter(
633-
(plugin) => plugin.name !== 'rolldown-plugin-dts:resolver',
634-
);
635-
636-
assert(
637-
dtsPlugins.length < rawDtsPlugins.length,
638-
'Expected "rolldown-plugin-dts:resolver" plugin to be present in rolldown-plugin-dts.',
639-
);
640-
641641
return executeMultiBundle(
642642
resolveEntryInputMap(entryPoints, true),
643-
[createMemoryFileLoaderPlugin(dtsFiles, DTS_EXTENSIONS, dtsSourcemap), ...dtsPlugins],
643+
[createMemoryFileLoaderPlugin(dtsFiles, DTS_EXTENSIONS, dtsSourcemap), fakeJsPlugin],
644644
'd.ts',
645645
options,
646646
);

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

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,4 +485,136 @@ export declare const SECONDARY: SharedItem;`,
485485
);
486486
});
487487
});
488+
489+
describe('tree-shaking and comment preservation', () => {
490+
it('should tree-shake unexported internal types in .d.ts when using export *', async () => {
491+
const primaryEntry = createEntryPoint('.', 'my-lib', `${projectRoot}/src/public-api.ts`);
492+
const secondaryEntry = createEntryPoint(
493+
'secondary',
494+
'my-lib-secondary',
495+
`${projectRoot}/secondary/src/public-api.ts`,
496+
);
497+
498+
const esmFiles = new Map<string, string>([
499+
[`${projectRoot}/src/public-api.js`, 'export const a = 1;'],
500+
[`${projectRoot}/secondary/src/public-api.js`, 'export const b = 2;'],
501+
]);
502+
503+
const dtsFiles = new Map<string, string>([
504+
[
505+
`${projectRoot}/src/internal.d.ts`,
506+
'export interface PublicA { id: string; }\ninterface UnexportedInternalA { secret: number; }',
507+
],
508+
[
509+
`${projectRoot}/src/public-api.d.ts`,
510+
'export * from "./internal.js";\nexport declare const a = 1;',
511+
],
512+
[
513+
`${projectRoot}/secondary/src/internal.d.ts`,
514+
'export interface PublicB { flag: boolean; }\ninterface UnexportedInternalB { hidden: string; }',
515+
],
516+
[
517+
`${projectRoot}/secondary/src/public-api.d.ts`,
518+
'export * from "./internal.js";\nexport declare const b = 2;',
519+
],
520+
]);
521+
522+
const output = await bundleEntryPoints(
523+
[
524+
{ entryPoint: primaryEntry, hasEsmChanges: true, hasDtsChanges: true },
525+
{ entryPoint: secondaryEntry, hasEsmChanges: true, hasDtsChanges: true },
526+
],
527+
esmFiles,
528+
dtsFiles,
529+
createOptions([primaryEntry, secondaryEntry]),
530+
);
531+
532+
expect(output.filesToEmit).toContain(
533+
jasmine.objectContaining({
534+
path: 'types/my-lib.d.ts',
535+
contents: jasmine.stringContaining('PublicA'),
536+
}),
537+
);
538+
expect(output.filesToEmit).not.toContain(
539+
jasmine.objectContaining({
540+
path: 'types/my-lib.d.ts',
541+
contents: jasmine.stringContaining('UnexportedInternalA'),
542+
}),
543+
);
544+
545+
expect(output.filesToEmit).toContain(
546+
jasmine.objectContaining({
547+
path: 'types/my-lib-secondary.d.ts',
548+
contents: jasmine.stringContaining('PublicB'),
549+
}),
550+
);
551+
expect(output.filesToEmit).not.toContain(
552+
jasmine.objectContaining({
553+
path: 'types/my-lib-secondary.d.ts',
554+
contents: jasmine.stringContaining('UnexportedInternalB'),
555+
}),
556+
);
557+
});
558+
559+
it('should preserve declare global and module augmentations in side-effect .d.ts imports', async () => {
560+
const primaryEntry = createEntryPoint();
561+
562+
const esmFiles = new Map<string, string>([
563+
[`${projectRoot}/src/public-api.js`, 'export const a = 1;'],
564+
]);
565+
const dtsFiles = new Map<string, string>([
566+
[
567+
`${projectRoot}/src/globals.d.ts`,
568+
'export {};\ndeclare global {\n interface Window {\n myCustomGlobal: boolean;\n }\n}\n',
569+
],
570+
[
571+
`${projectRoot}/src/augment.d.ts`,
572+
"declare module '@angular/core' {\n interface Component {\n augmentedProp?: string;\n }\n}\nexport {};\n",
573+
],
574+
[
575+
`${projectRoot}/src/public-api.d.ts`,
576+
'import "./globals.js";\nexport * from "./augment.js";\nexport declare const a = 1;\n',
577+
],
578+
]);
579+
580+
const output = await bundleEntryPoints(
581+
[{ entryPoint: primaryEntry, hasEsmChanges: true, hasDtsChanges: true }],
582+
esmFiles,
583+
dtsFiles,
584+
createOptions([primaryEntry]),
585+
);
586+
587+
const dtsFile = output.filesToEmit.find((f) => f.path === 'types/my-lib.d.ts');
588+
expect(dtsFile?.contents).toContain('myCustomGlobal');
589+
expect(dtsFile?.contents).toContain('augmentedProp');
590+
});
591+
592+
it('should preserve legal comments in emitted FESM2022 .mjs bundles', async () => {
593+
const primaryEntry = createEntryPoint();
594+
595+
const esmFiles = new Map<string, string>([
596+
[
597+
`${projectRoot}/src/public-api.js`,
598+
'/*! @license MIT Custom Header */\nexport const VALUE = 42;',
599+
],
600+
]);
601+
const dtsFiles = new Map<string, string>([
602+
[`${projectRoot}/src/public-api.d.ts`, 'export declare const VALUE = 42;'],
603+
]);
604+
605+
const output = await bundleEntryPoints(
606+
[{ entryPoint: primaryEntry, hasEsmChanges: true, hasDtsChanges: true }],
607+
esmFiles,
608+
dtsFiles,
609+
createOptions([primaryEntry]),
610+
);
611+
612+
expect(output.filesToEmit).toContain(
613+
jasmine.objectContaining({
614+
path: 'fesm2022/my-lib.mjs',
615+
contents: jasmine.stringContaining('/*! @license MIT Custom Header */'),
616+
}),
617+
);
618+
});
619+
});
488620
});

0 commit comments

Comments
 (0)