Skip to content

Commit d609a21

Browse files
authored
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 92033ce commit d609a21

4 files changed

Lines changed: 712 additions & 36 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: 212 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -81,50 +81,224 @@ interface MultiBundleOutput {
8181
warnings: string[];
8282
}
8383

84+
/**
85+
* Expands the selected entry points to include any available entry points whose module IDs
86+
* transitively overlap with the selected set.
87+
*/
88+
function expandOverlappingEntryPoints(
89+
selectedEntryPoints: readonly NormalizedEntryPoint[],
90+
allEntryPoints: readonly NormalizedEntryPoint[],
91+
isAvailable: (ep: NormalizedEntryPoint) => boolean,
92+
getModuleIds: (ep: NormalizedEntryPoint) => ReadonlySet<string> | undefined,
93+
): readonly NormalizedEntryPoint[] {
94+
assert(
95+
selectedEntryPoints.length <= allEntryPoints.length,
96+
'Selected entry points cannot exceed all entry points.',
97+
);
98+
if (selectedEntryPoints.length === allEntryPoints.length) {
99+
return selectedEntryPoints;
100+
}
101+
102+
const selected = new Set(selectedEntryPoints);
103+
for (const ep of selected) {
104+
const moduleIds = getModuleIds(ep);
105+
if (!moduleIds?.size) {
106+
continue;
107+
}
108+
109+
for (const candidate of allEntryPoints) {
110+
if (selected.has(candidate) || !isAvailable(candidate)) {
111+
continue;
112+
}
113+
114+
const candidateIds = getModuleIds(candidate);
115+
if (candidateIds && !moduleIds.isDisjointFrom(candidateIds)) {
116+
selected.add(candidate);
117+
}
118+
}
119+
}
120+
121+
return selected.size > selectedEntryPoints.length
122+
? allEntryPoints.filter((ep) => selected.has(ep))
123+
: selectedEntryPoints;
124+
}
125+
126+
/**
127+
* Checks whether any bundled entry point acquired a module ID that was not present in its
128+
* previous bundle result.
129+
*/
130+
function hasNewlyAddedModules(
131+
bundledEntryPoints: readonly NormalizedEntryPoint[],
132+
output: MultiBundleOutput,
133+
getPreviousModuleIds: (ep: NormalizedEntryPoint) => ReadonlySet<string> | undefined,
134+
): boolean {
135+
for (const ep of bundledEntryPoints) {
136+
const bundle = output.bundles.get(ep.bundleName);
137+
assert(bundle, `Expected bundle output for entry point "${ep.bundleName}".`);
138+
139+
const prevIds = getPreviousModuleIds(ep);
140+
if (!prevIds) {
141+
return true;
142+
}
143+
144+
for (const id of bundle.moduleIds) {
145+
if (!prevIds.has(id)) {
146+
return true;
147+
}
148+
}
149+
}
150+
151+
return false;
152+
}
153+
154+
/**
155+
* Bundles dirty entry points for a single mode (ESM or DTS), automatically expanding the entry
156+
* point set before or after bundling when module IDs overlap across entry points.
157+
*/
158+
async function bundleModeWithSharedModules(
159+
dirtyEntryPoints: readonly NormalizedEntryPoint[],
160+
allEntryPoints: readonly NormalizedEntryPoint[],
161+
files: ReadonlyMap<string, string>,
162+
dtsMode: boolean,
163+
getPreviousModuleIds: (ep: NormalizedEntryPoint) => ReadonlySet<string> | undefined,
164+
bundleFn: (
165+
entryPoints: readonly NormalizedEntryPoint[],
166+
files: ReadonlyMap<string, string>,
167+
options: NormalizedLibraryOptions,
168+
) => Promise<MultiBundleOutput>,
169+
options: NormalizedLibraryOptions,
170+
): Promise<MultiBundleOutput> {
171+
if (dirtyEntryPoints.length === 0) {
172+
return { filesToEmit: [], bundles: new Map(), warnings: [] };
173+
}
174+
175+
if (dirtyEntryPoints.length === allEntryPoints.length) {
176+
return bundleFn(dirtyEntryPoints, files, options);
177+
}
178+
179+
const isAvailable = (ep: NormalizedEntryPoint): boolean =>
180+
files.has(resolveEntryInputPath(ep.entryFilePath, dtsMode));
181+
182+
const initialEntryPoints = expandOverlappingEntryPoints(
183+
dirtyEntryPoints,
184+
allEntryPoints,
185+
isAvailable,
186+
getPreviousModuleIds,
187+
);
188+
const output = await bundleFn(initialEntryPoints, files, options);
189+
190+
if (
191+
initialEntryPoints.length < allEntryPoints.length &&
192+
hasNewlyAddedModules(initialEntryPoints, output, getPreviousModuleIds)
193+
) {
194+
const expandedEntryPoints = expandOverlappingEntryPoints(
195+
initialEntryPoints,
196+
allEntryPoints,
197+
isAvailable,
198+
(ep) => output.bundles.get(ep.bundleName)?.moduleIds ?? getPreviousModuleIds(ep),
199+
);
200+
201+
if (expandedEntryPoints.length > initialEntryPoints.length) {
202+
return bundleFn(expandedEntryPoints, files, options);
203+
}
204+
}
205+
206+
return output;
207+
}
208+
209+
/**
210+
* Collects all normalized entry points from options and bundle input items.
211+
*/
212+
function getAllEntryPoints(
213+
items: readonly BundleEntryPointInput[],
214+
options: NormalizedLibraryOptions,
215+
): NormalizedEntryPoint[] {
216+
const byName = new Map<string, NormalizedEntryPoint>();
217+
if (options.entryPoints) {
218+
for (const ep of options.entryPoints.values()) {
219+
byName.set(ep.name, ep);
220+
}
221+
}
222+
for (const item of items) {
223+
byName.set(item.entryPoint.name, item.entryPoint);
224+
}
225+
226+
return Array.from(byName.values());
227+
}
228+
84229
/**
85230
* 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).
231+
* using at most 2 Rolldown instances per pass (1 for all .mjs bundles, 1 for all .d.ts bundles).
87232
*
88233
* @param items The collection of entry point inputs with change statuses.
89234
* @param esmFiles Map of virtual POSIX ESM output file paths to their content.
90235
* @param dtsFiles Map of virtual POSIX TypeScript declaration file paths to their content.
91236
* @param options The normalized library builder options with POSIX paths.
237+
* @param previousBundleResults Optional map of entry point names to their previous bundle results.
92238
* @returns A promise resolving to the bundled output files and bundle cache results.
93239
*/
94240
export async function bundleEntryPoints(
95241
items: readonly BundleEntryPointInput[],
96242
esmFiles: ReadonlyMap<string, string>,
97243
dtsFiles: ReadonlyMap<string, string>,
98244
options: NormalizedLibraryOptions,
245+
previousBundleResults?: ReadonlyMap<string, BundleResult>,
99246
): Promise<BundleEntryPointsOutput> {
100247
const bundleResults = new Map<string, BundleResult>();
101248
if (items.length === 0) {
102249
return { filesToEmit: [], bundleResults, warnings: [] };
103250
}
104251

252+
const allEntryPoints = getAllEntryPoints(items, options);
253+
const itemsByName = new Map(items.map((item) => [item.entryPoint.name, item]));
254+
const getPreviousResult = (name: string): BundleResult | undefined =>
255+
itemsByName.get(name)?.previousBundleResult ?? previousBundleResults?.get(name);
256+
105257
const esmEntryPoints: NormalizedEntryPoint[] = [];
106258
const dtsEntryPoints: NormalizedEntryPoint[] = [];
107259

108260
for (const item of items) {
109-
if (item.hasEsmChanges || !item.previousBundleResult) {
110-
esmEntryPoints.push(item.entryPoint);
261+
const { entryPoint } = item;
262+
const previousBundleResult = getPreviousResult(entryPoint.name);
263+
if (item.hasEsmChanges || !previousBundleResult) {
264+
esmEntryPoints.push(entryPoint);
111265
}
112-
if (item.hasDtsChanges || !item.previousBundleResult) {
113-
dtsEntryPoints.push(item.entryPoint);
266+
if (item.hasDtsChanges || !previousBundleResult) {
267+
dtsEntryPoints.push(entryPoint);
114268
}
115269
}
116270

117271
const [esmOutput, dtsOutput] = await Promise.all([
118-
bundleAllEsm(esmEntryPoints, esmFiles, options),
119-
bundleAllDts(dtsEntryPoints, dtsFiles, options),
272+
bundleModeWithSharedModules(
273+
esmEntryPoints,
274+
allEntryPoints,
275+
esmFiles,
276+
false,
277+
(ep) => getPreviousResult(ep.name)?.esmModuleIds,
278+
bundleAllEsm,
279+
options,
280+
),
281+
bundleModeWithSharedModules(
282+
dtsEntryPoints,
283+
allEntryPoints,
284+
dtsFiles,
285+
true,
286+
(ep) => getPreviousResult(ep.name)?.dtsModuleIds,
287+
bundleAllDts,
288+
options,
289+
),
120290
]);
121291

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);
292+
for (const entryPoint of allEntryPoints) {
293+
const item = itemsByName.get(entryPoint.name);
294+
const esmBundle = esmOutput.bundles.get(entryPoint.bundleName);
295+
const dtsBundle = dtsOutput.bundles.get(entryPoint.bundleName);
296+
if (!item && !esmBundle && !dtsBundle) {
297+
continue;
298+
}
126299

127-
bundleResults.set(name, {
300+
const previousBundleResult = getPreviousResult(entryPoint.name);
301+
bundleResults.set(entryPoint.name, {
128302
esmModuleIds: esmBundle?.moduleIds ?? previousBundleResult?.esmModuleIds ?? new Set(),
129303
dtsModuleIds: dtsBundle?.moduleIds ?? previousBundleResult?.dtsModuleIds ?? new Set(),
130304
esmOutputFiles: esmBundle?.outputFiles ?? previousBundleResult?.esmOutputFiles ?? new Set(),
@@ -140,6 +314,15 @@ export async function bundleEntryPoints(
140314
};
141315
}
142316

317+
/**
318+
* Resolves the compiled in-memory entry file path for a TypeScript entry point.
319+
*/
320+
function resolveEntryInputPath(entryFilePath: string, dtsMode: boolean): string {
321+
return dtsMode
322+
? entryFilePath.replace(/\.(m?ts)$/, '.d.$1')
323+
: entryFilePath.replace(/\.(m?)ts$/, '.$1js');
324+
}
325+
143326
/**
144327
* Generates the Rolldown input mapping object from the specified entry points.
145328
*
@@ -153,9 +336,7 @@ function resolveEntryInputMap(
153336
): Record<string, string> {
154337
const input: Record<string, string> = {};
155338
for (const { bundleName, entryFilePath } of entryPoints) {
156-
input[bundleName] = dtsMode
157-
? entryFilePath.replace(/\.([cm]?ts)$/, '.d.$1')
158-
: entryFilePath.replace(/\.([cm]?)ts$/, '.$1js');
339+
input[bundleName] = resolveEntryInputPath(entryFilePath, dtsMode);
159340
}
160341

161342
return input;
@@ -174,6 +355,8 @@ function createMemoryFileLoaderPlugin(
174355
extensions: readonly string[],
175356
includeMap: boolean,
176357
): Plugin {
358+
const isDts = extensions === DTS_EXTENSIONS;
359+
177360
return {
178361
name: 'memory-file-loader',
179362
resolveId: {
@@ -187,28 +370,27 @@ function createMemoryFileLoaderPlugin(
187370
return files.has(id) ? { id, external: false } : undefined;
188371
}
189372

190-
if (id[0] !== '.' && !path.posix.isAbsolute(id)) {
373+
if (id[0] !== '.' && !path.isAbsolute(id)) {
191374
return { id, external: true };
192375
}
193376

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

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-
}
380+
if (files.has(normalizedResolved)) {
381+
return { id: normalizedResolved, external: false };
382+
}
383+
384+
const base = normalizedResolved.replace(/\.m?js$/, '');
385+
if (isDts && normalizedResolved.endsWith('.mjs') && files.has(`${base}.d.mts`)) {
386+
return { id: `${base}.d.mts`, external: false };
208387
}
209388

210-
if (resolvedCandidate) {
211-
return { id: resolvedCandidate, external: false };
389+
for (const ext of extensions) {
390+
const candidate = base + ext;
391+
if (files.has(candidate)) {
392+
return { id: candidate, external: false };
393+
}
212394
}
213395

214396
return undefined;

0 commit comments

Comments
 (0)