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
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
293 changes: 263 additions & 30 deletions packages/angular/build/src/builders/library/pipeline/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,50 +81,275 @@ interface MultiBundleOutput {
warnings: string[];
}

/**
* Checks whether two sets share at least one common element.
*/
function hasSetIntersection(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
if (a.size > b.size) {
return hasSetIntersection(b, a);
}

for (const item of a) {
if (b.has(item)) {
return true;
}
}

return false;
}

/**
* Expands the selected entry points to include any available entry points whose module IDs
* transitively overlap with the selected set.
*/
function expandOverlappingEntryPoints(
selectedEntryPoints: readonly NormalizedEntryPoint[],
allEntryPoints: readonly NormalizedEntryPoint[],
isAvailable: (ep: NormalizedEntryPoint) => boolean,
getModuleIds: (ep: NormalizedEntryPoint) => ReadonlySet<string> | undefined,
): readonly NormalizedEntryPoint[] {
if (selectedEntryPoints.length >= allEntryPoints.length) {
return selectedEntryPoints;
}

let trackedModuleIds: ReadonlySet<string> | undefined;
let mutableTrackedIds: Set<string> | undefined;

if (selectedEntryPoints.length === 1) {
trackedModuleIds = getModuleIds(selectedEntryPoints[0]);
} else {
mutableTrackedIds = new Set<string>();
for (const ep of selectedEntryPoints) {
const ids = getModuleIds(ep);
if (ids) {
for (const id of ids) {
mutableTrackedIds.add(id);
}
}
}
trackedModuleIds = mutableTrackedIds;
}

if (!trackedModuleIds || trackedModuleIds.size === 0) {
return selectedEntryPoints;
}

let selectedNames: Set<string> | undefined;
const singleSelectedName =
selectedEntryPoints.length === 1 ? selectedEntryPoints[0].name : undefined;

let added = true;
while (added) {
added = false;
for (const ep of allEntryPoints) {
if (singleSelectedName !== undefined && !selectedNames) {
if (ep.name === singleSelectedName) {
continue;
}
} else {
selectedNames ??= new Set(selectedEntryPoints.map((item) => item.name));
if (selectedNames.has(ep.name)) {
continue;
}
}

const ids = getModuleIds(ep);
if (ids && hasSetIntersection(ids, trackedModuleIds) && isAvailable(ep)) {
selectedNames ??= new Set(selectedEntryPoints.map((item) => item.name));
selectedNames.add(ep.name);
mutableTrackedIds ??= new Set(trackedModuleIds);
trackedModuleIds = mutableTrackedIds;
for (const id of ids) {
mutableTrackedIds.add(id);
}
added = true;
}
}
}

return selectedNames && selectedNames.size > selectedEntryPoints.length
? allEntryPoints.filter((ep) => selectedNames.has(ep.name))
: selectedEntryPoints;
}

/**
* Checks whether any bundled entry point acquired a module ID that was not present in its
* previous bundle result.
*/
function hasNewlyAddedModules(
bundledEntryPoints: readonly NormalizedEntryPoint[],
output: MultiBundleOutput,
getPreviousModuleIds: (ep: NormalizedEntryPoint) => ReadonlySet<string> | undefined,
): boolean {
for (const ep of bundledEntryPoints) {
const newIds = output.bundles.get(ep.bundleName)?.moduleIds;
if (!newIds || newIds.size === 0) {
continue;
}

const prevIds = getPreviousModuleIds(ep);
if (!prevIds) {
return true;
}

for (const id of newIds) {
if (!prevIds.has(id)) {
return true;
}
}
}

return false;
}

