From 6feb77419f0e5dde9759a767de9adf27962f1e9e Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:48:01 -0400 Subject: [PATCH 1/4] perf(@angular/build): replace watchpack with @parcel/watcher and chokidar This change replaces the watchpack file watching dependency in @angular/build with @parcel/watcher as the primary native file watcher, while falling back to chokidar (v4) for polling or unsupported environments. By leveraging @parcel/watcher's native C++ bindings (FSEvents, ReadDirectoryChangesW, inotify), file system watching is offloaded directly to OS kernel APIs, significantly reducing CPU and memory footprint during watch mode. Additionally, external directory watches are dynamically subsumed to minimize active native file handles, while early path filtering and event coalescing prevent redundant incremental rebuild triggers. --- package.json | 1 - packages/angular/build/BUILD.bazel | 4 +- packages/angular/build/package.json | 3 +- .../src/builders/application/build-action.ts | 3 +- .../build/src/tools/esbuild/watcher.ts | 485 +++++++++++++++--- .../build/src/tools/esbuild/watcher_spec.ts | 331 ++++++++++++ .../angular_devkit/build_angular/BUILD.bazel | 1 - pnpm-lock.yaml | 170 +++--- 8 files changed, 853 insertions(+), 145 deletions(-) create mode 100644 packages/angular/build/src/tools/esbuild/watcher_spec.ts diff --git a/package.json b/package.json index a0fb8d460599..6c046d5bc17e 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,6 @@ "@types/picomatch": "^4.0.0", "@types/progress": "^2.0.3", "@types/semver": "^7.3.12", - "@types/watchpack": "^2.4.4", "@types/yargs": "^17.0.20", "@types/yargs-parser": "^21.0.0", "@typescript-eslint/eslint-plugin": "8.65.0", diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index 7325ec88d35b..b019f60eab73 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -86,9 +86,11 @@ ts_project( ":node_modules/@babel/helper-split-export-declaration", ":node_modules/@inquirer/confirm", ":node_modules/@oxc-project/types", + ":node_modules/@parcel/watcher", ":node_modules/@vitejs/plugin-basic-ssl", ":node_modules/beasties", ":node_modules/browserslist", + ":node_modules/chokidar", ":node_modules/https-proxy-agent", ":node_modules/istanbul-lib-instrument", ":node_modules/jsonc-parser", @@ -110,7 +112,6 @@ ts_project( ":node_modules/tinyglobby", ":node_modules/vite", ":node_modules/vitest", - ":node_modules/watchpack", "//:node_modules/@angular/common", "//:node_modules/@angular/compiler", "//:node_modules/@angular/compiler-cli", @@ -125,7 +126,6 @@ ts_project( "//:node_modules/@types/node", "//:node_modules/@types/picomatch", "//:node_modules/@types/semver", - "//:node_modules/@types/watchpack", "//:node_modules/esbuild", "//:node_modules/esbuild-wasm", "//:node_modules/karma", diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json index 0c5db3c7478c..bb619647edb8 100644 --- a/packages/angular/build/package.json +++ b/packages/angular/build/package.json @@ -43,7 +43,8 @@ "source-map-support": "0.5.21", "tinyglobby": "0.2.17", "vite": "8.1.5", - "watchpack": "2.5.2" + "@parcel/watcher": "2.5.1", + "chokidar": "4.0.3" }, "optionalDependencies": { "lmdb": "3.5.6" diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts index d7dbdc8cfe21..e4133709e1b8 100644 --- a/packages/angular/build/src/builders/application/build-action.ts +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -113,11 +113,12 @@ export async function* runEsBuildBuildAction( // Setup a watcher const { createWatcher } = await import('../../tools/esbuild/watcher'); - watcher = createWatcher({ + watcher = await createWatcher({ polling: typeof poll === 'number', interval: poll, followSymlinks: preserveSymlinks, ignored, + cwd: workspaceRoot, }); // Setup abort support diff --git a/packages/angular/build/src/tools/esbuild/watcher.ts b/packages/angular/build/src/tools/esbuild/watcher.ts index cf9e1d94cb87..9d22fef7f21d 100644 --- a/packages/angular/build/src/tools/esbuild/watcher.ts +++ b/packages/angular/build/src/tools/esbuild/watcher.ts @@ -6,7 +6,11 @@ * found in the LICENSE file at https://angular.dev/license */ -import WatchPack from 'watchpack'; +import type * as ParcelWatcher from '@parcel/watcher'; +import type * as Chokidar from 'chokidar'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { toPosixPath } from '../../utils/path'; export class ChangedFiles { readonly added = new Set(); @@ -14,7 +18,7 @@ export class ChangedFiles { readonly removed = new Set(); get all(): string[] { - return [...this.added, ...this.modified, ...this.removed]; + return Array.from(new Set([...this.added, ...this.modified, ...this.removed])); } toDebugString(): string { @@ -34,102 +38,461 @@ export interface BuildWatcher extends AsyncIterableIterator { close(): Promise; } -export function createWatcher(options?: { +export interface WatcherOptions { polling?: boolean; interval?: number; ignored?: string[]; followSymlinks?: boolean; -}): BuildWatcher { - const watcher = new WatchPack({ - poll: options?.polling ? (options?.interval ?? true) : false, - ignored: options?.ignored, - followSymlinks: options?.followSymlinks, - aggregateTimeout: 250, - }); + cwd?: string; +} + +/** + * Probes the filesystem at the specified target directory to determine whether it is case-sensitive. + */ +function isFileSystemCaseSensitive(targetDir: string = process.cwd()): boolean { + try { + const resolved = path.resolve(targetDir); + // Invert the casing of the target directory path. + const altCase = + resolved === resolved.toLowerCase() ? resolved.toUpperCase() : resolved.toLowerCase(); + + // If the path contains no alphabetic characters (e.g. root '/'), invert-casing + // produces the exact same string. Fall back to platform-specific defaults in this case. + if (resolved === altCase) { + return process.platform !== 'win32' && process.platform !== 'darwin'; + } + + // If both the original path and the inverted-casing path exist on disk, + // the filesystem is case-insensitive (returns false). + return !fs.existsSync(altCase); + } catch { + // If an error occurs (e.g., permission denied), default to the platform-specific + // behavior (case-insensitive on Windows/macOS, sensitive on Linux/Unix). + return process.platform !== 'win32' && process.platform !== 'darwin'; + } +} + +/** + * Normalizes a file system path string to POSIX format (forward slashes '/') + * and strips trailing slashes (except root '/' or Windows drive root 'C:/'). + */ +export function toPosixPathNormalized(pathString: string): string { + let posixPath = toPosixPath(pathString); + if (posixPath.length > 1 && posixPath.endsWith('/') && !/^[a-zA-Z]:\/$/.test(posixPath)) { + posixPath = posixPath.slice(0, -1); + } + + return posixPath; +} + +/** + * Returns a lookup key for set lookups and matching, lowercasing on case-insensitive file systems. + */ +function toLookupKey(posixPath: string, isCaseSensitive: boolean): string { + return isCaseSensitive ? posixPath : posixPath.toLowerCase(); +} + +/** + * Determines whether a file path lookup key or any of its parent directories are present in watchedFiles. + */ +function isPathWatched(fileLookupKey: string, watchedFiles: Set): boolean { + if (watchedFiles.has(fileLookupKey)) { + return true; + } + + let current = fileLookupKey; + while (true) { + const parent = path.posix.dirname(current); + if (parent === current) { + break; + } + if (watchedFiles.has(parent)) { + return true; + } + current = parent; + } + + return false; +} + +class WatcherQueue { + private readonly nextQueue: ((value?: ChangedFiles) => void)[] = []; + private currentChangedFiles: ChangedFiles | undefined; + private isClosed = false; + + addChange(type: 'added' | 'modified' | 'removed', file: string): void { + if (this.isClosed) { + return; + } + + const changedFiles = (this.currentChangedFiles ??= new ChangedFiles()); + changedFiles[type].add(file); + this.flush(); + } + + addChanges( + changes: ReadonlyArray<{ type: 'added' | 'modified' | 'removed'; file: string }>, + ): void { + if (this.isClosed || changes.length === 0) { + return; + } + + const changedFiles = (this.currentChangedFiles ??= new ChangedFiles()); + for (const { type, file } of changes) { + changedFiles[type].add(file); + } + this.flush(); + } + + private flush(): void { + if ( + this.currentChangedFiles && + this.currentChangedFiles.all.length > 0 && + this.nextQueue.length > 0 + ) { + const next = this.nextQueue.shift(); + if (next) { + const result = this.currentChangedFiles; + this.currentChangedFiles = undefined; + next(result); + } + } + } + + async next(): Promise> { + if ( + this.currentChangedFiles && + this.currentChangedFiles.all.length > 0 && + this.nextQueue.length === 0 + ) { + const result = { value: this.currentChangedFiles }; + this.currentChangedFiles = undefined; + + return result; + } + + if (this.isClosed) { + return { done: true, value: undefined as unknown as ChangedFiles }; + } + + return new Promise((resolve) => { + this.nextQueue.push((value) => + resolve(value ? { value } : { done: true, value: undefined as unknown as ChangedFiles }), + ); + }); + } + + close(): void { + if (this.isClosed) { + return; + } + + this.isClosed = true; + this.currentChangedFiles = undefined; + + let next; + while ((next = this.nextQueue.shift()) !== undefined) { + next(); + } + } +} + +export async function createWatcher(options?: WatcherOptions): Promise { + if (options?.polling) { + return createChokidarWatcher(options); + } + + try { + const parcelWatcher = await import('@parcel/watcher'); + + return await createParcelWatcher(options, parcelWatcher); + } catch { + return createChokidarWatcher(options); + } +} + +/** + * Checks whether a file path is located inside a parent directory. + * + * Input Expectations: + * - Both `file` and `dir` must be normalized POSIX-style paths (using forward slashes '/'). + * - Both paths must share the same casing normalization (e.g., lowercased on case-insensitive file systems). + */ +export function isPathInside(file: string, dir: string): boolean { + if (file === dir) { + return false; + } + + const dirWithSlash = dir.endsWith('/') ? dir : dir + '/'; + + return file.startsWith(dirWithSlash); +} + +async function createParcelWatcher( + options: WatcherOptions | undefined, + parcelWatcher: typeof ParcelWatcher, +): Promise { const watchedFiles = new Set(); + const queue = new WatcherQueue(); + + const isCaseSensitive = isFileSystemCaseSensitive(options?.cwd); + const rootDirPosix = toPosixPathNormalized(options?.cwd ?? process.cwd()); + const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive); + const extraSubscriptions = new Map(); + const pendingSubscriptions = new Map>(); + const externalDirFiles = new Map>(); - const nextQueue: ((value?: ChangedFiles) => void)[] = []; - let currentChangedFiles: ChangedFiles | undefined; + const handleEvents = (events: ParcelWatcher.Event[]) => { + const changes: { type: 'added' | 'modified' | 'removed'; file: string }[] = []; + for (const event of events) { + const posixPath = toPosixPathNormalized(event.path); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!isPathWatched(lookupKey, watchedFiles)) { + continue; + } - watcher.on('aggregated', (changes, removals) => { - const changedFiles = currentChangedFiles ?? new ChangedFiles(); - for (const file of changes) { - changedFiles.modified.add(file); + const type = + event.type === 'create' ? 'added' : event.type === 'delete' ? 'removed' : 'modified'; + changes.push({ type, file: posixPath }); } - for (const file of removals) { - changedFiles.removed.add(file); + + if (changes.length > 0) { + queue.addChanges(changes); } + }; - const next = nextQueue.shift(); - if (next) { - currentChangedFiles = undefined; - next(changedFiles); - } else { - currentChangedFiles = changedFiles; + const subscription = await parcelWatcher.subscribe( + rootDirPosix, + (err, events) => { + if (!err) { + handleEvents(events); + } + }, + { + ignore: options?.ignored, + }, + ); + + const isCoveredByExistingExternal = (dirLookupKey: string): boolean => { + for (const existingDir of extraSubscriptions.keys()) { + if (dirLookupKey === existingDir || isPathInside(dirLookupKey, existingDir)) { + return true; + } } - }); + for (const pendingDir of pendingSubscriptions.keys()) { + if (dirLookupKey === pendingDir || isPathInside(dirLookupKey, pendingDir)) { + return true; + } + } + + return false; + }; + + const ensureExternalWatched = async (posixPath: string, lookupKey: string) => { + if (isPathInside(lookupKey, rootDirLookupKey) || lookupKey === rootDirLookupKey) { + return; + } + + const dirPath = path.posix.dirname(posixPath); + const dirKey = path.posix.dirname(lookupKey); + let dirSet = externalDirFiles.get(dirKey); + if (!dirSet) { + dirSet = new Set(); + externalDirFiles.set(dirKey, dirSet); + } + dirSet.add(lookupKey); - return { + if (isCoveredByExistingExternal(dirKey)) { + return; + } + + const subPromise = parcelWatcher.subscribe( + dirPath, + (err, events) => { + if (!err) { + handleEvents(events); + } + }, + { + ignore: options?.ignored, + }, + ); + + pendingSubscriptions.set(dirKey, subPromise); + + try { + const sub = await subPromise; + if (externalDirFiles.has(dirKey) && !isCoveredByExistingExternal(dirKey)) { + extraSubscriptions.set(dirKey, sub); + + // Subsume any nested child subscriptions that are now covered by this parent subscription + for (const [childDir, childSub] of extraSubscriptions.entries()) { + if (childDir !== dirKey && isPathInside(childDir, dirKey)) { + extraSubscriptions.delete(childDir); + void childSub.unsubscribe(); + } + } + } else { + void sub.unsubscribe(); + } + } catch { + // Ignore subscription errors for missing or restricted external directories + } finally { + pendingSubscriptions.delete(dirKey); + } + }; + + const buildWatcher: BuildWatcher = { [Symbol.asyncIterator]() { return this; }, - async next() { - if (currentChangedFiles && nextQueue.length === 0) { - const result = { value: currentChangedFiles }; - currentChangedFiles = undefined; + next() { + return queue.next(); + }, + + add(paths) { + const targets = typeof paths === 'string' ? [paths] : paths; + for (const file of targets) { + const posixPath = toPosixPathNormalized(file); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!watchedFiles.has(lookupKey)) { + watchedFiles.add(lookupKey); + void ensureExternalWatched(posixPath, lookupKey); + } + } + }, + + remove(paths) { + const targets = typeof paths === 'string' ? [paths] : paths; + for (const file of targets) { + const posixPath = toPosixPathNormalized(file); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (watchedFiles.delete(lookupKey)) { + if (!isPathInside(lookupKey, rootDirLookupKey) && lookupKey !== rootDirLookupKey) { + const dirKey = path.posix.dirname(lookupKey); + const dirSet = externalDirFiles.get(dirKey); + if (dirSet) { + dirSet.delete(lookupKey); + if (dirSet.size === 0) { + externalDirFiles.delete(dirKey); + const sub = extraSubscriptions.get(dirKey); + if (sub) { + extraSubscriptions.delete(dirKey); + void sub.unsubscribe(); + } + } + } + } + } + } + }, - return result; + async close() { + try { + if (subscription) { + await subscription.unsubscribe(); + } + if (pendingSubscriptions.size > 0) { + await Promise.allSettled(Array.from(pendingSubscriptions.values())); + } + for (const sub of extraSubscriptions.values()) { + await sub.unsubscribe(); + } + } finally { + extraSubscriptions.clear(); + pendingSubscriptions.clear(); + externalDirFiles.clear(); + queue.close(); } + }, + }; + + return buildWatcher; +} - return new Promise((resolve) => { - nextQueue.push((value) => resolve(value ? { value } : { done: true, value })); - }); +async function createChokidarWatcher( + options?: WatcherOptions, + chokidarModule?: typeof Chokidar, +): Promise { + const chokidar = chokidarModule ?? (await import('chokidar')); + const watchedFiles = new Set(); + const queue = new WatcherQueue(); + + const rootDir = options?.cwd ?? process.cwd(); + const isCaseSensitive = isFileSystemCaseSensitive(rootDir); + + const watcher = chokidar.watch([], { + ignoreInitial: true, + ignored: options?.ignored, + followSymlinks: options?.followSymlinks, + usePolling: !!options?.polling, + interval: options?.interval, + }); + + const handleEvent = (type: 'added' | 'modified' | 'removed', rawPath: string) => { + const posixPath = toPosixPathNormalized(rawPath); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!isPathWatched(lookupKey, watchedFiles)) { + return; + } + + queue.addChange(type, posixPath); + }; + + watcher.on('add', (path) => handleEvent('added', path)); + watcher.on('change', (path) => handleEvent('modified', path)); + watcher.on('unlink', (path) => handleEvent('removed', path)); + + const buildWatcher: BuildWatcher = { + [Symbol.asyncIterator]() { + return this; + }, + + next() { + return queue.next(); }, add(paths) { - const previousSize = watchedFiles.size; - if (typeof paths === 'string') { - watchedFiles.add(paths); - } else { - for (const file of paths) { - watchedFiles.add(file); + const targets = typeof paths === 'string' ? [paths] : paths; + const newPaths: string[] = []; + for (const p of targets) { + const posixPath = toPosixPathNormalized(p); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!watchedFiles.has(lookupKey)) { + watchedFiles.add(lookupKey); + newPaths.push(posixPath); } } - - if (previousSize !== watchedFiles.size) { - watcher.watch({ - files: watchedFiles, - }); + if (newPaths.length > 0) { + watcher.add(newPaths); } }, remove(paths) { - const previousSize = watchedFiles.size; - if (typeof paths === 'string') { - watchedFiles.delete(paths); - } else { - for (const file of paths) { - watchedFiles.delete(file); + const targets = typeof paths === 'string' ? [paths] : paths; + const removePaths: string[] = []; + for (const p of targets) { + const posixPath = toPosixPathNormalized(p); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (watchedFiles.has(lookupKey)) { + watchedFiles.delete(lookupKey); + removePaths.push(posixPath); } } - - if (previousSize !== watchedFiles.size) { - watcher.watch({ - files: watchedFiles, - }); + if (removePaths.length > 0) { + watcher.unwatch(removePaths); } }, async close() { try { - watcher.close(); + await watcher.close(); } finally { - let next; - while ((next = nextQueue.shift()) !== undefined) { - next(); - } + queue.close(); } }, }; + + return buildWatcher; } diff --git a/packages/angular/build/src/tools/esbuild/watcher_spec.ts b/packages/angular/build/src/tools/esbuild/watcher_spec.ts new file mode 100644 index 000000000000..324778ea9dfa --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/watcher_spec.ts @@ -0,0 +1,331 @@ +/** + * @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 * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ChangedFiles, createWatcher, isPathInside, toPosixPathNormalized } from './watcher'; + +describe('Watcher', () => { + describe('toPosixPathNormalized', () => { + it('should strip trailing slashes for standard directories', () => { + expect(toPosixPathNormalized('/src/app/')).toBe('/src/app'); + expect(toPosixPathNormalized('C:/src/app/')).toBe('C:/src/app'); + }); + + it('should preserve single root slash', () => { + expect(toPosixPathNormalized('/')).toBe('/'); + }); + + it('should preserve trailing slash for Windows drive root', () => { + expect(toPosixPathNormalized('C:/')).toBe('C:/'); + expect(toPosixPathNormalized('c:/')).toBe('c:/'); + }); + }); + + describe('isPathInside', () => { + it('should return true for a file inside a directory', () => { + expect(isPathInside('/src/app/main.ts', '/src/app')).toBeTrue(); + }); + + it('should return false when file and dir are identical', () => { + expect(isPathInside('/src/app', '/src/app')).toBeFalse(); + }); + + it('should return false for sibling directories with matching prefix', () => { + expect(isPathInside('/src/app-other/main.ts', '/src/app')).toBeFalse(); + }); + + it('should handle Windows drive letters on the same drive', () => { + expect(isPathInside('c:/src/app/main.ts', 'c:/src/app')).toBeTrue(); + }); + + it('should return false for Windows drive letters on different drives', () => { + expect(isPathInside('d:/src/app/main.ts', 'c:/src/app')).toBeFalse(); + }); + + it('should handle root directory correctly', () => { + expect(isPathInside('/src/main.ts', '/')).toBeTrue(); + }); + + it('should handle Windows drive root directory correctly', () => { + expect(isPathInside('c:/src/main.ts', 'c:/')).toBeTrue(); + }); + }); + + describe('ChangedFiles', () => { + it('should track added, modified, and removed files', () => { + const changes = new ChangedFiles(); + changes.added.add('/src/app.component.ts'); + changes.modified.add('/src/main.ts'); + changes.removed.add('/src/old.ts'); + + expect(changes.all).toEqual(['/src/app.component.ts', '/src/main.ts', '/src/old.ts']); + }); + + it('should deduplicate files present in multiple sets in .all', () => { + const changes = new ChangedFiles(); + changes.added.add('/src/main.ts'); + changes.modified.add('/src/main.ts'); + + expect(changes.all).toEqual(['/src/main.ts']); + }); + + it('should format debug string correctly', () => { + const changes = new ChangedFiles(); + changes.modified.add('/src/main.ts'); + + const debug = JSON.parse(changes.toDebugString()); + expect(debug).toEqual({ + added: [], + modified: ['/src/main.ts'], + removed: [], + }); + }); + }); + + describe('createWatcher', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watcher-spec-')); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should instantiate and close watcher without error', async () => { + const watcher = await createWatcher({ cwd: tempDir }); + expect(watcher).toBeDefined(); + + watcher.add(path.join(tempDir, 'main.ts')); + watcher.remove(path.join(tempDir, 'main.ts')); + + await watcher.close(); + }); + + it('should support array of paths in add and remove', async () => { + const watcher = await createWatcher({ cwd: tempDir }); + const file1 = path.join(tempDir, 'a.ts'); + const file2 = path.join(tempDir, 'b.ts'); + + watcher.add([file1, file2]); + watcher.remove([file1, file2]); + + await watcher.close(); + }); + + it('should support polling option', async () => { + const watcher = await createWatcher({ polling: true, interval: 100, cwd: tempDir }); + expect(watcher).toBeDefined(); + + watcher.add(path.join(tempDir, 'main.ts')); + await watcher.close(); + }); + + it('should emit changes when a watched file is modified (chokidar polling)', async () => { + const testFile = path.join(tempDir, 'test.txt'); + fs.writeFileSync(testFile, 'initial'); + + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(testFile); + + // Wait a short moment for watcher setup and mtime tick + await new Promise((resolve) => setTimeout(resolve, 100)); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + // Trigger change + fs.writeFileSync(testFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.length).toBeGreaterThan(0); + + await watcher.close(); + }, 10000); + + it('should preserve original path character casing in emitted changes', async () => { + const casedFile = path.join(tempDir, 'App.Component.ts'); + fs.writeFileSync(casedFile, 'initial'); + + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(casedFile); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(casedFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + const emittedFiles = result.value?.all ?? []; + expect(emittedFiles.some((f: string) => f.includes('App.Component.ts'))).toBeTrue(); + + await watcher.close(); + }, 10000); + + it('should emit changes when watching a directory containing modified files', async () => { + const subDir = path.join(tempDir, 'sub'); + fs.mkdirSync(subDir); + const testFile = path.join(subDir, 'nested.txt'); + fs.writeFileSync(testFile, 'initial'); + + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(subDir); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(testFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.length).toBeGreaterThan(0); + + await watcher.close(); + }, 10000); + + it('should support watching paths outside cwd', async () => { + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-')); + const externalFile = path.join(externalDir, 'external.txt'); + fs.writeFileSync(externalFile, 'initial'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(externalFile); + + await new Promise((r) => setTimeout(r, 100)); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(externalFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('external.txt'))).toBeTrue(); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should handle adding multiple external files in the same directory concurrently', async () => { + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-')); + const file1 = path.join(externalDir, 'file1.txt'); + const file2 = path.join(externalDir, 'file2.txt'); + fs.writeFileSync(file1, 'initial1'); + fs.writeFileSync(file2, 'initial2'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add([file1, file2]); + + await new Promise((r) => setTimeout(r, 100)); + + const iterator = watcher[Symbol.asyncIterator](); + let nextPromise = iterator.next(); + fs.writeFileSync(file1, 'updated1'); + let result = await nextPromise; + expect(result.value?.all.some((f: string) => f.includes('file1.txt'))).toBeTrue(); + + nextPromise = iterator.next(); + fs.writeFileSync(file2, 'updated2'); + result = await nextPromise; + expect(result.value?.all.some((f: string) => f.includes('file2.txt'))).toBeTrue(); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should clean up external subscriptions when all external files in a directory are removed', async () => { + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-')); + const file1 = path.join(externalDir, 'file1.txt'); + const file2 = path.join(externalDir, 'file2.txt'); + fs.writeFileSync(file1, 'initial1'); + fs.writeFileSync(file2, 'initial2'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add([file1, file2]); + + await new Promise((r) => setTimeout(r, 100)); + + // Remove files from watcher + watcher.remove(file1); + watcher.remove(file2); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }); + + it('should handle nested external directories without creating duplicate subscriptions', async () => { + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-')); + const subDir = path.join(externalDir, 'sub'); + fs.mkdirSync(subDir); + const parentFile = path.join(externalDir, 'parent.txt'); + const childFile = path.join(subDir, 'child.txt'); + fs.writeFileSync(parentFile, 'initial-parent'); + fs.writeFileSync(childFile, 'initial-child'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(parentFile); + watcher.add(childFile); + + await new Promise((r) => setTimeout(r, 100)); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(childFile, 'updated-child'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('child.txt'))).toBeTrue(); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should signal completion on close', async () => { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + const iterator = watcher[Symbol.asyncIterator](); + + const nextPromise = iterator.next(); + await watcher.close(); + + const result = await nextPromise; + expect(result.done).toBeTrue(); + }); + + it('should return done immediately if next() is called after close()', async () => { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + await watcher.close(); + + const result = await watcher.next(); + expect(result.done).toBeTrue(); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/BUILD.bazel b/packages/angular_devkit/build_angular/BUILD.bazel index 90fc7bf4b1fc..e2d2a269ce0b 100644 --- a/packages/angular_devkit/build_angular/BUILD.bazel +++ b/packages/angular_devkit/build_angular/BUILD.bazel @@ -175,7 +175,6 @@ ts_project( "//:node_modules/@types/node", "//:node_modules/@types/picomatch", "//:node_modules/@types/semver", - "//:node_modules/@types/watchpack", "//:node_modules/esbuild", "//:node_modules/esbuild-wasm", "//:node_modules/karma", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 261a4eef6cfc..f83e23654a90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,9 +154,6 @@ importers: '@types/semver': specifier: ^7.3.12 version: 7.7.1 - '@types/watchpack': - specifier: ^2.4.4 - version: 2.4.5 '@types/yargs': specifier: ^17.0.20 version: 17.0.35 @@ -346,6 +343,9 @@ importers: '@inquirer/confirm': specifier: 6.1.1 version: 6.1.1(@types/node@24.13.3) + '@parcel/watcher': + specifier: 2.5.1 + version: 2.5.1 '@vitejs/plugin-basic-ssl': specifier: 2.3.0 version: 2.3.0(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.0(supports-color@10.2.2))(sass@1.101.7)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -355,6 +355,9 @@ importers: browserslist: specifier: ^4.26.0 version: 4.28.7 + chokidar: + specifier: 4.0.3 + version: 4.0.3 esbuild: specifier: 0.28.1 version: 0.28.1 @@ -403,9 +406,6 @@ importers: vite: specifier: 8.1.5 version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.0(supports-color@10.2.2))(sass@1.101.7)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) - watchpack: - specifier: 2.5.2 - version: 2.5.2 devDependencies: '@angular-devkit/core': specifier: workspace:* @@ -2919,86 +2919,92 @@ packages: '@oxc-project/types@0.141.0': resolution: {integrity: sha512-S4as7z0j0xQkXcJlyY5ehntwK8/wRkQb9Cyqw+J/N2rkWGQGK0SxD6X6DhQTc7qsxVTBxXbxZtBJh3mr3PtIzQ==} - '@parcel/watcher-android-arm64@2.6.0': - resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [android] - '@parcel/watcher-darwin-arm64@2.6.0': - resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [darwin] - '@parcel/watcher-darwin-x64@2.6.0': - resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [darwin] - '@parcel/watcher-freebsd-x64@2.6.0': - resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [freebsd] - '@parcel/watcher-linux-arm-glibc@2.6.0': - resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] libc: [glibc] - '@parcel/watcher-linux-arm-musl@2.6.0': - resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] libc: [musl] - '@parcel/watcher-linux-arm64-glibc@2.6.0': - resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@parcel/watcher-linux-arm64-musl@2.6.0': - resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] libc: [musl] - '@parcel/watcher-linux-x64-glibc@2.6.0': - resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] libc: [glibc] - '@parcel/watcher-linux-x64-musl@2.6.0': - resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] libc: [musl] - '@parcel/watcher-win32-arm64@2.6.0': - resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [win32] - '@parcel/watcher-win32-x64@2.6.0': - resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [win32] - '@parcel/watcher@2.6.0': - resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} engines: {node: '>= 10.0.0'} '@peculiar/asn1-cms@2.8.0': @@ -3600,9 +3606,6 @@ packages: '@types/gensync@1.0.5': resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} - '@types/graceful-fs@4.1.9': - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} - '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -3714,9 +3717,6 @@ packages: '@types/urijs@1.19.26': resolution: {integrity: sha512-wkXrVzX5yoqLnndOwFsieJA7oKM8cNkOKJtf/3vVGSUFkWDKZvFHpIl9Pvqb/T9UsawBBFMTTD8xu7sK5MWuvg==} - '@types/watchpack@2.4.5': - resolution: {integrity: sha512-8CarnGOIYYRL342jwQyHrGwz4vCD3y5uwwYmzQVzT2Z24DqSd6wwBva6m0eNJX4S5pVmrx9xUEbOsOoqBVhWsg==} - '@types/which@3.0.4': resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==} @@ -4424,6 +4424,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -4779,6 +4783,11 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -7152,6 +7161,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -10622,62 +10635,65 @@ snapshots: '@oxc-project/types@0.141.0': {} - '@parcel/watcher-android-arm64@2.6.0': + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': optional: true - '@parcel/watcher-darwin-arm64@2.6.0': + '@parcel/watcher-darwin-x64@2.5.1': optional: true - '@parcel/watcher-darwin-x64@2.6.0': + '@parcel/watcher-freebsd-x64@2.5.1': optional: true - '@parcel/watcher-freebsd-x64@2.6.0': + '@parcel/watcher-linux-arm-glibc@2.5.1': optional: true - '@parcel/watcher-linux-arm-glibc@2.6.0': + '@parcel/watcher-linux-arm-musl@2.5.1': optional: true - '@parcel/watcher-linux-arm-musl@2.6.0': + '@parcel/watcher-linux-arm64-glibc@2.5.1': optional: true - '@parcel/watcher-linux-arm64-glibc@2.6.0': + '@parcel/watcher-linux-arm64-musl@2.5.1': optional: true - '@parcel/watcher-linux-arm64-musl@2.6.0': + '@parcel/watcher-linux-x64-glibc@2.5.1': optional: true - '@parcel/watcher-linux-x64-glibc@2.6.0': + '@parcel/watcher-linux-x64-musl@2.5.1': optional: true - '@parcel/watcher-linux-x64-musl@2.6.0': + '@parcel/watcher-win32-arm64@2.5.1': optional: true - '@parcel/watcher-win32-arm64@2.6.0': + '@parcel/watcher-win32-ia32@2.5.1': optional: true - '@parcel/watcher-win32-x64@2.6.0': + '@parcel/watcher-win32-x64@2.5.1': optional: true - '@parcel/watcher@2.6.0': + '@parcel/watcher@2.5.1': dependencies: - detect-libc: 2.1.2 + detect-libc: 1.0.3 is-glob: 4.0.3 + micromatch: 4.0.8 node-addon-api: 7.1.1 - picomatch: 4.0.5 optionalDependencies: - '@parcel/watcher-android-arm64': 2.6.0 - '@parcel/watcher-darwin-arm64': 2.6.0 - '@parcel/watcher-darwin-x64': 2.6.0 - '@parcel/watcher-freebsd-x64': 2.6.0 - '@parcel/watcher-linux-arm-glibc': 2.6.0 - '@parcel/watcher-linux-arm-musl': 2.6.0 - '@parcel/watcher-linux-arm64-glibc': 2.6.0 - '@parcel/watcher-linux-arm64-musl': 2.6.0 - '@parcel/watcher-linux-x64-glibc': 2.6.0 - '@parcel/watcher-linux-x64-musl': 2.6.0 - '@parcel/watcher-win32-arm64': 2.6.0 - '@parcel/watcher-win32-x64': 2.6.0 - optional: true + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 '@peculiar/asn1-cms@2.8.0': dependencies: @@ -11187,10 +11203,6 @@ snapshots: '@types/gensync@1.0.5': {} - '@types/graceful-fs@4.1.9': - dependencies: - '@types/node': 22.20.1 - '@types/http-errors@2.0.5': {} '@types/http-proxy@1.17.17': @@ -11325,11 +11337,6 @@ snapshots: '@types/urijs@1.19.26': {} - '@types/watchpack@2.4.5': - dependencies: - '@types/graceful-fs': 4.1.9 - '@types/node': 22.20.1 - '@types/which@3.0.4': {} '@types/ws@8.18.1': @@ -12256,6 +12263,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -12587,6 +12598,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@1.0.3: {} + detect-libc@2.1.2: {} detect-node@2.1.0: {} @@ -14767,8 +14780,7 @@ snapshots: node-addon-api@6.1.0: optional: true - node-addon-api@7.1.1: - optional: true + node-addon-api@7.1.1: {} node-domexception@1.0.0: {} @@ -15373,6 +15385,8 @@ snapshots: dependencies: picomatch: 2.3.2 + readdirp@4.1.2: {} + readdirp@5.0.0: {} real-require@0.2.0: {} @@ -15655,7 +15669,7 @@ snapshots: immutable: 5.1.9 source-map-js: 1.2.1 optionalDependencies: - '@parcel/watcher': 2.6.0 + '@parcel/watcher': 2.5.1 sax@1.6.0: optional: true From b085b59874472504bad1695e96f0ec781ffa3c5d Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:42:13 -0400 Subject: [PATCH 2/4] fixup! perf(@angular/build): replace watchpack with @parcel/watcher and chokidar --- .../src/builders/application/execute-build.ts | 5 +++- .../build/src/builders/application/options.ts | 2 +- .../build/src/builders/unit-test/options.ts | 2 +- .../esbuild/angular/source-file-cache.ts | 27 ++++++++++++++++++- .../build/src/tools/esbuild/watcher.ts | 22 ++++++++++++--- packages/angular/build/src/utils/path.ts | 11 +++----- 6 files changed, 54 insertions(+), 15 deletions(-) diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 21beb57bf478..cee8a2450bc2 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -104,7 +104,10 @@ export async function executeBuild( bundlingResult = BundlerContext.mergeResults([bundlingResult, ...typescriptResults]); } else { const target = transformSupportedBrowsersToTargets(browsers); - codeBundleCache = new SourceFileCache(cacheOptions.enabled ? cacheOptions.path : undefined); + codeBundleCache = new SourceFileCache( + cacheOptions.enabled ? cacheOptions.path : undefined, + options.workspaceRoot, + ); componentStyleBundler = createComponentStyleBundler(options, target); if (options.templateUpdates) { templateUpdates = new Map(); diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 4ec810ff3c07..b3c180843f70 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -160,7 +160,7 @@ export async function normalizeOptions( options.preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks'); // Setup base paths based on workspace root and project information - const workspaceRoot = canonicalizePath(context.workspaceRoot, preserveSymlinks); + const workspaceRoot = canonicalizePath(context.workspaceRoot); const projectMetadata = await context.getProjectMetadata(projectName); const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata); diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts index d3402b090e0f..1206bb2c1f21 100644 --- a/packages/angular/build/src/builders/unit-test/options.ts +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -58,7 +58,7 @@ export async function normalizeOptions( : process.execArgv.includes('--preserve-symlinks'); // Setup base paths based on workspace root and project information - const workspaceRoot = canonicalizePath(context.workspaceRoot, preserveSymlinks); + const workspaceRoot = canonicalizePath(context.workspaceRoot); const projectMetadata = await context.getProjectMetadata(projectName); const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata); diff --git a/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts b/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts index 2bc38bd12a89..12e8aa18a93b 100644 --- a/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts +++ b/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import { realpathSync } from 'node:fs'; import { platform } from 'node:os'; import * as path from 'node:path'; import type ts from 'typescript'; @@ -18,11 +19,25 @@ export class SourceFileCache extends Map { readonly modifiedFiles = new Set(); readonly typeScriptFileCache = new Map(); readonly loadResultCache = new MemoryLoadResultCache(); + private readonly rootDir?: string; + private readonly realRootDir?: string; referencedFiles?: readonly string[]; - constructor(readonly persistentCachePath?: string) { + constructor( + readonly persistentCachePath?: string, + workspaceRoot?: string, + ) { super(); + const root = workspaceRoot ?? process.cwd(); + try { + const normRoot = path.normalize(root); + const real = path.normalize(realpathSync(normRoot)); + if (real !== normRoot) { + this.rootDir = normRoot; + this.realRootDir = real; + } + } catch {} } invalidate(files: Iterable): boolean { @@ -45,6 +60,16 @@ export class SourceFileCache extends Map { invalid = this.delete(file) || invalid; this.modifiedFiles.add(file); + + // Invalidate the realpath variant if TypeScript stored the SourceFile under its realpath + // (e.g. dynamically imported modules where TS module resolution resolved symlinked root directories). + if (this.rootDir && this.realRootDir) { + if (file.startsWith(this.rootDir)) { + const realFile = this.realRootDir + file.slice(this.rootDir.length); + invalid = this.delete(realFile) || invalid; + this.modifiedFiles.add(realFile); + } + } } return invalid; diff --git a/packages/angular/build/src/tools/esbuild/watcher.ts b/packages/angular/build/src/tools/esbuild/watcher.ts index 9d22fef7f21d..e7b115f5e43e 100644 --- a/packages/angular/build/src/tools/esbuild/watcher.ts +++ b/packages/angular/build/src/tools/esbuild/watcher.ts @@ -236,7 +236,10 @@ async function createParcelWatcher( const queue = new WatcherQueue(); const isCaseSensitive = isFileSystemCaseSensitive(options?.cwd); - const rootDirPosix = toPosixPathNormalized(options?.cwd ?? process.cwd()); + const rootDir = options?.cwd ? path.resolve(options.cwd) : process.cwd(); + const rootDirPosix = toPosixPathNormalized(rootDir); + const realRootDir = fs.existsSync(rootDir) ? fs.realpathSync(rootDir) : rootDir; + const realRootDirPosix = toPosixPathNormalized(realRootDir); const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive); const extraSubscriptions = new Map(); const pendingSubscriptions = new Map>(); @@ -245,7 +248,11 @@ async function createParcelWatcher( const handleEvents = (events: ParcelWatcher.Event[]) => { const changes: { type: 'added' | 'modified' | 'removed'; file: string }[] = []; for (const event of events) { - const posixPath = toPosixPathNormalized(event.path); + let rawPath = toPosixPath(event.path); + if (realRootDirPosix !== rootDirPosix && rawPath.startsWith(realRootDirPosix)) { + rawPath = rootDirPosix + rawPath.slice(realRootDirPosix.length); + } + const posixPath = toPosixPathNormalized(rawPath); const lookupKey = toLookupKey(posixPath, isCaseSensitive); if (!isPathWatched(lookupKey, watchedFiles)) { continue; @@ -419,7 +426,10 @@ async function createChokidarWatcher( const watchedFiles = new Set(); const queue = new WatcherQueue(); - const rootDir = options?.cwd ?? process.cwd(); + const rootDir = options?.cwd ? path.resolve(options.cwd) : process.cwd(); + const rootDirPosix = toPosixPathNormalized(rootDir); + const realRootDir = fs.existsSync(rootDir) ? fs.realpathSync(rootDir) : rootDir; + const realRootDirPosix = toPosixPathNormalized(realRootDir); const isCaseSensitive = isFileSystemCaseSensitive(rootDir); const watcher = chokidar.watch([], { @@ -431,7 +441,11 @@ async function createChokidarWatcher( }); const handleEvent = (type: 'added' | 'modified' | 'removed', rawPath: string) => { - const posixPath = toPosixPathNormalized(rawPath); + let normalizedPath = toPosixPath(rawPath); + if (realRootDirPosix !== rootDirPosix && normalizedPath.startsWith(realRootDirPosix)) { + normalizedPath = rootDirPosix + normalizedPath.slice(realRootDirPosix.length); + } + const posixPath = toPosixPathNormalized(normalizedPath); const lookupKey = toLookupKey(posixPath, isCaseSensitive); if (!isPathWatched(lookupKey, watchedFiles)) { return; diff --git a/packages/angular/build/src/utils/path.ts b/packages/angular/build/src/utils/path.ts index 4056972f790c..36107e388474 100644 --- a/packages/angular/build/src/utils/path.ts +++ b/packages/angular/build/src/utils/path.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import { realpathSync } from 'node:fs'; import { isAbsolute, posix, relative, resolve } from 'node:path'; import { platform } from 'node:process'; @@ -53,17 +52,15 @@ export function isSubDirectory(parent: string, child: string): boolean { } /** - * Canonicalizes a file path by normalising Windows drive-letter casing to uppercase - * and optionally resolving symbolic links. + * Canonicalizes a file path by normalising Windows drive-letter casing to uppercase. * * @param pathString - The file path to canonicalize. - * @param preserveSymlinks - If true, symbolic links will not be resolved. * @returns The canonicalized file path. */ -export function canonicalizePath(pathString: string, preserveSymlinks = false): string { - const resolved = preserveSymlinks ? pathString : realpathSync(pathString); +export function canonicalizePath(pathString: string): string { + let resolved = toPosixPath(pathString); if (platform === 'win32' && /^[a-z]:/.test(resolved)) { - return resolved[0].toUpperCase() + resolved.slice(1); + resolved = resolved[0].toUpperCase() + resolved.slice(1); } return resolved; From f97ac76f7a3bed658918451b5b9d8d5145c07941 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:33:13 -0400 Subject: [PATCH 3/4] fix(@angular/build): safeguard Karma builder stream controller against closed state When running Karma tests or when stream consumers (such as Architect test harness `executeOnce`) cancel the builder output stream early, both `ProgressNotifierReporter.onRunComplete` and `karma.Server` exit callbacks can attempt to enqueue results or close the `ReadableStreamController`. Under WHATWG Streams specification rules, calling `.enqueue()` or `.close()` on a controller whose `desiredSize` is `null` (closed or cancelled) throws `TypeError [ERR_INVALID_STATE]: Invalid state: Controller is already closed`. This change checks `controller.desiredSize !== null` and wraps enqueue/close calls in a try-catch block to gracefully handle closed controllers. --- .../build/src/builders/karma/application_builder.ts | 10 ++++++++-- .../build/src/builders/karma/progress-reporter.ts | 10 ++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/angular/build/src/builders/karma/application_builder.ts b/packages/angular/build/src/builders/karma/application_builder.ts index b9a28e1bf0b0..d5f00e776f5f 100644 --- a/packages/angular/build/src/builders/karma/application_builder.ts +++ b/packages/angular/build/src/builders/karma/application_builder.ts @@ -85,8 +85,14 @@ export function execute( // Close the stream once the Karma server returns. karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => { - controller.enqueue({ success: exitCode === 0 }); - controller.close(); + if (controller.desiredSize !== null) { + try { + controller.enqueue({ success: exitCode === 0 }); + controller.close(); + } catch { + // Stream controller may already be closed or cancelled + } + } }); await karmaServer.start(); diff --git a/packages/angular/build/src/builders/karma/progress-reporter.ts b/packages/angular/build/src/builders/karma/progress-reporter.ts index 16824badd095..dc85979842d5 100644 --- a/packages/angular/build/src/builders/karma/progress-reporter.ts +++ b/packages/angular/build/src/builders/karma/progress-reporter.ts @@ -81,10 +81,12 @@ export function injectKarmaReporter( } onRunComplete = function (_browsers: unknown, results: RunCompleteInfo): void { - if (results.exitCode === 0) { - controller.enqueue({ success: true }); - } else { - controller.enqueue({ success: false }); + if (controller.desiredSize !== null) { + try { + controller.enqueue({ success: results.exitCode === 0 }); + } catch { + // Stream controller may already be closed or cancelled + } } }; } From 6a6d28b8ae27ca6797b1641909769b440a609dd9 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:08:48 -0400 Subject: [PATCH 4/4] fixup! perf(@angular/build): replace watchpack with @parcel/watcher and chokidar --- .../build/src/tools/esbuild/watcher.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/watcher.ts b/packages/angular/build/src/tools/esbuild/watcher.ts index e7b115f5e43e..b6bfce806583 100644 --- a/packages/angular/build/src/tools/esbuild/watcher.ts +++ b/packages/angular/build/src/tools/esbuild/watcher.ts @@ -361,7 +361,11 @@ async function createParcelWatcher( add(paths) { const targets = typeof paths === 'string' ? [paths] : paths; for (const file of targets) { - const posixPath = toPosixPathNormalized(file); + const rawPosix = toPosixPath(file); + const absolutePath = path.posix.isAbsolute(rawPosix) + ? rawPosix + : path.posix.resolve(rootDirPosix, rawPosix); + const posixPath = toPosixPathNormalized(absolutePath); const lookupKey = toLookupKey(posixPath, isCaseSensitive); if (!watchedFiles.has(lookupKey)) { watchedFiles.add(lookupKey); @@ -373,7 +377,11 @@ async function createParcelWatcher( remove(paths) { const targets = typeof paths === 'string' ? [paths] : paths; for (const file of targets) { - const posixPath = toPosixPathNormalized(file); + const rawPosix = toPosixPath(file); + const absolutePath = path.posix.isAbsolute(rawPosix) + ? rawPosix + : path.posix.resolve(rootDirPosix, rawPosix); + const posixPath = toPosixPathNormalized(absolutePath); const lookupKey = toLookupKey(posixPath, isCaseSensitive); if (watchedFiles.delete(lookupKey)) { if (!isPathInside(lookupKey, rootDirLookupKey) && lookupKey !== rootDirLookupKey) { @@ -471,7 +479,11 @@ async function createChokidarWatcher( const targets = typeof paths === 'string' ? [paths] : paths; const newPaths: string[] = []; for (const p of targets) { - const posixPath = toPosixPathNormalized(p); + const rawPosix = toPosixPath(p); + const absolutePath = path.posix.isAbsolute(rawPosix) + ? rawPosix + : path.posix.resolve(rootDirPosix, rawPosix); + const posixPath = toPosixPathNormalized(absolutePath); const lookupKey = toLookupKey(posixPath, isCaseSensitive); if (!watchedFiles.has(lookupKey)) { watchedFiles.add(lookupKey); @@ -487,7 +499,11 @@ async function createChokidarWatcher( const targets = typeof paths === 'string' ? [paths] : paths; const removePaths: string[] = []; for (const p of targets) { - const posixPath = toPosixPathNormalized(p); + const rawPosix = toPosixPath(p); + const absolutePath = path.posix.isAbsolute(rawPosix) + ? rawPosix + : path.posix.resolve(rootDirPosix, rawPosix); + const posixPath = toPosixPathNormalized(absolutePath); const lookupKey = toLookupKey(posixPath, isCaseSensitive); if (watchedFiles.has(lookupKey)) { watchedFiles.delete(lookupKey);