diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index bf6c034292df..d0ab55750e98 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -153,6 +153,7 @@ ts_project( ":build", ":node_modules/@angular-devkit/core", ":node_modules/@babel/core", + ":node_modules/@parcel/watcher", ":node_modules/oxc-parser", "//:node_modules/@angular/compiler-cli", "//:node_modules/@angular/router", diff --git a/packages/angular/build/src/utils/watcher.ts b/packages/angular/build/src/utils/watcher.ts index 610051c5dcc7..ca0e86f987ff 100644 --- a/packages/angular/build/src/utils/watcher.ts +++ b/packages/angular/build/src/utils/watcher.ts @@ -419,89 +419,364 @@ export function extractNodeModulesPackageDir(posixPath: string): string | undefi return `${nodeModulesPath}/${segments[0]}`; } -class ParcelExternalManager { - private readonly extraSubscriptions = new Map(); - private readonly pendingSubscriptions = new Map< - string, - Promise - >(); - private readonly externalDirFiles = new Map }>(); +/** + * Maximum number of concurrent external directory subscriptions before coalescing + * sibling/cousin directories into common ancestor directories. + * Kept well below macOS's ~512 per-process FSEvents stream limit. + */ +const DEFAULT_MAX_EXTERNAL_SUBSCRIPTIONS = 256; + +/** + * Node in a directory prefix tree used to subsume nested directories + * and coalesce sibling/cousin directories into common ancestors. + */ +interface DirTrieNode { + /** Case-normalized POSIX path key for set/map lookups. */ + dirKey: string; + + /** Original-case POSIX directory path passed to `@parcel/watcher`. */ + dirPath: string; + + /** Depth of this directory node from the filesystem root (0 at root). */ + depth: number; + + /** Parent directory node, or `undefined` if this node is a filesystem root. */ + parent: DirTrieNode | undefined; + + /** Child directory nodes keyed by their `dirKey`. */ + children: Map; + + /** Whether this directory is selected as an active subscription target. */ + isTarget: boolean; + + /** Number of active target leaf directories within this node's subtree. */ + leafCount: number; +} + +/** + * Checks whether any strict ancestor of the given trie node is already marked as a target. + */ +function hasTargetAncestor(node: DirTrieNode): boolean { + let current = node.parent; + while (current) { + if (current.isTarget) { + return true; + } + current = current.parent; + } + + return false; +} + +/** + * Manages `@parcel/watcher` directory subscriptions for watched files located outside the + * primary `workspaceRoot`. + * + * Because `@parcel/watcher` watches directories recursively and macOS limits each process to + * ~512 concurrent `FSEvents` streams, this manager: + * - Normalizes external `node_modules` file paths to their package root directory. + * - Batches synchronous `watchFile` and `removeFile` calls within a microtask before subscribing. + * - Uses a directory prefix tree to subsume nested child directories and iteratively coalesce + * deepest branching subdirectories into common ancestors when the subscription budget is exceeded. + * - Dynamically lowers its subscription budget and coalesces further if `subscribe()` fails on + * an existing directory due to OS stream limits. + */ +export class ParcelExternalManager { + /** Active `@parcel/watcher` subscriptions keyed by subscribed directory lookup key. */ + readonly #extraSubscriptions = new Map(); + + /** Reference-counted watched files grouped by their target external directory lookup key. */ + readonly #externalDirFiles = new Map }>(); + + /** Reverse lookup mapping each watched file lookup key to its target directory lookup key. */ + readonly #fileToDirKey = new Map(); + + /** The `@parcel/watcher` module instance. */ + readonly #parcelWatcher: typeof ParcelWatcher; + + /** Watcher configuration options including ignore patterns. */ + readonly #options: WatcherOptions | undefined; + + /** Normalized POSIX path of the primary watched workspace root. */ + readonly #rootDirPosix: string; + + /** Case-normalized lookup key of the primary watched workspace root. */ + readonly #rootDirLookupKey: string; + + /** Whether the underlying filesystem is case-sensitive. */ + readonly #isCaseSensitive: boolean; + + /** Callback invoked when `@parcel/watcher` emits filesystem events. */ + readonly #handleEvents: (events: ParcelWatcher.Event[]) => void; + + /** Current maximum number of concurrent external directory subscriptions allowed before coalescing. */ + #maxSubscriptions: number; + + /** Whether external directory state has changed and requires a reconciliation pass. */ + #needsReconcile = false; + + /** Whether obsolete subscriptions should be unsubscribed before creating new ones (used after stream limit errors). */ + #shrinkBeforeSubscribe = false; + /** In-flight promise for the currently scheduled or running reconciliation loop. */ + #reconcilePromise: Promise | undefined; + + /** Whether this manager has been closed. */ + #isClosed = false; + + /** + * Creates a new external directory subscription manager for `@parcel/watcher`. + * + * @param parcelWatcher The `@parcel/watcher` module. + * @param options Watcher configuration options. + * @param rootDirPosix Normalized POSIX path of the primary workspace root. + * @param rootDirLookupKey Case-normalized lookup key of the primary workspace root. + * @param isCaseSensitive Whether the filesystem at the workspace root is case-sensitive. + * @param handleEvents Callback to process incoming `@parcel/watcher` events. + * @param maxSubscriptions Maximum number of concurrent external directory subscriptions before coalescing. + */ constructor( - private readonly parcelWatcher: typeof ParcelWatcher, - private readonly options: WatcherOptions | undefined, - private readonly rootDirLookupKey: string, - private readonly handleEvents: (events: ParcelWatcher.Event[]) => void, - ) {} + parcelWatcher: typeof ParcelWatcher, + options: WatcherOptions | undefined, + rootDirPosix: string, + rootDirLookupKey: string, + isCaseSensitive: boolean, + handleEvents: (events: ParcelWatcher.Event[]) => void, + maxSubscriptions = DEFAULT_MAX_EXTERNAL_SUBSCRIPTIONS, + ) { + this.#parcelWatcher = parcelWatcher; + this.#options = options; + this.#rootDirPosix = rootDirPosix; + this.#rootDirLookupKey = rootDirLookupKey; + this.#isCaseSensitive = isCaseSensitive; + this.#handleEvents = handleEvents; + this.#maxSubscriptions = Math.max(1, maxSubscriptions); + } - async ensureWatched(posixPath: string, lookupKey: string): Promise { - if (isPathInside(lookupKey, this.rootDirLookupKey) || lookupKey === this.rootDirLookupKey) { + /** + * Registers a file path to be watched if it resides outside the primary workspace root, + * and schedules an asynchronous subscription reconciliation pass if needed. + * + * @param posixPath Normalized POSIX file path preserving original casing. + * @param lookupKey Case-normalized lookup key for the file path. + */ + watchFile(posixPath: string, lookupKey: string): void { + if ( + this.#isClosed || + isPathInside(lookupKey, this.#rootDirLookupKey) || + lookupKey === this.#rootDirLookupKey + ) { return; } - const dirPath = getDirectoryPath(posixPath); - const dirKey = getDirectoryPath(lookupKey); - let dirEntry = this.externalDirFiles.get(dirKey); + const pkgDir = extractNodeModulesPackageDir(posixPath); + const dirPath = pkgDir ?? getDirectoryPath(posixPath); + const dirKey = pkgDir + ? toLookupKey(pkgDir, this.#isCaseSensitive) + : getDirectoryPath(lookupKey); + + this.#fileToDirKey.set(lookupKey, dirKey); + + let dirEntry = this.#externalDirFiles.get(dirKey); + const isNewDir = !dirEntry; if (!dirEntry) { dirEntry = { dirPath, files: new Set() }; - this.externalDirFiles.set(dirKey, dirEntry); + this.#externalDirFiles.set(dirKey, dirEntry); } dirEntry.files.add(lookupKey); - await this.ensureDirWatched(dirPath, dirKey); + if (isNewDir || !this.#isCoveredByActiveSubscription(dirKey)) { + this.#scheduleReconcile(); + } } + /** + * Unregisters a previously watched external file and schedules a reconciliation pass + * if it was the last watched file in its target directory. + * + * @param lookupKey Case-normalized lookup key for the file path to remove. + */ removeFile(lookupKey: string): void { - if (isPathInside(lookupKey, this.rootDirLookupKey) || lookupKey === this.rootDirLookupKey) { + const dirKey = this.#fileToDirKey.get(lookupKey); + if (!dirKey) { return; } - const dirKey = getDirectoryPath(lookupKey); - const dirEntry = this.externalDirFiles.get(dirKey); + this.#fileToDirKey.delete(lookupKey); + const dirEntry = this.#externalDirFiles.get(dirKey); if (dirEntry) { dirEntry.files.delete(lookupKey); if (dirEntry.files.size === 0) { - this.externalDirFiles.delete(dirKey); - const sub = this.extraSubscriptions.get(dirKey); - if (sub) { - this.extraSubscriptions.delete(dirKey); - sub.unsubscribe().catch(() => {}); - - for (const [remainingDirKey, remainingDirEntry] of this.externalDirFiles.entries()) { - if (!this.isCoveredByExistingExternal(remainingDirKey)) { - this.ensureDirWatched(remainingDirEntry.dirPath, remainingDirKey).catch(() => {}); - } - } - } + this.#externalDirFiles.delete(dirKey); + this.#scheduleReconcile(); } } } + /** + * Waits for any scheduled or in-progress subscription reconciliation pass to complete. + */ + async flush(): Promise { + if (this.#reconcilePromise) { + await this.#reconcilePromise; + } + } + + /** + * Closes the manager, awaiting any pending reconciliation and unsubscribing all active + * external directory subscriptions. + */ async close(): Promise { + this.#isClosed = true; + this.#needsReconcile = false; try { - if (this.pendingSubscriptions.size > 0) { - await Promise.allSettled(Array.from(this.pendingSubscriptions.values())); + if (this.#reconcilePromise) { + await this.#reconcilePromise.catch(() => {}); } - if (this.extraSubscriptions.size > 0) { + if (this.#extraSubscriptions.size > 0) { await Promise.allSettled( - Array.from(this.extraSubscriptions.values()).map((sub) => sub.unsubscribe()), + Array.from(this.#extraSubscriptions.values(), (sub) => sub.unsubscribe()), ); } } finally { - this.extraSubscriptions.clear(); - this.pendingSubscriptions.clear(); - this.externalDirFiles.clear(); + this.#extraSubscriptions.clear(); + this.#externalDirFiles.clear(); + this.#fileToDirKey.clear(); } } - private isCoveredByExistingExternal(dirLookupKey: string): boolean { - for (const existingDir of this.extraSubscriptions.keys()) { - if (dirLookupKey === existingDir || isPathInside(dirLookupKey, existingDir)) { - return true; + /** + * Schedules a microtask to reconcile active `@parcel/watcher` subscriptions with the + * current set of external directories. + */ + #scheduleReconcile(): void { + if (this.#isClosed) { + return; + } + + this.#needsReconcile = true; + this.#reconcilePromise ??= Promise.resolve() + .then(() => this.#runReconcileLoop()) + .finally(() => { + this.#reconcilePromise = undefined; + }); + } + + /** + * Continuously executes reconciliation passes until no further changes are pending. + */ + async #runReconcileLoop(): Promise { + while (this.#needsReconcile && !this.#isClosed) { + this.#needsReconcile = false; + await this.#reconcileOnce(); + } + } + + /** + * Performs a single reconciliation pass: computes the desired coalesced directory set, + * subscribes to newly required directories, unsubscribes obsolete directories, and + * triggers fallback coalescing if OS stream limits are encountered. + */ + async #reconcileOnce(): Promise { + const desiredDirs = this.#computeDesiredDirectories(); + + if (this.#shrinkBeforeSubscribe) { + this.#shrinkBeforeSubscribe = false; + const toUnsubscribe: Promise[] = []; + for (const [existingKey, sub] of this.#extraSubscriptions.entries()) { + if (!desiredDirs.has(existingKey)) { + this.#extraSubscriptions.delete(existingKey); + toUnsubscribe.push(sub.unsubscribe().catch(() => {})); + } + } + if (toUnsubscribe.length > 0) { + await Promise.allSettled(toUnsubscribe); } } - for (const pendingDir of this.pendingSubscriptions.keys()) { - if (dirLookupKey === pendingDir || isPathInside(dirLookupKey, pendingDir)) { + + if (this.#isClosed) { + return; + } + + const toSubscribe: [string, string][] = []; + for (const [dirKey, dirPath] of desiredDirs.entries()) { + if (!this.#extraSubscriptions.has(dirKey)) { + toSubscribe.push([dirKey, dirPath]); + } + } + + let hadExistingDirFailure = false; + if (toSubscribe.length > 0) { + await Promise.allSettled( + toSubscribe.map(async ([dirKey, dirPath]) => { + const ignore = isPathInside(this.#rootDirLookupKey, dirKey) + ? [...(this.#options?.ignored ?? []), this.#rootDirPosix, `${this.#rootDirPosix}/**`] + : this.#options?.ignored; + + try { + const sub = await this.#parcelWatcher.subscribe( + dirPath, + (err, events) => { + if (!err) { + this.#handleEvents(events); + } + }, + { ignore }, + ); + + if (this.#isClosed) { + await sub.unsubscribe().catch(() => {}); + + return; + } + + this.#extraSubscriptions.set(dirKey, sub); + } catch { + // Ignore errors for missing directories; if the directory exists on disk, + // mark failure so the manager can coalesce further if over OS stream limits. + if (!this.#isClosed && fs.existsSync(dirPath)) { + hadExistingDirFailure = true; + } + } + }), + ); + } + + if (this.#isClosed) { + return; + } + + // Unsubscribe any previous subscriptions that are no longer desired and whose + // remaining watched files (if any) are now covered by another active subscription. + for (const [existingKey, sub] of this.#extraSubscriptions.entries()) { + if (!desiredDirs.has(existingKey) && this.#canRemoveSubscription(existingKey)) { + this.#extraSubscriptions.delete(existingKey); + sub.unsubscribe().catch(() => {}); + } + } + + // If subscribing to an existing directory failed (e.g. macOS FSEvents per-process limit), + // dynamically lower the subscription budget and re-reconcile to coalesce into ancestors. + if (hadExistingDirFailure && desiredDirs.size > 1 && this.#maxSubscriptions > 1) { + const nextMax = + this.#extraSubscriptions.size > 0 + ? Math.max(1, Math.floor(this.#extraSubscriptions.size / 2)) + : Math.max(1, Math.floor(Math.min(this.#maxSubscriptions, desiredDirs.size) / 2)); + + if (nextMax < desiredDirs.size && nextMax < this.#maxSubscriptions) { + this.#maxSubscriptions = nextMax; + this.#shrinkBeforeSubscribe = true; + this.#needsReconcile = true; + } + } + } + + /** + * Checks whether a directory lookup key is already covered by an active subscription. + */ + #isCoveredByActiveSubscription(dirLookupKey: string): boolean { + for (const existingDir of this.#extraSubscriptions.keys()) { + if (dirLookupKey === existingDir || isPathInside(dirLookupKey, existingDir)) { return true; } } @@ -509,47 +784,196 @@ class ParcelExternalManager { return false; } - private async ensureDirWatched(dirPath: string, dirKey: string): Promise { - if (this.isCoveredByExistingExternal(dirKey)) { - return; + /** + * Determines whether an existing subscription can be safely removed without leaving + * any remaining watched target directories uncovered. + */ + #canRemoveSubscription(subKey: string): boolean { + for (const targetDirKey of this.#externalDirFiles.keys()) { + if (targetDirKey === subKey || isPathInside(targetDirKey, subKey)) { + let coveredByOther = false; + for (const otherSubKey of this.#extraSubscriptions.keys()) { + if ( + otherSubKey !== subKey && + (targetDirKey === otherSubKey || isPathInside(targetDirKey, otherSubKey)) + ) { + coveredByOther = true; + break; + } + } + if (!coveredByOther) { + return false; + } + } + } + + return true; + } + + /** + * Computes the minimal set of external directories to subscribe to (mapped from `dirKey` to `dirPath`), + * subsuming nested directories and coalescing branching ancestors to stay within `#maxSubscriptions`. + */ + #computeDesiredDirectories(): Map { + if (this.#externalDirFiles.size === 0) { + return new Map(); } - const subPromise = this.parcelWatcher.subscribe( - dirPath, - (err, events) => { - if (!err) { - this.handleEvents(events); + const nodesByKey = new Map(); + const roots: DirTrieNode[] = []; + + for (const [dirKey, { dirPath }] of this.#externalDirFiles.entries()) { + const chain: { dirKey: string; dirPath: string }[] = []; + let currentKey = dirKey; + let currentPath = dirPath; + while (true) { + chain.push({ dirKey: currentKey, dirPath: currentPath }); + const parentKey = getDirectoryPath(currentKey); + if (parentKey === currentKey) { + break; } - }, - { - ignore: this.options?.ignored, - }, - ); + currentKey = parentKey; + currentPath = getDirectoryPath(currentPath); + } - this.pendingSubscriptions.set(dirKey, subPromise); + let parentNode: DirTrieNode | undefined; + for (let i = chain.length - 1; i >= 0; i--) { + const { dirKey: nodeKey, dirPath: nodePath } = chain[i]; + const depth = chain.length - 1 - i; + let node = nodesByKey.get(nodeKey); + if (!node) { + node = { + dirKey: nodeKey, + dirPath: nodePath, + depth, + parent: parentNode, + children: new Map(), + isTarget: false, + leafCount: 0, + }; + nodesByKey.set(nodeKey, node); + if (parentNode) { + parentNode.children.set(nodeKey, node); + } else { + roots.push(node); + } + } + parentNode = node; + } - try { - const sub = await subPromise; - this.pendingSubscriptions.delete(dirKey); + if (parentNode) { + parentNode.isTarget = true; + } + } - if (this.externalDirFiles.has(dirKey) && !this.isCoveredByExistingExternal(dirKey)) { - this.extraSubscriptions.set(dirKey, sub); + const initSubtree = (node: DirTrieNode): number => { + if (node.isTarget) { + node.children.clear(); + node.leafCount = 1; - // Subsume any nested child subscriptions that are now covered by this parent subscription - for (const [childDir, childSub] of this.extraSubscriptions.entries()) { - if (childDir !== dirKey && isPathInside(childDir, dirKey)) { - this.extraSubscriptions.delete(childDir); - childSub.unsubscribe().catch(() => {}); + return 1; + } + + let count = 0; + for (const child of node.children.values()) { + count += initSubtree(child); + } + node.leafCount = count; + + return count; + }; + + let totalLeaves = 0; + for (const root of roots) { + totalLeaves += initSubtree(root); + } + + if (totalLeaves > this.#maxSubscriptions) { + // Tier 1: non-ancestors of workspaceRoot (e.g., /libs, /node_modules) + // Tier 2: ancestors of workspaceRoot (e.g., when sibling apps/libs are directly under it) + const tier1ByDepth = new Map(); + const tier2ByDepth = new Map(); + + const collectInternalNodes = (node: DirTrieNode) => { + if (node.isTarget) { + return; + } + if (node.parent !== undefined && node.leafCount > 1) { + const isWorkspaceAncestor = isPathInside(this.#rootDirLookupKey, node.dirKey); + const bucketMap = isWorkspaceAncestor ? tier2ByDepth : tier1ByDepth; + let bucket = bucketMap.get(node.depth); + if (!bucket) { + bucket = []; + bucketMap.set(node.depth, bucket); } + bucket.push(node); } - } else { - sub.unsubscribe().catch(() => {}); + for (const child of node.children.values()) { + collectInternalNodes(child); + } + }; + + for (const root of roots) { + collectInternalNodes(root); + } + + const collapseTier = (byDepth: Map) => { + const sortedLevels = Array.from(byDepth.entries()).sort(([a], [b]) => b - a); + for (const [, nodes] of sortedLevels) { + if (totalLeaves <= this.#maxSubscriptions) { + return; + } + + const candidates = nodes + .filter((n) => n.leafCount > 1 && !hasTargetAncestor(n)) + .sort((a, b) => b.leafCount - a.leafCount || a.dirKey.localeCompare(b.dirKey)); + + for (const candidate of candidates) { + if (totalLeaves <= this.#maxSubscriptions) { + return; + } + if (candidate.leafCount <= 1 || hasTargetAncestor(candidate)) { + continue; + } + + const reduction = candidate.leafCount - 1; + candidate.isTarget = true; + candidate.children.clear(); + candidate.leafCount = 1; + totalLeaves -= reduction; + + let ancestor = candidate.parent; + while (ancestor) { + ancestor.leafCount -= reduction; + ancestor = ancestor.parent; + } + } + } + }; + + collapseTier(tier1ByDepth); + if (totalLeaves > this.#maxSubscriptions) { + collapseTier(tier2ByDepth); } - } catch { - // Ignore subscription errors for missing or restricted external directories - } finally { - this.pendingSubscriptions.delete(dirKey); } + + const desired = new Map(); + const collectTargets = (node: DirTrieNode) => { + if (node.isTarget) { + desired.set(node.dirKey, node.dirPath); + + return; + } + for (const child of node.children.values()) { + collectTargets(child); + } + }; + + for (const root of roots) { + collectTargets(root); + } + + return desired; } } @@ -609,7 +1033,9 @@ async function createParcelWatcher( const externalManager = new ParcelExternalManager( parcelWatcher, options, + rootDirPosix, rootDirLookupKey, + isCaseSensitive, handleEvents, ); @@ -629,7 +1055,7 @@ async function createParcelWatcher( const lookupKey = toLookupKey(posixPath, isCaseSensitive); if (!watchedFiles.has(lookupKey)) { watchedFiles.add(lookupKey); - void externalManager.ensureWatched(posixPath, lookupKey); + externalManager.watchFile(posixPath, lookupKey); } } }, diff --git a/packages/angular/build/src/utils/watcher_spec.ts b/packages/angular/build/src/utils/watcher_spec.ts index 547ad2695f90..3703eb9f4ce1 100644 --- a/packages/angular/build/src/utils/watcher_spec.ts +++ b/packages/angular/build/src/utils/watcher_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type * as ParcelWatcher from '@parcel/watcher'; import assert from 'node:assert'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -13,6 +14,7 @@ import { setTimeout } from 'node:timers/promises'; import { type BuildWatcher, ChangedFiles, + ParcelExternalManager, createWatcher, extractNodeModulesPackageDir, getDirectoryPath, @@ -829,5 +831,364 @@ describe('Watcher', () => { fs.rmSync(externalDir, { recursive: true, force: true }); } }, 10000); + + it('should detect changes across multiple external directories when using @parcel/watcher', async () => { + const externalRoot = fs.mkdtempSync(path.join(TMP_DIR, 'parcel-external-spec-')); + let watcher: BuildWatcher | undefined; + + try { + const lib1Dir = path.join(externalRoot, 'libs', 'lib-1', 'src'); + const lib2Dir = path.join(externalRoot, 'libs', 'lib-2', 'src'); + fs.mkdirSync(lib1Dir, { recursive: true }); + fs.mkdirSync(lib2Dir, { recursive: true }); + + const file1 = path.join(lib1Dir, 'index.ts'); + const file2 = path.join(lib2Dir, 'index.ts'); + fs.writeFileSync(file1, 'export const a = 1;'); + fs.writeFileSync(file2, 'export const b = 1;'); + + // Backdate initial file timestamps to earlier than `initTime - 1000` so that any + // delayed initial FSEvents replay from file creation is ignored by the watcher. + const pastTime = new Date(Date.now() - 5000); + fs.utimesSync(file1, pastTime, pastTime); + fs.utimesSync(file2, pastTime, pastTime); + + watcher = await createWatcher({ cwd: tempDir }); + watcher.add([file1, file2]); + + await setTimeout(150); + + const iterator = watcher[Symbol.asyncIterator](); + + let nextPromise = iterator.next(); + fs.writeFileSync(file1, 'export const a = 2;'); + let result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('lib-1'))).toBeTrue(); + + nextPromise = iterator.next(); + fs.writeFileSync(file2, 'export const b = 2;'); + result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('lib-2'))).toBeTrue(); + } finally { + await watcher?.close(); + fs.rmSync(externalRoot, { recursive: true, force: true }); + } + }, 10000); + }); + + describe('ParcelExternalManager', () => { + function createMockParcelWatcher(options?: { + failAfter?: number; + onSubscribe?: (dir: string, opts?: ParcelWatcher.Options) => void; + }) { + const activeDirs = new Set(); + const subscribeHistory: { dir: string; options?: ParcelWatcher.Options }[] = []; + let totalCalls = 0; + + const mockWatcher = { + subscribe: jasmine + .createSpy('subscribe') + .and.callFake( + async ( + dir: string, + _cb: ParcelWatcher.SubscribeCallback, + opts?: ParcelWatcher.Options, + ): Promise => { + totalCalls++; + options?.onSubscribe?.(dir, opts); + if (options?.failAfter !== undefined && activeDirs.size >= options.failAfter) { + throw new Error('Error starting FSEvents stream'); + } + activeDirs.add(dir); + subscribeHistory.push({ dir, options: opts }); + + return { + unsubscribe: async () => { + activeDirs.delete(dir); + }, + }; + }, + ), + } as unknown as typeof ParcelWatcher; + + return { + mockWatcher, + activeDirs, + subscribeHistory, + getTotalCalls: () => totalCalls, + }; + } + + it('should batch synchronous watchFile calls and subsume child directories added before parent', async () => { + const { mockWatcher, activeDirs, getTotalCalls } = createMockParcelWatcher(); + const manager = new ParcelExternalManager( + mockWatcher, + undefined, + '/monorepo/apps/app', + '/monorepo/apps/app', + true, + () => {}, + ); + + // Add deeper child directories first, then their parent directory in the same synchronous turn + manager.watchFile( + '/monorepo/libs/lib-1/src/components/button/button.ts', + '/monorepo/libs/lib-1/src/components/button/button.ts', + ); + manager.watchFile( + '/monorepo/libs/lib-1/src/components/card/card.ts', + '/monorepo/libs/lib-1/src/components/card/card.ts', + ); + manager.watchFile('/monorepo/libs/lib-1/src/index.ts', '/monorepo/libs/lib-1/src/index.ts'); + + await manager.flush(); + + // Should only ever have subscribed once to the parent '/monorepo/libs/lib-1/src' + expect(getTotalCalls()).toBe(1); + expect(Array.from(activeDirs)).toEqual(['/monorepo/libs/lib-1/src']); + + await manager.close(); + }); + + it('should normalize external node_modules paths to their package root directory', async () => { + const { mockWatcher, activeDirs, getTotalCalls } = createMockParcelWatcher(); + const manager = new ParcelExternalManager( + mockWatcher, + undefined, + '/monorepo/apps/app', + '/monorepo/apps/app', + true, + () => {}, + ); + + manager.watchFile( + '/monorepo/node_modules/my-pkg/fesm2022/my-pkg.mjs', + '/monorepo/node_modules/my-pkg/fesm2022/my-pkg.mjs', + ); + manager.watchFile( + '/monorepo/node_modules/my-pkg/esm2022/sub/helper.mjs', + '/monorepo/node_modules/my-pkg/esm2022/sub/helper.mjs', + ); + manager.watchFile( + '/monorepo/node_modules/@scope/scoped-pkg/fesm2022/index.mjs', + '/monorepo/node_modules/@scope/scoped-pkg/fesm2022/index.mjs', + ); + manager.watchFile( + '/monorepo/node_modules/@scope/scoped-pkg/package.json', + '/monorepo/node_modules/@scope/scoped-pkg/package.json', + ); + + await manager.flush(); + + expect(getTotalCalls()).toBe(2); + expect(Array.from(activeDirs).sort()).toEqual([ + '/monorepo/node_modules/@scope/scoped-pkg', + '/monorepo/node_modules/my-pkg', + ]); + + // Removing one file from my-pkg should keep the package subscription active + manager.removeFile('/monorepo/node_modules/my-pkg/fesm2022/my-pkg.mjs'); + await manager.flush(); + expect(activeDirs.has('/monorepo/node_modules/my-pkg')).toBeTrue(); + + // Removing the last file from my-pkg should unsubscribe the package directory + manager.removeFile('/monorepo/node_modules/my-pkg/esm2022/sub/helper.mjs'); + await manager.flush(); + expect(activeDirs.has('/monorepo/node_modules/my-pkg')).toBeFalse(); + expect(Array.from(activeDirs)).toEqual(['/monorepo/node_modules/@scope/scoped-pkg']); + + await manager.close(); + }); + + it('should coalesce large numbers of external library and node_modules directories into common ancestors within budget', async () => { + const { mockWatcher, activeDirs, getTotalCalls } = createMockParcelWatcher(); + const manager = new ParcelExternalManager( + mockWatcher, + undefined, + '/monorepo/apps/app', + '/monorepo/apps/app', + true, + () => {}, + 64, + ); + + // Simulate 600 sibling libraries and 200 hoisted node_modules packages (800 external dirs) + for (let i = 0; i < 600; i++) { + const file = `/monorepo/libs/lib-${i}/src/index.ts`; + manager.watchFile(file, file); + } + for (let i = 0; i < 200; i++) { + const file = `/monorepo/node_modules/pkg-${i}/dist/index.mjs`; + manager.watchFile(file, file); + } + + await manager.flush(); + + // All 600 libs coalesce into '/monorepo/libs' and all 200 packages coalesce into '/monorepo/node_modules' + expect(getTotalCalls()).toBe(2); + expect(Array.from(activeDirs).sort()).toEqual(['/monorepo/libs', '/monorepo/node_modules']); + + await manager.close(); + }); + + it('should prioritize collapsing deeper branching subdirectories before broader ancestor directories', async () => { + const { mockWatcher, activeDirs } = createMockParcelWatcher(); + const manager = new ParcelExternalManager( + mockWatcher, + undefined, + '/monorepo/apps/app', + '/monorepo/apps/app', + true, + () => {}, + 3, // Budget of 3 subscriptions + ); + + // lib-1 has 4 component subdirectories, lib-2 and lib-3 have 1 each (total = 6 leaf dirs) + for (let c = 1; c <= 4; c++) { + const file = `/monorepo/libs/lib-1/src/comp-${c}/comp.ts`; + manager.watchFile(file, file); + } + manager.watchFile('/monorepo/libs/lib-2/src/index.ts', '/monorepo/libs/lib-2/src/index.ts'); + manager.watchFile('/monorepo/libs/lib-3/src/index.ts', '/monorepo/libs/lib-3/src/index.ts'); + + await manager.flush(); + + // Collapsing lib-1/src reduces total leaves from 6 to 3, meeting the budget of 3 + // without needing to collapse all of '/monorepo/libs' + expect(Array.from(activeDirs).sort()).toEqual([ + '/monorepo/libs/lib-1/src', + '/monorepo/libs/lib-2/src', + '/monorepo/libs/lib-3/src', + ]); + + await manager.close(); + }); + + it('should ignore workspaceRoot when coalescing into an ancestor of workspaceRoot (Tier 2)', async () => { + const { mockWatcher, activeDirs, subscribeHistory } = createMockParcelWatcher(); + const manager = new ParcelExternalManager( + mockWatcher, + { ignored: ['/monorepo/apps/app/dist'] }, + '/monorepo/apps/app', + '/monorepo/apps/app', + true, + () => {}, + 2, // Budget of 2 subscriptions + ); + + // Sibling apps/packages directly under /monorepo/apps (which is an ancestor of /monorepo/apps/app) + manager.watchFile( + '/monorepo/apps/shared-1/src/index.ts', + '/monorepo/apps/shared-1/src/index.ts', + ); + manager.watchFile( + '/monorepo/apps/shared-2/src/index.ts', + '/monorepo/apps/shared-2/src/index.ts', + ); + manager.watchFile( + '/monorepo/apps/shared-3/src/index.ts', + '/monorepo/apps/shared-3/src/index.ts', + ); + + await manager.flush(); + + expect(Array.from(activeDirs)).toEqual(['/monorepo/apps']); + expect(subscribeHistory.length).toBe(1); + expect(subscribeHistory[0].options?.ignore).toEqual([ + '/monorepo/apps/app/dist', + '/monorepo/apps/app', + '/monorepo/apps/app/**', + ]); + + await manager.close(); + }); + + it('should dynamically lower budget and coalesce into ancestor when subscribe fails with FSEvents stream limit error', async () => { + const externalRoot = fs.mkdtempSync(path.join(TMP_DIR, 'parcel-fallback-spec-')); + + try { + const rootPosix = toPosixPathNormalized(path.join(externalRoot, 'apps', 'app')); + const libFiles: string[] = []; + for (let i = 0; i < 6; i++) { + const libDir = path.join(externalRoot, 'libs', `lib-${i}`, 'src'); + fs.mkdirSync(libDir, { recursive: true }); + const file = toPosixPathNormalized(path.join(libDir, 'index.ts')); + fs.writeFileSync(file, 'export const x = 1;'); + libFiles.push(file); + } + + // Allow at most 2 active FSEvents streams even though manager budget starts at 64 + const { mockWatcher, activeDirs } = createMockParcelWatcher({ failAfter: 2 }); + const manager = new ParcelExternalManager( + mockWatcher, + undefined, + rootPosix, + rootPosix, + true, + () => {}, + 64, + ); + + for (const file of libFiles) { + manager.watchFile(file, file); + } + + await manager.flush(); + + // Should have shrunk and coalesced the 6 libraries into the single '/libs' ancestor directory + const expectedLibsDir = toPosixPathNormalized(path.join(externalRoot, 'libs')); + expect(Array.from(activeDirs)).toEqual([expectedLibsDir]); + + await manager.close(); + } finally { + fs.rmSync(externalRoot, { recursive: true, force: true }); + } + }); + + it('should uncoalesce and subscribe to remaining leaf directories when removals bring count back within budget', async () => { + const { mockWatcher, activeDirs } = createMockParcelWatcher(); + const manager = new ParcelExternalManager( + mockWatcher, + undefined, + '/monorepo/apps/app', + '/monorepo/apps/app', + true, + () => {}, + 2, // Budget of 2 subscriptions + ); + + const file1 = '/monorepo/libs/lib-1/src/index.ts'; + const file2 = '/monorepo/libs/lib-2/src/index.ts'; + const file3 = '/monorepo/libs/lib-3/src/index.ts'; + + manager.watchFile(file1, file1); + manager.watchFile(file2, file2); + manager.watchFile(file3, file3); + + await manager.flush(); + + // 3 directories exceed budget of 2, so they coalesce into '/monorepo/libs' + expect(Array.from(activeDirs)).toEqual(['/monorepo/libs']); + + // Removing file3 brings remaining directories to 2, which fits within budget + manager.removeFile(file3); + await manager.flush(); + + expect(Array.from(activeDirs).sort()).toEqual([ + '/monorepo/libs/lib-1/src', + '/monorepo/libs/lib-2/src', + ]); + + // Removing the remaining files unsubscribes everything + manager.removeFile(file1); + manager.removeFile(file2); + await manager.flush(); + + expect(activeDirs.size).toBe(0); + + await manager.close(); + }); }); });