Repository navigation
fix(@angular/build): improve library bundler resolution and watch shared chunk consistency #34440
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: Why are we special casing If I'm following this correctly, I think the below |
||
|
|
||
| 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)); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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(), | ||
|
|
@@ -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. | ||
| * | ||
|
|
@@ -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; | ||
|
|
@@ -174,6 +406,8 @@ function createMemoryFileLoaderPlugin( | |
| extensions: readonly string[], | ||
| includeMap: boolean, | ||
| ): Plugin { | ||
| const isDts = extensions === DTS_EXTENSIONS; | ||
|
|
||
| return { | ||
| name: 'memory-file-loader', | ||
| resolveId: { | ||
|
|
@@ -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; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: Can we use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/intersection?