fix(files): ignore nested generated directories on macOS (#24620)

* fix(files): ignore nested generated directories on macOS

* fix(files): filter generated filenames containing newlines
This commit is contained in:
Neil
2026-10-02 05:32:41 -07:00
committed by GitHub
parent 0f167ac659
commit 4d2f235a10
3 changed files with 118 additions and 8 deletions
@@ -0,0 +1,74 @@
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { subscribe, type AsyncSubscription, type Event } from '@parcel/watcher'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
describe('filesystem watcher native ignores', () => {
let root: string | null = null
let subscription: AsyncSubscription | null = null
afterEach(async () => {
await subscription?.unsubscribe()
subscription = null
if (root) {
await rm(root, { recursive: true, force: true })
root = null
}
})
it('drops root and nested generated-file storms while delivering source edits', async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-watch-ignore-')))
const rootModules = join(root, 'node_modules')
const nestedModules = join(root, 'packages', 'app', 'node_modules')
const nestedSource = join(root, 'packages', 'app', 'src')
await Promise.all(
[rootModules, nestedModules, nestedSource].map((directory) =>
mkdir(directory, { recursive: true })
)
)
const events: Event[] = []
const errors: Error[] = []
subscription = await subscribe(
root,
(error, batch) => {
if (error) {
errors.push(error)
}
events.push(...batch)
},
buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
)
const generatedNames = Array.from({ length: 20 }, (_, index) => `generated-${index}.js`)
// Windows forbids control characters in filenames.
if (process.platform !== 'win32') {
generatedNames.push('generated\nnewline.js')
}
const generatedFiles = [rootModules, nestedModules].flatMap((directory) =>
generatedNames.map((name) => join(directory, name))
)
await Promise.all(generatedFiles.map((file) => writeFile(file, 'generated')))
const sourceFiles = [join(root, 'source.ts'), join(nestedSource, 'source.ts')]
if (process.platform !== 'win32') {
sourceFiles.push(join(root, 'source\nnewline.ts'), join(nestedSource, 'source\nnewline.ts'))
}
await Promise.all(sourceFiles.map((file) => writeFile(file, 'source')))
await vi.waitFor(
() => {
for (const sourceFile of sourceFiles) {
expect(events.some((event) => event.path === sourceFile)).toBe(true)
}
},
{ timeout: 8_000 }
)
// Why: ignored callbacks must stay absent after the native debounce has drained.
await new Promise((resolve) => setTimeout(resolve, 300))
expect(errors).toEqual([])
const generatedPaths = new Set(generatedFiles)
expect(events.filter((event) => generatedPaths.has(event.path))).toEqual([])
})
})
+39 -1
View File
@@ -27,12 +27,50 @@ describe('buildParcelWatcherIgnoreOptions', () => {
expect(plainPaths.length).toBeLessThanOrEqual(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
expect(option).toEqual(WATCHER_IGNORE_DIRS.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT))
const fallbackRegex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)')
for (const dir of WATCHER_IGNORE_DIRS.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)) {
for (const dir of WATCHER_IGNORE_DIRS) {
expect(fallbackRegex.test(dir)).toBe(true)
expect(fallbackRegex.test(`${dir}/file.ts`)).toBe(true)
expect(fallbackRegex.test(`packages/app/${dir}`)).toBe(true)
expect(fallbackRegex.test(`packages/app/${dir}/file.ts`)).toBe(true)
}
expect(fallbackRegex.test('packages/app/node_modules-cache/file.ts')).toBe(false)
expect(fallbackRegex.test('packages/app/.github/workflows/check.yml')).toBe(false)
expect(fallbackRegex.test('project\\node_modules/file.ts')).toBe(false)
expect(options.ignoreGlobs?.[0]).not.toContain('?!')
})
it('filters nested macOS directories when all names fit in daemon exclusions', () => {
setPlatform('darwin')
const options = buildParcelWatcherIgnoreOptions(['node_modules', '.cache'])
expect(options.ignore).toEqual(['node_modules', '.cache'])
const regex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)')
expect(regex.test('packages/app/node_modules/file.js')).toBe(true)
expect(regex.test('packages/app/.cache/file.js')).toBe(true)
expect(regex.test('packages/app/xcache/file.js')).toBe(false)
})
it('keeps an empty ignore list empty', () => {
for (const platform of ['darwin', 'linux', 'win32'] as const) {
setPlatform(platform)
expect(buildParcelWatcherIgnoreOptions([])).toEqual({})
}
})
it('matches newline-containing paths only under ignored names', () => {
for (const platform of ['darwin', 'linux', 'win32'] as const) {
setPlatform(platform)
const options = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
const regex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)')
expect(regex.test('packages/app/node_modules/generated\nnewline.js')).toBe(true)
expect(regex.test('packages/app/src/source\nnewline.ts')).toBe(false)
expect(regex.test('packages/app/node_modules-cache/generated\nnewline.js')).toBe(false)
expect(regex.test('packages\\app\\node_modules\\generated\nnewline.js')).toBe(
platform === 'win32'
)
expect(regex.test('packages\\app\\src\\source\nnewline.ts')).toBe(false)
}
})
it('uses one lookahead-free native regex for nested ignores on Linux/Windows', () => {
for (const platform of ['linux', 'win32'] as const) {
setPlatform(platform)
+5 -7
View File
@@ -18,9 +18,7 @@ export const WATCHER_IGNORE_DIRS: string[] = [
// closed — one entry over the cap and @parcel/watcher silently loses ALL
// daemon-side exclusions, so fseventsd delivers every node_modules/.git event
// to this process (measured ~29x client CPU plus daemon-side delivery load).
// Keep the 8 highest-churn dirs as plain paths (daemon-excluded) and demote
// the rest to globs (userspace-filtered). Ordering of WATCHER_IGNORE_DIRS is
// therefore meaningful: the first 8 get true daemon-side exclusion on macOS.
// The first 8 names get root-level daemon exclusions; the regex covers all names at every depth.
export const MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT = 8
export type ParcelWatcherIgnoreOptions = {
@@ -37,10 +35,10 @@ function escapeRegex(value: string): string {
function buildNestedDirectoryRegex(ignoreDirs: readonly string[]): string {
const alternatives = ignoreDirs.map(escapeRegex).join('|')
if (process.platform === 'win32') {
return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/].*)?$`
return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/][\\s\\S]*)?$`
}
// Why: backslash is a legal POSIX filename character, not a path separator.
return `^(?:[^/]+/)*(?:${alternatives})(?:/.*)?$`
return `^(?:[^/]+/)*(?:${alternatives})(?:/[\\s\\S]*)?$`
}
export function buildParcelWatcherIgnoreOptions(
@@ -56,9 +54,9 @@ export function buildParcelWatcherIgnoreOptions(
return { ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)] }
}
const daemonExcludedDirs = ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
const remainingDirs = ignoreDirs.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
return {
ignore: [...daemonExcludedDirs],
...(remainingDirs.length > 0 ? { ignoreGlobs: [buildNestedDirectoryRegex(remainingDirs)] } : {})
// Why: plain exclusions resolve under the root, leaving nested generated directories unfiltered.
ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)]
}
}