Skip to content

Commit 4e8c365

Browse files
committed
fix(@angular/build): improve library bundler resolution and watch shared chunk consistency
- Strip trailing slashes when resolving relative directory imports in `createMemoryFileLoaderPlugin` so `./dir/` resolves `.../dir/index.js`. - Support `.cjs` and `.d.cts` extensions, and prioritize `.d.mts`/`.d.cts` before `.d.ts` when resolving explicit `.mjs`/`.cjs` specifiers in `.d.ts` mode. - Treat Windows drive-letter absolute paths (`/^[a-zA-Z]:\//`) as internal modules. - Fall back to on-disk declaration files in `.d.ts` mode for hand-authored ambient/declaration files not emitted into the in-memory `dtsFiles` map. - Re-bundle all entry points from in-memory outputs during partial watch rebuilds when previous bundle results contained shared chunks to prevent inlining shared code into a subset of entry points.
1 parent 193abc0 commit 4e8c365

3 files changed

Lines changed: 762 additions & 35 deletions

File tree

‎packages/angular/build/src/builders/library/pipeline/build-action.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,17 @@ export async function buildAction(actionContext: BuildActionContext): Promise<Ex
103103
let bundleOutput: BundleEntryPointsOutput;
104104
try {
105105
[bundleOutput] = await Promise.all([
106-
bundleEntryPoints(itemsToBundle, esmFiles, dtsFiles, {
107-
...options,
108-
declarationMap,
109-
compilationMode,
110-
}),
106+
bundleEntryPoints(
107+
itemsToBundle,
108+
esmFiles,
109+
dtsFiles,
110+
{
111+
...options,
112+
declarationMap,
113+
compilationMode,
114+
},
115+
buildState.previousBundleResults,
116+
),
111117
diagnosePromise,
112118
]);
113119
} catch (error) {

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

Lines changed: 263 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -81,50 +81,275 @@ interface MultiBundleOutput {
8181
warnings: string[];
8282
}
8383

84+
/**
85+
* Checks whether two sets share at least one common element.
86+
*/
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;
135+
}
136+
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+
}
155+
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) {
192+
return true;
193+
}
194+
195+
for (const id of newIds) {
196+
if (!prevIds.has(id)) {
197+
return true;
198+
}
199+
}
200+
}
201+
202+
return false;
203+
}
204+
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+
260+
/**
261+
* Collects all normalized entry points from options and bundle input items.
262+
*/
263+
function getAllEntryPoints(
264+
items: readonly BundleEntryPointInput[],
265+
options: NormalizedLibraryOptions,
266+
): NormalizedEntryPoint[] {
267+
const byName = new Map<string, NormalizedEntryPoint>();
268+
if (options.entryPoints) {
269+
for (const ep of options.entryPoints.values()) {
270+
byName.set(ep.name, ep);
271+
}
272+
}
273+
for (const item of items) {
274+
byName.set(item.entryPoint.name, item.entryPoint);
275+
}
276+
277+
return Array.from(byName.values());
278+
}
279+
84280
/**
85281
* Bundles the compiled in-memory JavaScript and declaration files for all dirty entry points
86-
* 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).
87283
*
88284
* @param items The collection of entry point inputs with change statuses.
89285
* @param esmFiles Map of virtual POSIX ESM output file paths to their content.
90286
* @param dtsFiles Map of virtual POSIX TypeScript declaration file paths to their content.
91287
* @param options The normalized library builder options with POSIX paths.
288+
* @param previousBundleResults Optional map of entry point names to their previous bundle results.
92289
* @returns A promise resolving to the bundled output files and bundle cache results.
93290
*/
94291
export async function bundleEntryPoints(
95292
items: readonly BundleEntryPointInput[],
96293
esmFiles: ReadonlyMap<string, string>,
97294
dtsFiles: ReadonlyMap<string, string>,
98295
options: NormalizedLibraryOptions,
296+
previousBundleResults?: ReadonlyMap<string, BundleResult>,
99297
): Promise<BundleEntryPointsOutput> {
100298
const bundleResults = new Map<string, BundleResult>();
101299
if (items.length === 0) {
102300
return { filesToEmit: [], bundleResults, warnings: [] };
103301
}
104302

303+
const allEntryPoints = getAllEntryPoints(items, options);
304+
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);
307+
105308
const esmEntryPoints: NormalizedEntryPoint[] = [];
106309
const dtsEntryPoints: NormalizedEntryPoint[] = [];
107310

