diff --git a/package.json b/package.json index 4753c6a18110..b11eb709e31d 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 454dca65ffca..714b67f368f0 100644 --- a/packages/angular/build/package.json +++ b/packages/angular/build/package.json @@ -24,9 +24,11 @@ "@babel/helper-annotate-as-pure": "8.0.0", "@babel/helper-split-export-declaration": "7.24.7", "@inquirer/confirm": "6.1.1", + "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.4.3", "browserslist": "^4.26.0", + "chokidar": "5.0.0", "esbuild": "0.28.1", "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", @@ -42,8 +44,7 @@ "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", - "vite": "8.1.5", - "watchpack": "2.5.2" + "vite": "8.1.5" }, "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 dbec8d687b9f..7e1439826804 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 @@ -215,6 +216,9 @@ export async function* runEsBuildBuildAction( // Remove any stale locations if the build was successful if (staleWatchFiles?.size) { watcher.remove([...staleWatchFiles]); + for (const staleFile of staleWatchFiles) { + currentWatchFiles.delete(staleFile); + } } for (const outputResult of emitOutputResults( diff --git a/packages/angular/build/src/tools/esbuild/watcher.ts b/packages/angular/build/src/tools/esbuild/watcher.ts index cf9e1d94cb87..ca26335fe5dc 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,549 @@ 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, - }); - const watchedFiles = new Set(); + cwd?: string; +} - const nextQueue: ((value?: ChangedFiles) => void)[] = []; - let currentChangedFiles: ChangedFiles | undefined; +/** + * 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(); - watcher.on('aggregated', (changes, removals) => { - const changedFiles = currentChangedFiles ?? new ChangedFiles(); - for (const file of changes) { - changedFiles.modified.add(file); + // 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'; } - for (const file of removals) { - changedFiles.removed.add(file); + + // 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; +} - const next = nextQueue.shift(); - if (next) { - currentChangedFiles = undefined; - next(changedFiles); - } else { - currentChangedFiles = changedFiles; +class WatcherQueue { + private readonly nextQueue: ((value?: ChangedFiles) => void)[] = []; + private currentChangedFiles: ChangedFiles | undefined; + private isClosed = false; + private timeoutId: NodeJS.Timeout | undefined; + + addChange(type: 'added' | 'modified' | 'removed', file: string): void { + if (this.isClosed) { + return; } - }); - return { + const changedFiles = (this.currentChangedFiles ??= new ChangedFiles()); + changedFiles[type].add(file); + this.scheduleFlush(); + } + + 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.scheduleFlush(); + } + + private scheduleFlush(): void { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + this.timeoutId = setTimeout(() => { + this.timeoutId = undefined; + this.flush(); + }, 250); + } + + 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 && + !this.timeoutId + ) { + 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; + } + + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + + 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); +} + +class ParcelExternalManager { + private readonly extraSubscriptions = new Map(); + private readonly pendingSubscriptions = new Map< + string, + Promise + >(); + private readonly externalDirFiles = new Map }>(); + + constructor( + private readonly parcelWatcher: typeof ParcelWatcher, + private readonly options: WatcherOptions | undefined, + private readonly rootDirLookupKey: string, + private readonly handleEvents: (events: ParcelWatcher.Event[]) => void, + ) {} + + async ensureWatched(posixPath: string, lookupKey: string): Promise { + if (isPathInside(lookupKey, this.rootDirLookupKey) || lookupKey === this.rootDirLookupKey) { + return; + } + + const dirPath = path.posix.dirname(posixPath); + const dirKey = path.posix.dirname(lookupKey); + let dirEntry = this.externalDirFiles.get(dirKey); + if (!dirEntry) { + dirEntry = { dirPath, files: new Set() }; + this.externalDirFiles.set(dirKey, dirEntry); + } + dirEntry.files.add(lookupKey); + + await this.ensureDirWatched(dirPath, dirKey); + } + + removeFile(lookupKey: string): void { + if (isPathInside(lookupKey, this.rootDirLookupKey) || lookupKey === this.rootDirLookupKey) { + return; + } + + const dirKey = path.posix.dirname(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); + void sub.unsubscribe(); + + for (const [remainingDirKey, remainingDirEntry] of this.externalDirFiles.entries()) { + if (!this.isCoveredByExistingExternal(remainingDirKey)) { + void this.ensureDirWatched(remainingDirEntry.dirPath, remainingDirKey); + } + } + } + } + } + } + + async close(): Promise { + try { + if (this.pendingSubscriptions.size > 0) { + await Promise.allSettled(Array.from(this.pendingSubscriptions.values())); + } + for (const sub of this.extraSubscriptions.values()) { + await sub.unsubscribe(); + } + } finally { + this.extraSubscriptions.clear(); + this.pendingSubscriptions.clear(); + this.externalDirFiles.clear(); + } + } + + private isCoveredByExistingExternal(dirLookupKey: string): boolean { + for (const existingDir of this.extraSubscriptions.keys()) { + if (dirLookupKey === existingDir || isPathInside(dirLookupKey, existingDir)) { + return true; + } + } + for (const pendingDir of this.pendingSubscriptions.keys()) { + if (dirLookupKey === pendingDir || isPathInside(dirLookupKey, pendingDir)) { + return true; + } + } + + return false; + } + + private async ensureDirWatched(dirPath: string, dirKey: string): Promise { + if (this.isCoveredByExistingExternal(dirKey)) { + return; + } + + const subPromise = this.parcelWatcher.subscribe( + dirPath, + (err, events) => { + if (!err) { + this.handleEvents(events); + } + }, + { + ignore: this.options?.ignored, + }, + ); + + this.pendingSubscriptions.set(dirKey, subPromise); + + try { + const sub = await subPromise; + if (this.externalDirFiles.has(dirKey) && !this.isCoveredByExistingExternal(dirKey)) { + this.extraSubscriptions.set(dirKey, sub); + + // 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); + void childSub.unsubscribe(); + } + } + } else { + void sub.unsubscribe(); + } + } catch { + // Ignore subscription errors for missing or restricted external directories + } finally { + this.pendingSubscriptions.delete(dirKey); + } + } +} + +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 initTime = Date.now(); + + 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; + } + + if (event.type !== 'delete') { + const stat = fs.statSync(event.path, { throwIfNoEntry: false }); + // Ignore historical events from before watcher initialization, but allow a 1000 ms window + // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second + // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes. + if (stat && stat.mtimeMs < initTime - 1000) { + continue; + } + } + + const type = + event.type === 'create' ? 'added' : event.type === 'delete' ? 'removed' : 'modified'; + changes.push({ type, file: event.path }); + } + + if (changes.length > 0) { + queue.addChanges(changes); + } + }; + + const subscription = await parcelWatcher.subscribe( + rootDirPosix, + (err, events) => { + if (!err) { + handleEvents(events); + } + }, + { + ignore: options?.ignored, + }, + ); + + const externalManager = new ParcelExternalManager( + parcelWatcher, + options, + rootDirLookupKey, + handleEvents, + ); + + const buildWatcher: BuildWatcher = { [Symbol.asyncIterator]() { return this; }, - async next() { - if (currentChangedFiles && nextQueue.length === 0) { - const result = { value: currentChangedFiles }; - currentChangedFiles = undefined; + next() { + return queue.next(); + }, - return result; + 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 externalManager.ensureWatched(posixPath, lookupKey); + } } + }, - return new Promise((resolve) => { - nextQueue.push((value) => resolve(value ? { value } : { done: true, value })); - }); + 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)) { + externalManager.removeFile(lookupKey); + } + } }, - add(paths) { - const previousSize = watchedFiles.size; - if (typeof paths === 'string') { - watchedFiles.add(paths); - } else { - for (const file of paths) { - watchedFiles.add(file); + async close() { + try { + if (subscription) { + await subscription.unsubscribe(); } + await externalManager.close(); + } finally { + queue.close(); + } + }, + }; + + return buildWatcher; +} + +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 rootDirPosix = toPosixPathNormalized(rootDir); + const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive); + + const watcher = chokidar.watch(rootDir, { + ignoreInitial: true, + ignored: options?.ignored, + followSymlinks: options?.followSymlinks, + usePolling: !!options?.polling, + interval: options?.interval, + }); + const initTime = Date.now(); + + const handleEvent = (type: 'added' | 'modified' | 'removed', rawPath: string) => { + const posixPath = toPosixPathNormalized(rawPath); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!isPathWatched(lookupKey, watchedFiles)) { + return; + } + + if (type !== 'removed') { + const stat = fs.statSync(rawPath, { throwIfNoEntry: false }); + // Ignore historical events from before watcher initialization, but allow a 1000 ms window + // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second + // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes. + if (stat && stat.mtimeMs < initTime - 1000) { + return; } + } + + queue.addChange(type, rawPath); + }; + + 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(); + }, - if (previousSize !== watchedFiles.size) { - watcher.watch({ - files: watchedFiles, - }); + add(paths) { + 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); + if (!isPathInside(lookupKey, rootDirLookupKey) && lookupKey !== rootDirLookupKey) { + newPaths.push(posixPath); + } + } + } + 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); + if (!isPathInside(lookupKey, rootDirLookupKey) && lookupKey !== rootDirLookupKey) { + 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..7049e452e2c2 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/watcher_spec.ts @@ -0,0 +1,426 @@ +/** + * @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 { setTimeout } from 'node:timers/promises'; +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.realpathSync(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 setTimeout(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 setTimeout(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 setTimeout(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 setTimeout(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 setTimeout(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 setTimeout(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 setTimeout(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 subscribe to subsumed external child directory when parent external subscription is removed', 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 setTimeout(100); + + watcher.remove(parentFile); + + await setTimeout(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(); + }); + + it('should ignore stale modifications before initTime and emit changes after initTime (@parcel/watcher)', async () => { + const testFile = path.join(tempDir, 'test.txt'); + fs.writeFileSync(testFile, 'initial'); + + // Small delay to ensure initial mtimeMs is strictly earlier than initTime - 1000 + await setTimeout(1100); + + // Create native @parcel/watcher (polling: false / default) + const watcher = await createWatcher({ cwd: tempDir }); + watcher.add(testFile); + + // Wait a short moment for native watcher setup and kernel event stream initialization + await setTimeout(150); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + // Trigger a change after watcher initialization + fs.writeFileSync(testFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('test.txt'))).toBeTrue(); + + await watcher.close(); + }, 10000); + + it('should emit changes when a file is deleted and recreated with stabilization delay (@parcel/watcher)', async () => { + const testFile = path.join(tempDir, 'recreate.txt'); + fs.writeFileSync(testFile, 'initial'); + + await setTimeout(50); + + const watcher = await createWatcher({ cwd: tempDir }); + watcher.add(testFile); + + await setTimeout(150); + + const iterator = watcher[Symbol.asyncIterator](); + + // Delete the file + fs.rmSync(testFile); + let result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.removed.size).toBeGreaterThan(0); + + // Brief stabilization delay before recreating to prevent macOS fsevents kernel driver + // from coalescing unlink and create into a single directory event + await setTimeout(150); + + // Recreate the file + fs.writeFileSync(testFile, 'recreated'); + result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.added.size).toBeGreaterThan(0); + + await watcher.close(); + }, 10000); + }); +}); 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 e0600a6b9f5e..6031ceaa1c70 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.6.0 + version: 2.6.0 '@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.1(supports-color@11.0.0))(sass@1.102.0)(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: 5.0.0 + version: 5.0.0 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.1(supports-color@11.0.0))(sass@1.102.0)(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:* @@ -3589,9 +3589,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-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} @@ -3688,9 +3685,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==} @@ -10751,7 +10745,6 @@ snapshots: '@parcel/watcher-linux-x64-musl': 2.6.0 '@parcel/watcher-win32-arm64': 2.6.0 '@parcel/watcher-win32-x64': 2.6.0 - optional: true '@peculiar/asn1-cms@2.8.0': dependencies: @@ -11245,10 +11238,6 @@ snapshots: '@types/gensync@1.0.5': {} - '@types/graceful-fs@4.1.9': - dependencies: - '@types/node': 22.20.1 - '@types/http-cache-semantics@4.2.0': {} '@types/http-errors@2.0.5': {} @@ -11362,11 +11351,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': @@ -14852,8 +14836,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: {}