Skip to content

Dev server on macOS adds a new FSEvents watch listener for an out-of-root file on every transform #23940

Open

Description

@quantizor

Describe the bug

On macOS, each transform of a module that lives outside root (a linked workspace package, for example) registers one more watch listener for that file, and none is ever removed. A long dev session grows the heap without bound, and every file system event under the watched folder is delivered to every accumulated listener, so the cost of each change event grows with how long the server has run.

The chain:

  1. Loading a module outside the root calls ensureWatchedFile, which calls watcher.add(file) every time, with no check that the file is already watched.
  2. On macOS, Vite's bundled chokidar 3.6.0 takes the FSEvents path: _addToFsEvents → initWatch → setFSEventsListener, which adds a new filteredListener to the folder's listener set on each call, and _addPathCloser stores a new closer each time. Nothing deduplicates a path that is already watched.
  3. On the fs.watch path (Linux, Windows, or server.watch.useFsEvents: false on macOS), chokidar does deduplicate, so the count stays at one.

Either layer can fix it: ensureWatchedFile can remember which files it has already added (per watcher), or the chokidar patch can skip setFSEventsListener for a path that already has a closer.

Reproduction

package.json:

{"name":"repro","private":true,"type":"module","devDependencies":{"vite":"8.3.5"}}

repro.mjs (writes app/main.js and shared/value.js itself):

// node repro.mjs [transforms]            FSEvents watcher (macOS default)
// NO_FSEVENTS=1 node repro.mjs [transforms]   fs.watch watcher, for comparison
// Transforms a module that lives outside the Vite root again and again, as a dev session does on each edit,
// and prints how many watch registrations chokidar holds for that one file (read from its private _closers map).
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { createServer, version } from "vite";

mkdirSync("app", { recursive: true });
mkdirSync("shared", { recursive: true });
writeFileSync("app/main.js", 'import { value } from "../shared/value.js";\nconsole.log(value);\n');
writeFileSync("shared/value.js", "export const value = 1;\n");

const transforms = Number(process.argv[2] ?? 2000);
const outside = path.resolve("shared/value.js");
const url = `/@fs${outside}`;

const server = await createServer({
  logLevel: "silent",
  root: path.resolve("app"),
  server: {
    fs: { allow: [process.cwd()] },
    middlewareMode: true,
    watch: process.env.NO_FSEVENTS ? { useFsEvents: false } : undefined,
  },
});

const registrations = () => server.watcher._closers.get(outside)?.length ?? 0;
const settle = () => new Promise((resolve) => setTimeout(resolve, 500));

await server.transformRequest(url);
await settle();
console.log(`platform=${process.platform} vite=${version} useFsEvents=${!process.env.NO_FSEVENTS}`);
console.log(`after 1 transform: watch_registrations=${registrations()}`);

for (let i = 0; i < transforms; i += 1) {
  server.moduleGraph.invalidateModule(await server.moduleGraph.getModuleByUrl(url));
  await server.transformRequest(url);
}

await settle();
console.log(`after ${transforms + 1} transforms: watch_registrations=${registrations()}`);
await server.close();

Steps to reproduce

npm install
node repro.mjs 2000
NO_FSEVENTS=1 node repro.mjs 2000

repro.mjs creates a middleware-mode server with root: "app", then invalidates and transforms /@fs/<project>/shared/value.js 2000 times. It prints the number of closers chokidar holds for that file.

platform=darwin vite=8.3.5 useFsEvents=true
after 1 transform: watch_registrations=1
after 2001 transforms: watch_registrations=2001
platform=darwin vite=8.3.5 useFsEvents=false
after 1 transform: watch_registrations=1
after 2001 transforms: watch_registrations=1

Expected: one registration per watched file, however many times it is transformed.

System Info

System:
  OS: macOS 27.2
  CPU: (12) arm64 Apple M2 Pro
Binaries:
  Node: 24.22.1
  npm: 11.19.1
npmPackages:
  vite: 8.3.5 => 8.3.5

Used Package Manager

npm

Logs

No response

Validations

Activity

  1. quantizor commented on Oct 29, 2026

    @quantizor
    Author

    One more data point and the change I'm running locally. It keeps a per-watcher set of files already added, so each out-of-root file is registered once, and it drops a file from the set when chokidar reports it deleted so a recreated file is watched again:

    const ensuredWatchFiles = new WeakMap<FSWatcher, Set<string>>()
    
    export function ensureWatchedFile(watcher: FSWatcher, file: string | null, root: string): void {
      if (!file || normalizePath(file).startsWith(withTrailingSlash(root)) || file.includes('\0')) return
    
      let ensured = ensuredWatchFiles.get(watcher)
      if (!ensured) {
        const files = new Set<string>()
        watcher.on('unlink', (unlinked) => files.delete(path.resolve(unlinked)))
        ensuredWatchFiles.set(watcher, files)
        ensured = files
      }
    
      const resolved = path.resolve(file)
      if (ensured.has(resolved) || !fs.existsSync(file)) return
    
      ensured.add(resolved)
      watcher.add(resolved)
    }

    On 8.3.1 with this change, transforming one out-of-root module repeatedly leaves one entry for it in server.watcher._closers.

  2. duongynhi000005-oss commented on Oct 30, 2026

    @duongynhi000005-oss

    I investigated #23940 and traced the repeated watcher.add calls in packages/vite/src/node/utils.ts:

    Root Cause

    In packages/vite/src/node/server/transformRequest.ts:292, every transform calls ensureWatchedFile(server.watcher, id, config.root). In packages/vite/src/node/utils.ts:785-801, ensureWatchedFile checks whether file is outside root and unconditionally calls watcher.add(path.resolve(file)). On macOS, chokidar's FSEvents handler (_addToFsEvents) registers a new filteredFsEventsListener closure in the directory's listeners set on every watcher.add() call for an already-watched out-of-root file.

    Proposed Fix

    Track already-added out-of-root file paths per FSWatcher using a WeakMap<FSWatcher, Set<string>> in ensureWatchedFile so watcher.add(resolvedPath) is only invoked once per file per watcher instance.

    I have a branch ready (duongynhi000005-oss/vite:fix-issue-23940-dedup-ensure-watched-file) and would be glad to open a PR!

  3. bensig commented on Nov 14, 2026

    @bensig

    Confirmed with Vite 7.3.8 and chokidar 3.6.0 on macOS 27.2 (26B123), Apple Silicon, Node 24.22.1.

    I used a separate middleware server with configFile: false and polling disabled. I repeatedly invalidated and transformed one JavaScript file outside the Vite root.

    I checked server.watcher._closers for that file:

    • useFsEvents: true, usePolling: false: 1 stored closer after the first transform; 21 after 21 transforms.
    • useFsEvents: false, usePolling: false: 1 stored closer after the first transform; 1 after 21 transforms.

    The installed ensureWatchedFile calls watcher.add(path.resolve(file)) without checking for an existing registration.

    This reproduces the duplication in 7.3.8. The fs.watch backend avoids it in this isolated test without enabling polling.

    I also observed a separate fseventsd memory incident. I have not established that Vite caused that daemon failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions