From 4e8c3651584faaeceeafc064bf51e8f9e3441e1d Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:51:02 +0000 Subject: [PATCH] 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. --- .../builders/library/pipeline/build-action.ts | 16 +- .../src/builders/library/pipeline/bundler.ts | 293 +++++++++-- .../builders/library/pipeline/bundler_spec.ts | 488 ++++++++++++++++++ 3 files changed, 762 insertions(+), 35 deletions(-) create mode 100644 packages/angular/build/src/builders/library/pipeline/bundler_spec.ts diff --git a/packages/angular/build/src/builders/library/pipeline/build-action.ts b/packages/angular/build/src/builders/library/pipeline/build-action.ts index 9d579e58a76a..30c18fac39c6 100644 --- a/packages/angular/build/src/builders/library/pipeline/build-action.ts +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -103,11 +103,17 @@ export async function buildAction(actionContext: BuildActionContext): Promise, b: ReadonlySet): 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( + selectedEntryPoints: readonly NormalizedEntryPoint[], + allEntryPoints: readonly NormalizedEntryPoint[], + isAvailable: (ep: NormalizedEntryPoint) => boolean, + getModuleIds: (ep: NormalizedEntryPoint) => ReadonlySet | undefined, +): readonly NormalizedEntryPoint[] { + if (selectedEntryPoints.length >= allEntryPoints.length) { + return selectedEntryPoints; + } + + let trackedModuleIds: ReadonlySet | undefined; + let mutableTrackedIds: Set | undefined; + + if (selectedEntryPoints.length === 1) { + trackedModuleIds = getModuleIds(selectedEntryPoints[0]); + } else { + mutableTrackedIds = new Set(); + 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 | undefined; + const singleSelectedName = + selectedEntryPoints.length === 1 ? selectedEntryPoints[0].name : undefined; + + 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)); + 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 | undefined, +): boolean { + for (const ep of bundledEntryPoints) { + const newIds = output.bundles.get(ep.bundleName)?.moduleIds; + 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, + dtsMode: boolean, + getPreviousModuleIds: (ep: NormalizedEntryPoint) => ReadonlySet | undefined, + bundleFn: ( + entryPoints: readonly NormalizedEntryPoint[], + files: ReadonlyMap, + options: NormalizedLibraryOptions, + ) => Promise, + options: NormalizedLibraryOptions, +): Promise { + 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(); + 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( @@ -96,35 +293,63 @@ export async function bundleEntryPoints( esmFiles: ReadonlyMap, dtsFiles: ReadonlyMap, options: NormalizedLibraryOptions, + previousBundleResults?: ReadonlyMap, ): Promise { const bundleResults = new Map(); 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 { const input: Record = {}; 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; diff --git a/packages/angular/build/src/builders/library/pipeline/bundler_spec.ts b/packages/angular/build/src/builders/library/pipeline/bundler_spec.ts new file mode 100644 index 000000000000..09e3fa5e0619 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/bundler_spec.ts @@ -0,0 +1,488 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../types'; +import { bundleEntryPoints } from './bundler'; + +describe('bundler pipeline', () => { + const workspaceRoot = '/workspace'; + const projectRoot = '/workspace/projects/my-lib'; + const outputPath = '/workspace/dist/my-lib'; + + function createEntryPoint( + name = '.', + bundleName = 'my-lib', + entryFilePath = `${projectRoot}/src/public-api.ts`, + ): NormalizedEntryPoint { + return { + subpath: name === '.' ? '.' : `./${name}`, + name, + displayName: name === '.' ? 'my-lib' : `my-lib/${name}`, + bundleName, + entryFilePath, + isPrimary: name === '.', + }; + } + + function createOptions( + entryPoints: readonly NormalizedEntryPoint[], + overrides: Partial = {}, + ): NormalizedLibraryOptions { + return { + workspaceRoot, + projectRoot, + outputPath, + preserveSymlinks: false, + declarationMap: false, + entryPoints: new Map(entryPoints.map((ep) => [ep.name, ep])), + ...overrides, + } as unknown as NormalizedLibraryOptions; + } + + describe('memory-file-loader resolution', () => { + it('should resolve relative directory imports with trailing slashes', async () => { + const primary = createEntryPoint(); + const options = createOptions([primary]); + + const esmFiles = new Map([ + [ + `${projectRoot}/src/public-api.js`, + `export * from './dir/'; +export * from './nested///';`, + ], + [`${projectRoot}/src/dir/index.js`, `export const DIR_VALUE = 'from-dir';`], + [`${projectRoot}/src/nested/index.mjs`, `export const NESTED_VALUE = 'from-nested';`], + ]); + + const dtsFiles = new Map([ + [ + `${projectRoot}/src/public-api.d.ts`, + `export * from './dir/'; +export * from './nested///';`, + ], + [`${projectRoot}/src/dir/index.d.ts`, `export declare const DIR_VALUE: string;`], + [`${projectRoot}/src/nested/index.d.mts`, `export declare const NESTED_VALUE: string;`], + ]); + + const output = await bundleEntryPoints( + [{ entryPoint: primary, hasEsmChanges: true, hasDtsChanges: true }], + esmFiles, + dtsFiles, + options, + ); + + expect(output.warnings).toEqual([]); + expect(output.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'fesm2022/my-lib.mjs', + contents: jasmine.stringContaining('from-dir'), + }), + ); + expect(output.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'fesm2022/my-lib.mjs', + contents: jasmine.stringContaining('from-nested'), + }), + ); + expect(output.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'types/my-lib.d.ts', + contents: jasmine.stringContaining('DIR_VALUE'), + }), + ); + expect(output.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'types/my-lib.d.ts', + contents: jasmine.stringContaining('NESTED_VALUE'), + }), + ); + }); + + it('should prefer .d.mts for .mjs specifiers in d.ts mode', async () => { + const primary = createEntryPoint(); + const options = createOptions([primary]); + + const esmFiles = new Map([ + [`${projectRoot}/src/public-api.js`, `export * from './mod-esm.mjs';`], + [`${projectRoot}/src/mod-esm.mjs`, `export const FROM_MJS = 1;`], + ]); + + const dtsFiles = new Map([ + [`${projectRoot}/src/public-api.d.ts`, `export * from './mod-esm.mjs';`], + // Both .d.ts and .d.mts exist; .mjs specifier should pick .d.mts + [`${projectRoot}/src/mod-esm.d.ts`, `export declare const WRONG_ESM: number;`], + [`${projectRoot}/src/mod-esm.d.mts`, `export declare const FROM_MJS: number;`], + ]); + + const output = await bundleEntryPoints( + [{ entryPoint: primary, hasEsmChanges: true, hasDtsChanges: true }], + esmFiles, + dtsFiles, + options, + ); + + expect(output.warnings).toEqual([]); + expect(output.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'types/my-lib.d.ts', + contents: jasmine.stringContaining('FROM_MJS'), + }), + ); + expect(output.filesToEmit).not.toContain( + jasmine.objectContaining({ + path: 'types/my-lib.d.ts', + contents: jasmine.stringContaining('WRONG_ESM'), + }), + ); + }); + }); + + describe('watch mode shared chunk consistency', () => { + it('should preserve shared chunks across entry points during a partial watch rebuild', async () => { + const primary = createEntryPoint('.', 'my-lib', `${projectRoot}/src/public-api.ts`); + const secondary = createEntryPoint( + 'secondary', + 'my-lib-secondary', + `${projectRoot}/secondary/src/public-api.ts`, + ); + const options = createOptions([primary, secondary]); + + const esmFiles = new Map([ + [`${projectRoot}/src/shared.js`, `export const SHARED_TOKEN = 'shared-v1';`], + [ + `${projectRoot}/src/public-api.js`, + `import { SHARED_TOKEN } from './shared'; +export const PRIMARY = 'primary-' + SHARED_TOKEN;`, + ], + [ + `${projectRoot}/secondary/src/public-api.js`, + `import { SHARED_TOKEN } from '../../src/shared'; +export const SECONDARY = 'secondary-' + SHARED_TOKEN;`, + ], + ]); + + const dtsFiles = new Map([ + [`${projectRoot}/src/shared.d.ts`, `export interface SharedItem { id: string; }`], + [ + `${projectRoot}/src/public-api.d.ts`, + `import type { SharedItem } from './shared'; +export declare const PRIMARY: SharedItem;`, + ], + [ + `${projectRoot}/secondary/src/public-api.d.ts`, + `import type { SharedItem } from '../../src/shared'; +export declare const SECONDARY: SharedItem;`, + ], + ]); + + // 1. Initial full build with both entry points + const initialOutput = await bundleEntryPoints( + [ + { entryPoint: primary, hasEsmChanges: true, hasDtsChanges: true }, + { entryPoint: secondary, hasEsmChanges: true, hasDtsChanges: true }, + ], + esmFiles, + dtsFiles, + options, + ); + + expect(initialOutput.filesToEmit).toContain( + jasmine.objectContaining({ + path: jasmine.stringMatching(/^fesm2022\/shared-[^/]+\.mjs$/), + }), + ); + expect(initialOutput.filesToEmit).toContain( + jasmine.objectContaining({ + path: jasmine.stringMatching(/^types\/shared-[^/]+\.d\.ts$/), + }), + ); + + // 2. Partial watch rebuild where only the primary entry point has changes + esmFiles.set( + `${projectRoot}/src/public-api.js`, + `import { SHARED_TOKEN } from './shared'; +export const PRIMARY = 'primary-updated-' + SHARED_TOKEN;`, + ); + dtsFiles.set( + `${projectRoot}/src/public-api.d.ts`, + `import type { SharedItem } from './shared'; +export declare const PRIMARY: SharedItem; +export declare const EXTRA: boolean;`, + ); + + const rebuildOutput = await bundleEntryPoints( + [ + { + entryPoint: primary, + hasEsmChanges: true, + hasDtsChanges: true, + previousBundleResult: initialOutput.bundleResults.get('.'), + }, + ], + esmFiles, + dtsFiles, + options, + initialOutput.bundleResults, + ); + + expect(rebuildOutput.filesToEmit).toContain( + jasmine.objectContaining({ + path: jasmine.stringMatching(/^fesm2022\/shared-[^/]+\.mjs$/), + }), + ); + expect(rebuildOutput.filesToEmit).toContain( + jasmine.objectContaining({ + path: jasmine.stringMatching(/^types\/shared-[^/]+\.d\.ts$/), + }), + ); + expect(rebuildOutput.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'fesm2022/my-lib.mjs', + contents: jasmine.stringContaining('primary-updated-'), + }), + ); + expect(rebuildOutput.filesToEmit).not.toContain( + jasmine.objectContaining({ + path: 'fesm2022/my-lib.mjs', + contents: jasmine.stringContaining('shared-v1'), + }), + ); + expect(rebuildOutput.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'fesm2022/my-lib-secondary.mjs', + }), + ); + expect(rebuildOutput.filesToEmit).not.toContain( + jasmine.objectContaining({ + path: 'fesm2022/my-lib-secondary.mjs', + contents: jasmine.stringContaining('shared-v1'), + }), + ); + + expect(rebuildOutput.bundleResults.get('.')?.esmOutputFiles).toContain( + jasmine.stringMatching(/^fesm2022\/shared-[^/]+\.mjs$/), + ); + expect(rebuildOutput.bundleResults.get('secondary')?.esmOutputFiles).toContain( + jasmine.stringMatching(/^fesm2022\/shared-[^/]+\.mjs$/), + ); + expect(rebuildOutput.bundleResults.get('.')?.dtsOutputFiles).toContain( + jasmine.stringMatching(/^types\/shared-[^/]+\.d\.ts$/), + ); + expect(rebuildOutput.bundleResults.get('secondary')?.dtsOutputFiles).toContain( + jasmine.stringMatching(/^types\/shared-[^/]+\.d\.ts$/), + ); + }); + + it('should update non-dirty entry point esmOutputFiles while preserving dtsOutputFiles during an ESM-only watch rebuild', async () => { + const primary = createEntryPoint('.', 'my-lib', `${projectRoot}/src/public-api.ts`); + const secondary = createEntryPoint( + 'secondary', + 'my-lib-secondary', + `${projectRoot}/secondary/src/public-api.ts`, + ); + const options = createOptions([primary, secondary]); + + const esmFiles = new Map([ + [`${projectRoot}/src/shared.js`, `export const SHARED_TOKEN = 'shared-v1';`], + [ + `${projectRoot}/src/public-api.js`, + `import { SHARED_TOKEN } from './shared'; +export const PRIMARY = 'primary-' + SHARED_TOKEN;`, + ], + [ + `${projectRoot}/secondary/src/public-api.js`, + `import { SHARED_TOKEN } from '../../src/shared'; +export const SECONDARY = 'secondary-' + SHARED_TOKEN;`, + ], + ]); + + const dtsFiles = new Map([ + [`${projectRoot}/src/shared.d.ts`, `export interface SharedItem { id: string; }`], + [ + `${projectRoot}/src/public-api.d.ts`, + `import type { SharedItem } from './shared'; +export declare const PRIMARY: SharedItem;`, + ], + [ + `${projectRoot}/secondary/src/public-api.d.ts`, + `import type { SharedItem } from '../../src/shared'; +export declare const SECONDARY: SharedItem;`, + ], + ]); + + const initialOutput = await bundleEntryPoints( + [ + { entryPoint: primary, hasEsmChanges: true, hasDtsChanges: true }, + { entryPoint: secondary, hasEsmChanges: true, hasDtsChanges: true }, + ], + esmFiles, + dtsFiles, + options, + ); + + const initialSecondaryResult = initialOutput.bundleResults.get('secondary'); + expect(initialSecondaryResult).toBeDefined(); + + // ESM-only modification on primary entry point (no .d.ts change) + esmFiles.set( + `${projectRoot}/src/public-api.js`, + `import { SHARED_TOKEN } from './shared'; +export const PRIMARY = 'primary-esm-only-' + SHARED_TOKEN;`, + ); + + const rebuildOutput = await bundleEntryPoints( + [ + { + entryPoint: primary, + hasEsmChanges: true, + hasDtsChanges: false, + previousBundleResult: initialOutput.bundleResults.get('.'), + }, + ], + esmFiles, + dtsFiles, + options, + initialOutput.bundleResults, + ); + + const updatedSecondaryResult = rebuildOutput.bundleResults.get('secondary'); + expect(updatedSecondaryResult).toBeDefined(); + expect(updatedSecondaryResult?.esmOutputFiles).toContain('fesm2022/my-lib-secondary.mjs'); + expect(updatedSecondaryResult?.esmOutputFiles).toContain( + jasmine.stringMatching(/^fesm2022\/shared-[^/]+\.mjs$/), + ); + expect(updatedSecondaryResult?.dtsOutputFiles).toEqual( + initialSecondaryResult?.dtsOutputFiles, + ); + expect(updatedSecondaryResult?.dtsModuleIds).toEqual(initialSecondaryResult?.dtsModuleIds); + }); + + it('should extract a shared chunk when a dirty entry point newly imports an existing module', async () => { + const primary = createEntryPoint('.', 'my-lib', `${projectRoot}/src/public-api.ts`); + const secondary = createEntryPoint( + 'secondary', + 'my-lib-secondary', + `${projectRoot}/secondary/src/public-api.ts`, + ); + const options = createOptions([primary, secondary]); + + const esmFiles = new Map([ + [`${projectRoot}/src/shared.js`, `export const SHARED_TOKEN = 'shared-v1';`], + [ + `${projectRoot}/src/public-api.js`, + `import { SHARED_TOKEN } from './shared'; +export const PRIMARY = 'primary-' + SHARED_TOKEN;`, + ], + [ + `${projectRoot}/secondary/src/public-api.js`, + `export const SECONDARY = 'secondary-only';`, + ], + ]); + + const dtsFiles = new Map([ + [`${projectRoot}/src/shared.d.ts`, `export interface SharedItem { id: string; }`], + [ + `${projectRoot}/src/public-api.d.ts`, + `import type { SharedItem } from './shared'; +export declare const PRIMARY: SharedItem;`, + ], + [`${projectRoot}/secondary/src/public-api.d.ts`, `export declare const SECONDARY: string;`], + ]); + + // 1. Initial build: only primary imports shared, so shared is inlined into primary and no shared chunk exists + const initialOutput = await bundleEntryPoints( + [ + { entryPoint: primary, hasEsmChanges: true, hasDtsChanges: true }, + { entryPoint: secondary, hasEsmChanges: true, hasDtsChanges: true }, + ], + esmFiles, + dtsFiles, + options, + ); + + expect(initialOutput.filesToEmit).not.toContain( + jasmine.objectContaining({ + path: jasmine.stringMatching(/^fesm2022\/shared-[^/]+\.mjs$/), + }), + ); + expect(initialOutput.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'fesm2022/my-lib.mjs', + contents: jasmine.stringContaining('shared-v1'), + }), + ); + + // 2. Watch rebuild: secondary is modified to also import shared; only secondary is dirty + esmFiles.set( + `${projectRoot}/secondary/src/public-api.js`, + `import { SHARED_TOKEN } from '../../src/shared'; +export const SECONDARY = 'secondary-' + SHARED_TOKEN;`, + ); + dtsFiles.set( + `${projectRoot}/secondary/src/public-api.d.ts`, + `import type { SharedItem } from '../../src/shared'; +export declare const SECONDARY: SharedItem;`, + ); + + const rebuildOutput = await bundleEntryPoints( + [ + { + entryPoint: secondary, + hasEsmChanges: true, + hasDtsChanges: true, + previousBundleResult: initialOutput.bundleResults.get('secondary'), + }, + ], + esmFiles, + dtsFiles, + options, + initialOutput.bundleResults, + ); + + expect(rebuildOutput.filesToEmit).toContain( + jasmine.objectContaining({ + path: jasmine.stringMatching(/^fesm2022\/shared-[^/]+\.mjs$/), + }), + ); + expect(rebuildOutput.filesToEmit).toContain( + jasmine.objectContaining({ + path: jasmine.stringMatching(/^types\/shared-[^/]+\.d\.ts$/), + }), + ); + expect(rebuildOutput.filesToEmit).toContain( + jasmine.objectContaining({ + path: 'fesm2022/my-lib.mjs', + }), + ); + expect(rebuildOutput.filesToEmit).not.toContain( + jasmine.objectContaining({ + path: 'fesm2022/my-lib.mjs', + contents: jasmine.stringContaining('shared-v1'), + }), + ); + expect(rebuildOutput.filesToEmit).not.toContain( + jasmine.objectContaining({ + path: 'fesm2022/my-lib-secondary.mjs', + contents: jasmine.stringContaining('shared-v1'), + }), + ); + expect(rebuildOutput.bundleResults.get('.')?.esmOutputFiles).toContain( + jasmine.stringMatching(/^fesm2022\/shared-[^/]+\.mjs$/), + ); + expect(rebuildOutput.bundleResults.get('secondary')?.esmOutputFiles).toContain( + jasmine.stringMatching(/^fesm2022\/shared-[^/]+\.mjs$/), + ); + expect(rebuildOutput.bundleResults.get('.')?.dtsOutputFiles).toContain( + jasmine.stringMatching(/^types\/shared-[^/]+\.d\.ts$/), + ); + expect(rebuildOutput.bundleResults.get('secondary')?.dtsOutputFiles).toContain( + jasmine.stringMatching(/^types\/shared-[^/]+\.d\.ts$/), + ); + }); + }); +});