#1470·chokidar

Watching a non-existent nested path silently stops its parent directory's subtree from being watched

Author: sapphi-redCreated Jul 10, 2026Updated Jul 11, 2026

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/public is watched
  • <base> exists
  • <base>/root/a/b/c/public does not exist
  • <base>/root/a/b/c/src/counter.js exists
  • When <base>/root/a/b/c/src/counter.js is 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:

javascript
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.

Cause Investigation by AI

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:

  1. _addToNodeFs(): stat on the missing path throws ENOENT. _handleError() intentionally swallows ENOENT, but still returns the truthy error object (return error || this.closed), so _addToNodeFs() returns the path.

  2. add(): any returned path is re-added as this.add(sp.dirname(item), sp.basename(_origAdd || item)), i.e. the parent directory is watched with the missing entry passed as target. This is intentional, so that creating the path later is detected.

  3. _handleDir(): for a target add, parentDir.add(sp.basename(dir)) runs unconditionally, but _handleRead() is guarded by if (!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():

javascript
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.