108311
for (const item of items) {
109-
if (item.hasEsmChanges || !item.previousBundleResult) {
110-
esmEntryPoints.push(item.entryPoint);
312+
const { entryPoint } = item;
313+
const previousBundleResult = getPreviousResult(entryPoint.name);
314+
if (item.hasEsmChanges || !previousBundleResult) {
315+
esmEntryPoints.push(entryPoint);
111316
}
112-
if (item.hasDtsChanges || !item.previousBundleResult) {
113-
dtsEntryPoints.push(item.entryPoint);
317+
if (item.hasDtsChanges || !previousBundleResult) {
318+
dtsEntryPoints.push(entryPoint);
114319
}
115320
}
116321

117322
const [esmOutput, dtsOutput] = await Promise.all([
118-
bundleAllEsm(esmEntryPoints, esmFiles, options),
119-
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+
),
120341
]);
121342

122-
for (const { entryPoint, previousBundleResult } of items) {
123-
const { bundleName, name } = entryPoint;
124-
const esmBundle = esmOutput.bundles.get(bundleName);
125-
const dtsBundle = dtsOutput.bundles.get(bundleName);
343+
for (const entryPoint of allEntryPoints) {
344+
const item = itemsByName.get(entryPoint.name);
345+
const esmBundle = esmOutput.bundles.get(entryPoint.bundleName);
346+
const dtsBundle = dtsOutput.bundles.get(entryPoint.bundleName);
347+
if (!item && !esmBundle && !dtsBundle) {
348+
continue;
349+
}
126350

127-
bundleResults.set(name, {
351+
const previousBundleResult = getPreviousResult(entryPoint.name);
352+
bundleResults.set(entryPoint.name, {
128353
esmModuleIds: esmBundle?.moduleIds ?? previousBundleResult?.esmModuleIds ?? new Set(),
129354
dtsModuleIds: dtsBundle?.moduleIds ?? previousBundleResult?.dtsModuleIds ?? new Set(),
130355
esmOutputFiles: esmBundle?.outputFiles ?? previousBundleResult?.esmOutputFiles ?? new Set(),
@@ -140,6 +365,15 @@ export async function bundleEntryPoints(
140365
};
141366
}
142367

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+
143377
/**
144378
* Generates the Rolldown input mapping object from the specified entry points.
145379
*
@@ -153,9 +387,7 @@ function resolveEntryInputMap(
153387
): Record<string, string> {
154388
const input: Record<string, string> = {};
155389
for (const { bundleName, entryFilePath } of entryPoints) {
156-
input[bundleName] = dtsMode
157-
? entryFilePath.replace(/\.([cm]?ts)$/, '.d.$1')
158-
: entryFilePath.replace(/\.([cm]?)ts$/, '.$1js');
390+
input[bundleName] = resolveEntryInputPath(entryFilePath, dtsMode);
159391
}
160392

161393
return input;
@@ -174,6 +406,8 @@ function createMemoryFileLoaderPlugin(
174406
extensions: readonly string[],
175407
includeMap: boolean,
176408
): Plugin {
409+
const isDts = extensions === DTS_EXTENSIONS;
410+
177411
return {
178412
name: 'memory-file-loader',
179413
resolveId: {
@@ -187,28 +421,27 @@ function createMemoryFileLoaderPlugin(
187421
return files.has(id) ? { id, external: false } : undefined;
188422
}
189423

190-
if (id[0] !== '.' && !path.posix.isAbsolute(id)) {
424+
if (id[0] !== '.' && !path.isAbsolute(id)) {
191425
return { id, external: true };
192426
}
193427

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

196-
let resolvedCandidate: string | undefined;
197-
if (files.has(resolved)) {
198-
resolvedCandidate = resolved;
199-
} else {
200-
const base = resolved.replace(/\.[cm]?js$/, '');
201-
for (const ext of extensions) {
202-
const candidate = base + ext;
203-
if (files.has(candidate)) {
204-
resolvedCandidate = candidate;
205-
break;
206-
}
207-
}
431+
if (files.has(normalizedResolved)) {
432+
return { id: normalizedResolved, external: false };
208433
}
209434

210-
if (resolvedCandidate) {
211-
return { id: resolvedCandidate, external: false };
435+
const base = normalizedResolved.replace(/\.m?js$/, '');
436+
if (isDts && normalizedResolved.endsWith('.mjs') && files.has(`${base}.d.mts`)) {
437+
return { id: `${base}.d.mts`, external: false };
438+
}
439+
440+
for (const ext of extensions) {
441+
const candidate = base + ext;
442+
if (files.has(candidate)) {
443+
return { id: candidate, external: false };
444+
}
212445
}
213446

214447
return undefined;

0 commit comments

Comments
 (0)