Watching a non-existent nested path silently stops its parent directory's subtree from being watched
Describe the bug
When watch() receives both a directory to watch recursively and a non-existent path nested two or more levels below it, the change events for the files in the parent of the non-existent path is not emitted.
An example case:
<base>and<base>/root/a/b/c/publicis watched<base>exists<base>/root/a/b/c/publicdoes not exist<base>/root/a/b/c/src/counter.jsexists- When
<base>/root/a/b/c/src/counter.jsis changed, no event is emitted
Versions (please complete the following information):
- Chokidar version: 5.0.0 (also exists in v3 as well)
- Node version: 24.14.1
- OS version: Windows 11, also tested on WSL and had the same behavior
To Reproduce:
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import { watch } from 'chokidar'
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'chokidar-repro-'))
// Build: <base>/root/a/b/c/src/counter.js
// The directory <base>/root/a/b/c/public is deliberately NEVER created.
const root = path.join(base, 'root')
const parent = path.join(root, 'a', 'b', 'c')
const missingDir = path.join(parent, 'public') // does not exist
const file = path.join(parent, 'src', 'counter.js')
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, 'export const n = 1\n')
async function run(label, paths) {
const watcher = watch(paths, { ignoreInitial: true })
let changed = false
watcher.on('change', () => (changed = true))
watcher.on('error', (e) => console.log(' error:', e.message))
await new Promise((r) => setTimeout(r, 1000))
const watched = Object.keys(watcher.getWatched())
const srcWatched = watched.some((p) => path.resolve(p) === path.resolve(parent, 'src'))
fs.appendFileSync(file, '// edit\n')
await new Promise((r) => setTimeout(r, 1000))
await watcher.close()
console.log(`\n${label}`)
console.log(` a/b/c/src is watched? : ${srcWatched}`)
console.log(` 'change' event fired? : ${changed}`)
return changed
}
const control = await run('CONTROL — watch only the existing root:', [root])
const broken = await run('BUG — additionally watch a non-existent nested path:', [root, missingDir])
fs.rmSync(base, { recursive: true, force: true })
console.log(`\nExpected: both fire 'change'. Actual: control=${control}, with-missing-path=${broken}`)Run this script.
Expected behavior
The change event for <base>/root/a/b/c/src/counter.js is emitted.
Additional context
This was found by https://github.com/vitejs/vite/issues/19864. Vite watches [rootDir, publicDir] and normally publicDir is under rootDir.
When watch() receives both a directory to watch recursively and a non-existent path nested two or more levels below it, the non-existent path's parent directory is registered as "tracked" but never scanned. The recursive walk of the root then treats that parent as already handled and never descends into it, so the parent's entire subtree goes unwatched. No error is emitted and ready still fires, so the failure is completely silent.
The interaction is between three places:
_addToNodeFs():staton the missing path throwsENOENT._handleError()intentionally swallowsENOENT, but still returns the truthy error object (return error || this.closed), so_addToNodeFs()returns the path.add(): any returned path is re-added asthis.add(sp.dirname(item), sp.basename(_origAdd || item)), i.e. the parent directory is watched with the missing entry passed astarget. This is intentional, so that creating the path later is detected._handleDir(): for atargetadd,parentDir.add(sp.basename(dir))runs unconditionally, but_handleRead()is guarded byif (!target). The directory is therefore marked as a tracked child of its parent without ever being read.
The recursive scan of the root later reaches that grandparent directory and evaluates, in _handleRead():
if (item === target || (!target && !previous.has(item))) {
this._addToNodeFs(path, initialAdd, wh, depth + 1);
}Because step 3 already inserted the directory into previous, previous.has(item) is true and the walk never descends into it. Step 3 skipped reading the directory, and the recursive walk now refuses to read it precisely because step 3 marked it. The subtree ends up watched by nobody.
In short, _handleRead() uses "present in the parent's DirEntry" as a proxy for "already scanned", but a target add marks a directory as present without scanning it.
Whether the bug triggers depends on which of the two racing operations reaches the directory first, which in practice is decided by how deeply the missing path is nested:
non-existent path passed to watch() |
subtree watched? |
|---|---|
<root>/public |
yes |
<root>/a/public |
yes |
<root>/a/b/public |
no |
<root>/a/b/c/public |
no |
At depth 0 and 1 the root's own scan wins the race; at depth ≥ 2 the parent re-add wins. At depth ≥ 2 it reproduces on every run.
Source: paulmillr/chokidar