Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,17 @@ export async function buildAction(actionContext: BuildActionContext): Promise<Ex
let bundleOutput: BundleEntryPointsOutput;
try {
[bundleOutput] = await Promise.all([
bundleEntryPoints(itemsToBundle, esmFiles, dtsFiles, {
...options,
declarationMap,
compilationMode,
}),
bundleEntryPoints(
itemsToBundle,
esmFiles,
dtsFiles,
{
...options,
declarationMap,
compilationMode,
},
buildState.previousBundleResults,
),
diagnosePromise,
]);
} catch (error) {
Expand Down
158 changes: 130 additions & 28 deletions packages/angular/build/src/builders/library/pipeline/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,51 @@ interface MultiBundleOutput {
warnings: string[];
}

/**
* Checks whether an entry point's previous output files included shared (non-entry) chunks.
*/
function hasSharedChunks(
outputFiles: ReadonlySet<string> | undefined,
dir: string,
bundleName: string,
extension: 'mjs' | 'd.ts',
): boolean {
if (!outputFiles || outputFiles.size === 0) {
return false;
}

const entryFile = `${dir}/${bundleName}.${extension}`;
const entryMapFile = `${entryFile}.map`;

for (const file of outputFiles) {
if (file !== entryFile && file !== entryMapFile) {
return true;
}
}

return false;
}

/**
* Collects all normalized entry points from options and bundle input items.
*/
function getAllEntryPoints(
items: readonly BundleEntryPointInput[],
options: NormalizedLibraryOptions,
): NormalizedEntryPoint[] {
const byName = new Map<string, NormalizedEntryPoint>();
if (options.entryPoints) {
for (const ep of options.entryPoints.values()) {
byName.set(ep.name, ep);
}
}
for (const item of items) {
byName.set(item.entryPoint.name, item.entryPoint);
}

return Array.from(byName.values());
}

/**
* Bundles the compiled in-memory JavaScript and declaration files for all dirty entry points
* using at most 2 Rolldown instances total (1 for all .mjs bundles, 1 for all .d.ts bundles).
Expand All @@ -89,42 +134,98 @@ interface MultiBundleOutput {
* @param esmFiles Map of virtual POSIX ESM output file paths to their content.
* @param dtsFiles Map of virtual POSIX TypeScript declaration file paths to their content.
* @param options The normalized library builder options with POSIX paths.
* @param previousBundleResults Optional map of entry point names to their previous bundle results.
* @returns A promise resolving to the bundled output files and bundle cache results.
*/
export async function bundleEntryPoints(
items: readonly BundleEntryPointInput[],
esmFiles: ReadonlyMap<string, string>,
dtsFiles: ReadonlyMap<string, string>,
options: NormalizedLibraryOptions,
previousBundleResults?: ReadonlyMap<string, BundleResult>,
): Promise<BundleEntryPointsOutput> {
const bundleResults = new Map<string, BundleResult>();
if (items.length === 0) {
return { filesToEmit: [], bundleResults, warnings: [] };
}

const esmEntryPoints: NormalizedEntryPoint[] = [];
const dtsEntryPoints: NormalizedEntryPoint[] = [];
const allEntryPoints = getAllEntryPoints(items, options);
const itemsByName = new Map(items.map((item) => [item.entryPoint.name, item]));

let esmEntryPoints: NormalizedEntryPoint[] = [];
let dtsEntryPoints: NormalizedEntryPoint[] = [];
let hasSharedEsmChunks = false;
let hasSharedDtsChunks = false;

for (const item of items) {
if (item.hasEsmChanges || !item.previousBundleResult) {
esmEntryPoints.push(item.entryPoint);
const { entryPoint } = item;
const previousBundleResult =
item.previousBundleResult ?? previousBundleResults?.get(entryPoint.name);
if (item.hasEsmChanges || !previousBundleResult) {
esmEntryPoints.push(entryPoint);
}
if (item.hasDtsChanges || !item.previousBundleResult) {
dtsEntryPoints.push(item.entryPoint);
if (item.hasDtsChanges || !previousBundleResult) {
dtsEntryPoints.push(entryPoint);
}
}

for (const entryPoint of allEntryPoints) {
const previousBundleResult =
itemsByName.get(entryPoint.name)?.previousBundleResult ??
previousBundleResults?.get(entryPoint.name);
if (!previousBundleResult) {
continue;
}

hasSharedEsmChunks ||= hasSharedChunks(
previousBundleResult.esmOutputFiles,
FESM_OUTPUT_DIR,
entryPoint.bundleName,
'mjs',
);
hasSharedDtsChunks ||= hasSharedChunks(
previousBundleResult.dtsOutputFiles,
TYPES_OUTPUT_DIR,
entryPoint.bundleName,
'd.ts',
);
}

if (
esmEntryPoints.length > 0 &&
hasSharedEsmChunks &&
esmEntryPoints.length < allEntryPoints.length
) {
esmEntryPoints = allEntryPoints.filter((ep) =>
esmFiles.has(ep.entryFilePath.replace(/\.(m?)ts$/, '.$1js')),
);
}
if (
dtsEntryPoints.length > 0 &&
hasSharedDtsChunks &&
dtsEntryPoints.length < allEntryPoints.length
) {
dtsEntryPoints = allEntryPoints.filter((ep) =>
dtsFiles.has(ep.entryFilePath.replace(/\.(m?ts)$/, '.d.$1')),
);
}

const [esmOutput, dtsOutput] = await Promise.all([
bundleAllEsm(esmEntryPoints, esmFiles, options),
bundleAllDts(dtsEntryPoints, dtsFiles, options),
]);

for (const { entryPoint, previousBundleResult } of items) {
const { bundleName, name } = entryPoint;
const esmBundle = esmOutput.bundles.get(bundleName);
const dtsBundle = dtsOutput.bundles.get(bundleName);
for (const entryPoint of allEntryPoints) {
const item = itemsByName.get(entryPoint.name);
const esmBundle = esmOutput.bundles.get(entryPoint.bundleName);
const dtsBundle = dtsOutput.bundles.get(entryPoint.bundleName);
if (!item && !esmBundle && !dtsBundle) {
continue;
}

bundleResults.set(name, {
const previousBundleResult =
item?.previousBundleResult ?? previousBundleResults?.get(entryPoint.name);
bundleResults.set(entryPoint.name, {
esmModuleIds: esmBundle?.moduleIds ?? previousBundleResult?.esmModuleIds ?? new Set(),
dtsModuleIds: dtsBundle?.moduleIds ?? previousBundleResult?.dtsModuleIds ?? new Set(),
esmOutputFiles: esmBundle?.outputFiles ?? previousBundleResult?.esmOutputFiles ?? new Set(),
Expand Down Expand Up @@ -154,8 +255,8 @@ function resolveEntryInputMap(
const input: Record<string, string> = {};
for (const { bundleName, entryFilePath } of entryPoints) {
input[bundleName] = dtsMode
? entryFilePath.replace(/\.([cm]?ts)$/, '.d.$1')
: entryFilePath.replace(/\.([cm]?)ts$/, '.$1js');
? entryFilePath.replace(/\.(m?ts)$/, '.d.$1')
: entryFilePath.replace(/\.(m?)ts$/, '.$1js');
}

return input;
Expand All @@ -174,6 +275,8 @@ function createMemoryFileLoaderPlugin(
extensions: readonly string[],
includeMap: boolean,
): Plugin {
const isDts = extensions === DTS_EXTENSIONS;

return {
name: 'memory-file-loader',
resolveId: {
Expand All @@ -187,28 +290,27 @@ function createMemoryFileLoaderPlugin(
return files.has(id) ? { id, external: false } : undefined;
}

if (id[0] !== '.' && !path.posix.isAbsolute(id)) {
if (id[0] !== '.' && !path.isAbsolute(id)) {
return { id, external: true };
}

const resolved = id[0] === '.' ? path.posix.join(path.posix.dirname(importer), id) : id;
const normalizedResolved = resolved.replace(/\/+$/, '');

let resolvedCandidate: string | undefined;
if (files.has(resolved)) {
resolvedCandidate = resolved;
} else {
const base = resolved.replace(/\.[cm]?js$/, '');
for (const ext of extensions) {
const candidate = base + ext;
if (files.has(candidate)) {
resolvedCandidate = candidate;
break;
}
}
if (files.has(normalizedResolved)) {
return { id: normalizedResolved, external: false };
}

if (resolvedCandidate) {
return { id: resolvedCandidate, external: false };
const base = normalizedResolved.replace(/\.m?js$/, '');
if (isDts && normalizedResolved.endsWith('.mjs') && files.has(`${base}.d.mts`)) {
return { id: `${base}.d.mts`, external: false };
}

for (const ext of extensions) {
const candidate = base + ext;
if (files.has(candidate)) {
return { id: candidate, external: false };
}
}

return undefined;
Expand Down
Loading
Loading