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
70 changes: 35 additions & 35 deletions packages/angular/build/src/builders/library/pipeline/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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 @@ -137,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;
Expand Down Expand Up @@ -519,10 +535,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') {
Expand Down Expand Up @@ -608,39 +625,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
132 changes: 132 additions & 0 deletions packages/angular/build/src/builders/library/pipeline/bundler_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>([
[`${projectRoot}/src/public-api.js`, 'export const a = 1;'],
[`${projectRoot}/secondary/src/public-api.js`, 'export const b = 2;'],
]);

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