/**
* Bundles dirty entry points for a single mode (ESM or DTS), automatically expanding the entry
* point set before or after bundling when module IDs overlap across entry points.
*/
async function bundleModeWithSharedModules(
dirtyEntryPoints: readonly NormalizedEntryPoint[],
allEntryPoints: readonly NormalizedEntryPoint[],
files: ReadonlyMap<string, string>,
dtsMode: boolean,
getPreviousModuleIds: (ep: NormalizedEntryPoint) => ReadonlySet<string> | undefined,
bundleFn: (
entryPoints: readonly NormalizedEntryPoint[],
files: ReadonlyMap<string, string>,
options: NormalizedLibraryOptions,
) => Promise<MultiBundleOutput>,
options: NormalizedLibraryOptions,
): Promise<MultiBundleOutput> {
if (dirtyEntryPoints.length === 0) {
return { filesToEmit: [], bundles: new Map(), warnings: [] };
}

if (dirtyEntryPoints.length >= allEntryPoints.length) {
return bundleFn(dirtyEntryPoints, files, options);
}

const isAvailable = (ep: NormalizedEntryPoint): boolean =>
files.has(resolveEntryInputPath(ep.entryFilePath, dtsMode));

const initialEntryPoints = expandOverlappingEntryPoints(
dirtyEntryPoints,
allEntryPoints,
isAvailable,
getPreviousModuleIds,
);
const output = await bundleFn(initialEntryPoints, files, options);

if (
initialEntryPoints.length < allEntryPoints.length &&
hasNewlyAddedModules(initialEntryPoints, output, getPreviousModuleIds)
) {
const expandedEntryPoints = expandOverlappingEntryPoints(
initialEntryPoints,
allEntryPoints,
isAvailable,
(ep) => output.bundles.get(ep.bundleName)?.moduleIds ?? getPreviousModuleIds(ep),
);

if (expandedEntryPoints.length > initialEntryPoints.length) {
return bundleFn(expandedEntryPoints, files, options);
}
}

return output;
}

/**
* 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).
* using at most 2 Rolldown instances per pass (1 for all .mjs bundles, 1 for all .d.ts bundles).
*
* @param items The collection of entry point inputs with change statuses.
* @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 allEntryPoints = getAllEntryPoints(items, options);
const itemsByName = new Map(items.map((item) => [item.entryPoint.name, item]));
const getPreviousResult = (name: string): BundleResult | undefined =>
itemsByName.get(name)?.previousBundleResult ?? previousBundleResults?.get(name);

const esmEntryPoints: NormalizedEntryPoint[] = [];
const dtsEntryPoints: NormalizedEntryPoint[] = [];

for (const item of items) {
if (item.hasEsmChanges || !item.previousBundleResult) {
esmEntryPoints.push(item.entryPoint);
const { entryPoint } = item;
const previousBundleResult = getPreviousResult(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);
}
}

const [esmOutput, dtsOutput] = await Promise.all([
bundleAllEsm(esmEntryPoints, esmFiles, options),
bundleAllDts(dtsEntryPoints, dtsFiles, options),
bundleModeWithSharedModules(
esmEntryPoints,
allEntryPoints,
esmFiles,
false,
(ep) => getPreviousResult(ep.name)?.esmModuleIds,
bundleAllEsm,
options,
),
bundleModeWithSharedModules(
dtsEntryPoints,
allEntryPoints,
dtsFiles,
true,
(ep) => getPreviousResult(ep.name)?.dtsModuleIds,
bundleAllDts,
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 = getPreviousResult(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 All @@ -140,6 +365,15 @@ export async function bundleEntryPoints(
};
}

/**
* Resolves the compiled in-memory entry file path for a TypeScript entry point.
*/
function resolveEntryInputPath(entryFilePath: string, dtsMode: boolean): string {
return dtsMode
? entryFilePath.replace(/\.(m?ts)$/, '.d.$1')
: entryFilePath.replace(/\.(m?)ts$/, '.$1js');
}

/**
* Generates the Rolldown input mapping object from the specified entry points.
*
Expand All @@ -153,9 +387,7 @@ function resolveEntryInputMap(
): Record<string, string> {
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');
input[bundleName] = resolveEntryInputPath(entryFilePath, dtsMode);
}

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

return {
name: 'memory-file-loader',
resolveId: {
Expand All @@ -187,28 +421,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