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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider: TBH, this function implementation has kinda lost me. IIUC, the intent is to figure out all entry points which share a module ID with the given selected entry points?

Is that fundamentally just an Object.groupBy call? Is there a way we can simplify to something closer to:

return Object.groupBy(selectedEntryPoints, getModuleIds).values();

I suspect that wouldn't exactly work because each entry point has multiple module IDs, but I'm wondering if conceptually it's a cleaner direction?

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;
}
Comment on lines +111 to +113

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: In what practical situation do we have more selected entry points than all entry points? Should that be an assertion error?


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;
Comment on lines +138 to +139

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Why are we special casing singleSelectedName?

If I'm following this correctly, I think the below while loop does nothing in that case? Could we just return early or let this be naturally handled as an array that just happens to have one item in it?


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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider: Could we just initialize on line 137 instead of using ??=?

const selectedNames = new Set<string>();

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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: We should always expect to get a bundle right? Would it be more clear to do output.bundles.get(ep.bundleName)!.moduleIds and then drop the subsequent if (!newIds) style checks? Similarly, do we really care about skipping .size === 0 cases or will that no-op on its own?

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