Skip to content

Commit 82efdc3

Browse files
committed
fixup! fix(@angular/build): improve library bundler resolution and watch shared chunk consistency
1 parent 83599e3 commit 82efdc3

2 files changed

Lines changed: 322 additions & 68 deletions

File tree

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

Lines changed: 199 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -82,30 +82,181 @@ interface MultiBundleOutput {
8282
}
8383

8484
/**
85-
* Checks whether an entry point's previous output files included shared (non-entry) chunks.
85+
* Checks whether two sets share at least one common element.
8686
*/
87-
function hasSharedChunks(
88-
outputFiles: ReadonlySet<string> | undefined,
89-
dir: string,
90-
bundleName: string,
91-
extension: 'mjs' | 'd.ts',
92-
): boolean {
93-
if (!outputFiles || outputFiles.size === 0) {
94-
return false;
87+
function hasSetIntersection(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
88+
if (a.size > b.size) {
89+
return hasSetIntersection(b, a);
90+
}
91+
92+
for (const item of a) {
93+
if (b.has(item)) {
94+
return true;
95+
}
96+
}
97+
98+
return false;
99+
}
100+
101+
/**
102+
* Expands the selected entry points to include any available entry points whose module IDs
103+
* transitively overlap with the selected set.
104+
*/
105+
function expandOverlappingEntryPoints(
106+
selectedEntryPoints: readonly NormalizedEntryPoint[],
107+
allEntryPoints: readonly NormalizedEntryPoint[],
108+
isAvailable: (ep: NormalizedEntryPoint) => boolean,
109+
getModuleIds: (ep: NormalizedEntryPoint) => ReadonlySet<string> | undefined,
110+
): readonly NormalizedEntryPoint[] {
111+
if (selectedEntryPoints.length >= allEntryPoints.length) {
112+
return selectedEntryPoints;
113+
}
114+
115+
let trackedModuleIds: ReadonlySet<string> | undefined;
116+
let mutableTrackedIds: Set<string> | undefined;
117+
118+
if (selectedEntryPoints.length === 1) {
119+
trackedModuleIds = getModuleIds(selectedEntryPoints[0]);
120+
} else {
121+
mutableTrackedIds = new Set<string>();
122+
for (const ep of selectedEntryPoints) {
123+
const ids = getModuleIds(ep);
124+
if (ids) {
125+
for (const id of ids) {
126+
mutableTrackedIds.add(id);
127+
}
128+
}
129+
}
130+
trackedModuleIds = mutableTrackedIds;
131+
}
132+
133+
if (!trackedModuleIds || trackedModuleIds.size === 0) {
134+
return selectedEntryPoints;
95135
}
96136

97-
const entryFile = `${dir}/${bundleName}.${extension}`;
98-
const entryMapFile = `${entryFile}.map`;
137+
let selectedNames: Set<string> | undefined;
138+
const singleSelectedName =
139+
selectedEntryPoints.length === 1 ? selectedEntryPoints[0].name : undefined;
140+
141+
let added = true;
142+
while (added) {
143+
added = false;
144+
for (const ep of allEntryPoints) {
145+
if (singleSelectedName !== undefined && !selectedNames) {
146+
if (ep.name === singleSelectedName) {
147+
continue;
148+
}
149+
} else {
150+
selectedNames ??= new Set(selectedEntryPoints.map((item) => item.name));
151+
if (selectedNames.has(ep.name)) {
152+
continue;
153+
}
154+
}
99155

100-
for (const file of outputFiles) {
101-
if (file !== entryFile && file !== entryMapFile) {
156+
const ids = getModuleIds(ep);
157+
if (ids && hasSetIntersection(ids, trackedModuleIds) && isAvailable(ep)) {
158+
selectedNames ??= new Set(selectedEntryPoints.map((item) => item.name));
159+
selectedNames.add(ep.name);
160+
mutableTrackedIds ??= new Set(trackedModuleIds);
161+
trackedModuleIds = mutableTrackedIds;
162+
for (const id of ids) {
163+
mutableTrackedIds.add(id);
164+
}
165+
added = true;
166+
}
167+
}
168+
}
169+
170+
return selectedNames && selectedNames.size > selectedEntryPoints.length
171+
? allEntryPoints.filter((ep) => selectedNames.has(ep.name))
172+
: selectedEntryPoints;
173+
}
174+
175+
/**
176+
* Checks whether any bundled entry point acquired a module ID that was not present in its
177+
* previous bundle result.
178+
*/
179+
function hasNewlyAddedModules(
180+
bundledEntryPoints: readonly NormalizedEntryPoint[],
181+
output: MultiBundleOutput,
182+
getPreviousModuleIds: (ep: NormalizedEntryPoint) => ReadonlySet<string> | undefined,
183+
): boolean {
184+
for (const ep of bundledEntryPoints) {
185+
const newIds = output.bundles.get(ep.bundleName)?.moduleIds;
186+
if (!newIds || newIds.size === 0) {
187+
continue;
188+
}
189+
190+
const prevIds = getPreviousModuleIds(ep);
191+
if (!prevIds) {
102192
return true;
103193
}
194+
195+
for (const id of newIds) {
196+
if (!prevIds.has(id)) {
197+
return true;
198+
}
199+
}
104200
}
105201

106202
return false;
107203
}
108204

205+
/**
206+
* Bundles dirty entry points for a single mode (ESM or DTS), automatically expanding the entry
207+
* point set before or after bundling when module IDs overlap across entry points.
208+
*/
209+
async function bundleModeWithSharedModules(
210+
dirtyEntryPoints: readonly NormalizedEntryPoint[],
211+
allEntryPoints: readonly NormalizedEntryPoint[],
212+
files: ReadonlyMap<string, string>,
213+
dtsMode: boolean,
214+
getPreviousModuleIds: (ep: NormalizedEntryPoint) => ReadonlySet<string> | undefined,
215+
bundleFn: (
216+
entryPoints: readonly NormalizedEntryPoint[],
217+
files: ReadonlyMap<string, string>,
218+
options: NormalizedLibraryOptions,
219+
) => Promise<MultiBundleOutput>,
220+
options: NormalizedLibraryOptions,
221+
): Promise<MultiBundleOutput> {
222+
if (dirtyEntryPoints.length === 0) {
223+
return { filesToEmit: [], bundles: new Map(), warnings: [] };
224+
}
225+
226+
if (dirtyEntryPoints.length >= allEntryPoints.length) {
227+
return bundleFn(dirtyEntryPoints, files, options);
228+
}
229+
230+
const isAvailable = (ep: NormalizedEntryPoint): boolean =>
231+
files.has(resolveEntryInputPath(ep.entryFilePath, dtsMode));
232+
233+
const initialEntryPoints = expandOverlappingEntryPoints(
234+
dirtyEntryPoints,
235+
allEntryPoints,
236+
isAvailable,
237+
getPreviousModuleIds,
238+
);
239+
const output = await bundleFn(initialEntryPoints, files, options);
240+
241+
if (
242+
initialEntryPoints.length < allEntryPoints.length &&
243+
hasNewlyAddedModules(initialEntryPoints, output, getPreviousModuleIds)
244+
) {
245+
const expandedEntryPoints = expandOverlappingEntryPoints(
246+
initialEntryPoints,
247+
allEntryPoints,
248+
isAvailable,
249+
(ep) => output.bundles.get(ep.bundleName)?.moduleIds ?? getPreviousModuleIds(ep),
250+
);
251+
252+
if (expandedEntryPoints.length > initialEntryPoints.length) {
253+
return bundleFn(expandedEntryPoints, files, options);
254+
}
255+
}
256+
257+
return output;
258+
}
259+
109260
/**
110261
* Collects all normalized entry points from options and bundle input items.
111262
*/
@@ -128,7 +279,7 @@ function getAllEntryPoints(
128279

129280
/**
130281
* Bundles the compiled in-memory JavaScript and declaration files for all dirty entry points
131-
* using at most 2 Rolldown instances total (1 for all .mjs bundles, 1 for all .d.ts bundles).
282+
* using at most 2 Rolldown instances per pass (1 for all .mjs bundles, 1 for all .d.ts bundles).
132283
*
133284
* @param items The collection of entry point inputs with change statuses.
134285
* @param esmFiles Map of virtual POSIX ESM output file paths to their content.
@@ -151,16 +302,15 @@ export async function bundleEntryPoints(
151302

152303
const allEntryPoints = getAllEntryPoints(items, options);
153304
const itemsByName = new Map(items.map((item) => [item.entryPoint.name, item]));
305+
const getPreviousResult = (name: string): BundleResult | undefined =>
306+
itemsByName.get(name)?.previousBundleResult ?? previousBundleResults?.get(name);
154307

155-
let esmEntryPoints: NormalizedEntryPoint[] = [];
156-
let dtsEntryPoints: NormalizedEntryPoint[] = [];
157-
let hasSharedEsmChunks = false;
158-
let hasSharedDtsChunks = false;
308+
const esmEntryPoints: NormalizedEntryPoint[] = [];
309+
const dtsEntryPoints: NormalizedEntryPoint[] = [];
159310

160311
for (const item of items) {
161312
const { entryPoint } = item;
162-
const previousBundleResult =
163-
item.previousBundleResult ?? previousBundleResults?.get(entryPoint.name);
313+
const previousBundleResult = getPreviousResult(entryPoint.name);
164314
if (item.hasEsmChanges || !previousBundleResult) {
165315
esmEntryPoints.push(entryPoint);
166316
}
@@ -169,50 +319,25 @@ export async function bundleEntryPoints(
169319
}
170320
}
171321

172-
for (const entryPoint of allEntryPoints) {
173-
const previousBundleResult =
174-
itemsByName.get(entryPoint.name)?.previousBundleResult ??
175-
previousBundleResults?.get(entryPoint.name);
176-
if (!previousBundleResult) {
177-
continue;
178-
}
179-
180-
hasSharedEsmChunks ||= hasSharedChunks(
181-
previousBundleResult.esmOutputFiles,
182-
FESM_OUTPUT_DIR,
183-
entryPoint.bundleName,
184-
'mjs',
185-
);
186-
hasSharedDtsChunks ||= hasSharedChunks(
187-
previousBundleResult.dtsOutputFiles,
188-
TYPES_OUTPUT_DIR,
189-
entryPoint.bundleName,
190-
'd.ts',
191-
);
192-
}
193-
194-
if (
195-
esmEntryPoints.length > 0 &&
196-
hasSharedEsmChunks &&
197-
esmEntryPoints.length < allEntryPoints.length
198-
) {
199-
esmEntryPoints = allEntryPoints.filter((ep) =>
200-
esmFiles.has(ep.entryFilePath.replace(/\.(m?)ts$/, '.$1js')),
201-
);
202-
}
203-
if (
204-
dtsEntryPoints.length > 0 &&
205-
hasSharedDtsChunks &&
206-
dtsEntryPoints.length < allEntryPoints.length
207-
) {
208-
dtsEntryPoints = allEntryPoints.filter((ep) =>
209-
dtsFiles.has(ep.entryFilePath.replace(/\.(m?ts)$/, '.d.$1')),
210-
);
211-
}
212-
213322
const [esmOutput, dtsOutput] = await Promise.all([
214-
bundleAllEsm(esmEntryPoints, esmFiles, options),
215-
bundleAllDts(dtsEntryPoints, dtsFiles, options),
323+
bundleModeWithSharedModules(
324+
esmEntryPoints,
325+
allEntryPoints,
326+
esmFiles,
327+
false,
328+
(ep) => getPreviousResult(ep.name)?.esmModuleIds,
329+
bundleAllEsm,
330+
options,
331+
),
332+
bundleModeWithSharedModules(
333+
dtsEntryPoints,
334+
allEntryPoints,
335+
dtsFiles,
336+
true,
337+
(ep) => getPreviousResult(ep.name)?.dtsModuleIds,
338+
bundleAllDts,
339+
options,
340+
),
216341
]);
217342

218343
for (const entryPoint of allEntryPoints) {
@@ -223,8 +348,7 @@ export async function bundleEntryPoints(
223348
continue;
224349
}
225350

226-
const previousBundleResult =
227-
item?.previousBundleResult ?? previousBundleResults?.get(entryPoint.name);
351+
const previousBundleResult = getPreviousResult(entryPoint.name);
228352
bundleResults.set(entryPoint.name, {
229353
esmModuleIds: esmBundle?.moduleIds ?? previousBundleResult?.esmModuleIds ?? new Set(),
230354
dtsModuleIds: dtsBundle?.moduleIds ?? previousBundleResult?.dtsModuleIds ?? new Set(),
@@ -241,6 +365,15 @@ export async function bundleEntryPoints(
241365
};
242366
}
243367

368+
/**
369+
* Resolves the compiled in-memory entry file path for a TypeScript entry point.
370+
*/
371+
function resolveEntryInputPath(entryFilePath: string, dtsMode: boolean): string {
372+
return dtsMode
373+
? entryFilePath.replace(/\.(m?ts)$/, '.d.$1')
374+
: entryFilePath.replace(/\.(m?)ts$/, '.$1js');
375+
}
376+
244377
/**
245378
* Generates the Rolldown input mapping object from the specified entry points.
246379
*
@@ -254,9 +387,7 @@ function resolveEntryInputMap(
254387
): Record<string, string> {
255388
const input: Record<string, string> = {};
256389
for (const { bundleName, entryFilePath } of entryPoints) {
257-
input[bundleName] = dtsMode
258-
? entryFilePath.replace(/\.(m?ts)$/, '.d.$1')
259-
: entryFilePath.replace(/\.(m?)ts$/, '.$1js');
390+
input[bundleName] = resolveEntryInputPath(entryFilePath, dtsMode);
260391
}
261392

262393
return input;

0 commit comments

Comments
 (0)