You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Watch mode watches every file under root before the first test, unlike vitest run #11327
In watch mode Vitest's top-level Vite server hands chokidar the config root to watch recursively.
chokidar opens an fs.watch handle per entry — every file as well as every directory — before the
first test runs, so startup scales with repository size rather than with the test suite.
vitest run is unaffected: Vitest swaps in Vite's NoopWatcher when watch mode is off. Same config,
same root, same single trivial spec — only the mode differs:
tree under root
watched entries
vitest run
vitest --watch
2,000 module dirs
8,060
1.23s
5.10s
8,000 module dirs
32,060
1.11s
15.97s
20,000 module dirs
80,060
1.00s
36.10s
a real monorepo
181,710
1.25s
102.2s
Medians of 4 watch runs per size, all measured in one directory. run does not move as the tree
grows 10x. Watch is linear in watched entries at a near-constant ~0.46 ms each (0.48 / 0.46 /
0.44), and extrapolating that to the monorepo's 181,710 entries predicts ~82s against the 102.2s
actually measured — so the synthetic fixture models the real cost to within about 25%.
One caveat for anyone re-measuring: the per-entry constant depends heavily on where the tree lives,
so compare only within a single directory. The identical 32,060-entry fixture measured 3.6s under %TEMP% and 15.97s under C:\Development on this machine, and run-to-run variance was far worse
under %TEMP% (one run of four took 21.0s, against a ±10% spread in the numbers above).
The handle count is the clearest evidence that the whole tree is being visited. Counting FSWatcher handles via process._getActiveHandles() once the watcher settles:
the 80,069-entry synthetic fixture → 80,069 handles, an exact match
the 181,710-entry monorepo → 181,692 handles
a 1,000-file directory → 1,001 handles
One handle per file and per directory, whether or not anything is reachable from a test.
Expected: watch-mode startup scales with the test suite and its module graph, as run does. A
directory containing no test files and no test dependency should not be visited at startup.
Actual: watch-mode startup scales with the size of the tree under root.
Root cause
Line numbers from the installed dist of vitest 5.0.0 / vite 8.3.0.
vitest/dist/chunks/index.B89dZ0-N.js:8896 — run mode disables the watcher:
A related smaller bug: server.watch = null in config is silently ignored
Because of the ??= on line 8898, the one setting a user would reach for to opt out is overwritten
by the slow path — ??= fires on null. Measured on the 80,069-entry fixture: 34.92s with server: { watch: null } against a 35.02s baseline, i.e. no effect at all. Worth fixing
independently of everything else.
Ruled out by measurement — listed to save triage time
Spec discovery.test.dir, a narrowed test.exclude, and forceRerunTriggers: [] change
nothing about what is watched. On the 80,069-entry fixture all three, and the baseline, settle at
an identical 80,069FSWatcher handles. tinyglobby prunes to a static prefix, so globbing
is cheap.
The dependency scanner. Vitest already sets optimizeDeps to { noDiscovery: true, entries: [], include: [] } on the parent's environments
(index.B89dZ0-N.js:7193-7231, applied at :7352). It would also affect run, which is flat.
Worker pool startup. Watch mode uses fewer workers than run mode — index.B89dZ0-N.js:10049
halves the count: vitest.config.watch ? Math.max(Math.floor(maxThreadsCount / 2), 1) : ....
tsconfig discovery. Vite 8 has no tsconfck dependency at all.
The vendored chokidar is what runs. Vite inlines chokidar 3.6.0 (patched) at node.js:11464 and lists no chokidar dependency, so npm ls chokidar is misleading.
Workaround (works today, and suggests the fix needs no new API)
Narrow what chokidar traverses with a server.watch.ignored predicate that keeps the root, the
files directly in it, and a scope discovered from the host, pruning everything else at depth one. resolveChokidarOptions honours a predicate (normalizeIgnored passes non-strings through, node.js:11523).
On the 80,069-entry tree this takes watch startup from 35.02s to 1.00s, against a run-mode floor
of 0.97s — the watch penalty disappears entirely.
Two hooks supply a complete scope, and neither alone is enough:
configureVitest gives each project's include globs, trimmed to the part before the first
wildcard — so a spec that has never been loaded, in a directory that did not exist, is still seen.
defineCacheKeyGenerator gives every module a run loads. It fires per module while the cache path is computed, which precedes the cache lookup, so unlike transform it is not silenced by test.fsModuleCache. Returning nothing leaves the key untouched.
Three approaches look like they would work and do not:
Listing the subtrees by hand costs what crawling them costs, so it stops scaling exactly when
it matters.
Driving the scope from transform passes cold and covers nothing warm. With test.fsModuleCache: true, a spec importing 14 modules across 14 directories gives transform14/14 directories cold and 0/14 warm — the scope is simply empty on a warm run. resolveId is not an alternative: it is called twice in that run, both times for index.html,
and never for a fixture module.
Reading the module graph on a timer. The graph is complete on a warm run — Vitest replays
cached imports into it (index.B89dZ0-N.js:9780-9791, "we populate the module graph to make the
watch mode work because it relies on importers") — but read environments.client, not ssr,
which shows only entry files. The real objection is that it needs a trigger, and a poll leaves a
window in which an edit to a just-loaded dependency is missed. The watcher's ready cannot be
that trigger: a plugin attaching a listener at the earliest available moment (configureServer,
137ms) never observes it across the whole 200s crawl.
Possible fixes, roughly in order of how much they help
Watch lazily. Vitest already knows its spec files and their dependencies; watching the module
graph rather than the whole root would make startup proportional to the suite. This is what Karma
does, and it is the only option indifferent to repository size. A third-party plugin can already
assemble a complete scope from configureVitest and defineCacheKeyGenerator alone, which
suggests no new API is needed.
A native recursive watch (fs.watch(root, { recursive: true })) is O(1) to establish — it
arms immediately with a single handle — but it loses events and should not be adopted as it
stands. One handle means one ReadDirectoryChangesW buffer for the whole tree. Measured on
Windows with a 1,000-file burst driven from a separate process, three trials each:
watcher
handles
distinct files seen
lost
fs.watch recursive
1
213 / 205 / 179
787 / 795 / 821
chokidar 3.6.0
1001
1000
0
chokidar's per-entry handles each have their own buffer, which is precisely why it does not drop
events — the cost and the correctness are the same design choice. A native watch is also a kernel
watch only on Windows and macOS: on Linux, Node runs a userspace shim that opens a handle per
file and directory (fs: add recursive watch for linux nodejs/node#45098, shipped in v19.1.0 as "fs.watch recursive support on
Linux", commit 34bfef91a9) — which is also why it cannot be feature-detected, since the ERR_FEATURE_UNAVAILABLE_ON_PLATFORM throw no longer fires there.
Make the exclusions gitignore-aware. Not a solution on its own, but it removes the largest
single component at essentially no cost and, unlike a hand-written denylist, does not rot. This
part belongs to Vite, not Vitest. It must not be done by un-gating the outDir exclusion.
At minimum, document it. The interaction between the top-level root, test.projects[].root
and watch cost is not discoverable, and the natural reading — that a per-project root bounds the
work — is wrong.
Scope note
A plain Vite dev server rooted at the same repository pays the identical crawl: createServer({ root }) with no Vitest returns in 137ms and then settles after 200.5s across
181,692 handles, with an 87-second worst-case event-loop stall. So the crawl itself is Vite's,
and Vite's dev server legitimately wants to watch its root for HMR.
What is specific to Vitest is combining a root-sized watch with a workload that only needs the module
graph, having no supported way to opt out (the ??= above), and already holding the information
needed to scope it. Happy to move this to vitejs/vite if you disagree.
git clone https://github.com/asapach/vitest-watch-root-repro &&cd vitest-watch-root-repro
npm install
node generate.mjs . 20000 # 20,053 directories, 60,000 files
npx vitest run # time to first result
npx vitest --watch # time to first result
--watch is required rather than cosmetic: the default is !isCI && process.stdin.isTTY && !isAgent (vitest/dist/chunks/defaults.D2ip7f-X.js:53), so a
redirected or piped stdin silently gives run mode and no asymmetry at all. Confirm the banner reads DEV, not RUN.
The fixture is additive and has no cleanup step, so generate in ascending size order to reproduce the table.
Describe the bug
In watch mode Vitest's top-level Vite server hands chokidar the config
rootto watch recursively.chokidar opens an
fs.watchhandle per entry — every file as well as every directory — before thefirst test runs, so startup scales with repository size rather than with the test suite.
vitest runis unaffected: Vitest swaps in Vite'sNoopWatcherwhen watch mode is off. Same config,same root, same single trivial spec — only the mode differs:
rootvitest runvitest --watchMedians of 4 watch runs per size, all measured in one directory.
rundoes not move as the treegrows 10x. Watch is linear in watched entries at a near-constant ~0.46 ms each (0.48 / 0.46 /
0.44), and extrapolating that to the monorepo's 181,710 entries predicts ~82s against the 102.2s
actually measured — so the synthetic fixture models the real cost to within about 25%.
One caveat for anyone re-measuring: the per-entry constant depends heavily on where the tree lives,
so compare only within a single directory. The identical 32,060-entry fixture measured 3.6s under
%TEMP%and 15.97s underC:\Developmenton this machine, and run-to-run variance was far worseunder
%TEMP%(one run of four took 21.0s, against a ±10% spread in the numbers above).The handle count is the clearest evidence that the whole tree is being visited. Counting
FSWatcherhandles viaprocess._getActiveHandles()once the watcher settles:One handle per file and per directory, whether or not anything is reachable from a test.
Expected: watch-mode startup scales with the test suite and its module graph, as
rundoes. Adirectory containing no test files and no test dependency should not be visited at startup.
Actual: watch-mode startup scales with the size of the tree under
root.Root cause
Line numbers from the installed dist of vitest 5.0.0 / vite 8.3.0.
vitest/dist/chunks/index.B89dZ0-N.js:8896— run mode disables the watcher:server.watch === nullgets Vite'sNoopWatcher, which is whyrunnever pays.index.B89dZ0-N.js:9158— only one watcher exists per process:The cost therefore attaches to the top-level config's
root. Per-projectrootvalues arefree, which makes this easy to misattribute.
vite/dist/node/chunks/node.js:24288— the top-level server hands chokidar the root:node.js:16353— the default exclusions are narrow and none is gitignore-aware:Generated trees are crawled exactly like source. In the monorepo above, gitignored output
directories account for 59.2% of all watched entries.
outDiris not the issue: its exclusion is gated onemptyOutDir, and Vitest setsemptyOutDir: falsedeliberately (index.B89dZ0-N.js:8880-8885, citing fix: don't add outDirs to watch.ignored if emptyOutDir is false vitejs/vite#16453), sobuild output stays watched on purpose.
A related smaller bug:
server.watch = nullin config is silently ignoredBecause of the
??=on line 8898, the one setting a user would reach for to opt out is overwrittenby the slow path —
??=fires onnull. Measured on the 80,069-entry fixture: 34.92s withserver: { watch: null }against a 35.02s baseline, i.e. no effect at all. Worth fixingindependently of everything else.
Ruled out by measurement — listed to save triage time
test.dir, a narrowedtest.exclude, andforceRerunTriggers: []changenothing about what is watched. On the 80,069-entry fixture all three, and the baseline, settle at
an identical 80,069
FSWatcherhandles.tinyglobbyprunes to a static prefix, so globbingis cheap.
optimizeDepsto{ noDiscovery: true, entries: [], include: [] }on the parent's environments(
index.B89dZ0-N.js:7193-7231, applied at:7352). It would also affectrun, which is flat.index.B89dZ0-N.js:10049halves the count:
vitest.config.watch ? Math.max(Math.floor(maxThreadsCount / 2), 1) : ....tsconfckdependency at all.node.js:11464and lists no chokidar dependency, sonpm ls chokidaris misleading.Workaround (works today, and suggests the fix needs no new API)
Narrow what chokidar traverses with a
server.watch.ignoredpredicate that keeps the root, thefiles directly in it, and a scope discovered from the host, pruning everything else at depth one.
resolveChokidarOptionshonours a predicate (normalizeIgnoredpasses non-strings through,node.js:11523).On the 80,069-entry tree this takes watch startup from 35.02s to 1.00s, against a run-mode floor
of 0.97s — the watch penalty disappears entirely.
Two hooks supply a complete scope, and neither alone is enough:
configureVitestgives each project'sincludeglobs, trimmed to the part before the firstwildcard — so a spec that has never been loaded, in a directory that did not exist, is still seen.
defineCacheKeyGeneratorgives every module a run loads. It fires per module while the cachepath is computed, which precedes the cache lookup, so unlike
transformit is not silenced bytest.fsModuleCache. Returning nothing leaves the key untouched.Three approaches look like they would work and do not:
it matters.
transformpasses cold and covers nothing warm. Withtest.fsModuleCache: true, a spec importing 14 modules across 14 directories givestransform14/14 directories cold and 0/14 warm — the scope is simply empty on a warm run.resolveIdis not an alternative: it is called twice in that run, both times forindex.html,and never for a fixture module.
cached imports into it (
index.B89dZ0-N.js:9780-9791, "we populate the module graph to make thewatch mode work because it relies on importers") — but read
environments.client, notssr,which shows only entry files. The real objection is that it needs a trigger, and a poll leaves a
window in which an edit to a just-loaded dependency is missed. The watcher's
readycannot bethat trigger: a plugin attaching a listener at the earliest available moment (
configureServer,137ms) never observes it across the whole 200s crawl.
Possible fixes, roughly in order of how much they help
Watch lazily. Vitest already knows its spec files and their dependencies; watching the module
graph rather than the whole root would make startup proportional to the suite. This is what Karma
does, and it is the only option indifferent to repository size. A third-party plugin can already
assemble a complete scope from
configureVitestanddefineCacheKeyGeneratoralone, whichsuggests no new API is needed.
A native recursive watch (
fs.watch(root, { recursive: true })) is O(1) to establish — itarms immediately with a single handle — but it loses events and should not be adopted as it
stands. One handle means one
ReadDirectoryChangesWbuffer for the whole tree. Measured onWindows with a 1,000-file burst driven from a separate process, three trials each:
fs.watchrecursivechokidar's per-entry handles each have their own buffer, which is precisely why it does not drop
events — the cost and the correctness are the same design choice. A native watch is also a kernel
watch only on Windows and macOS: on Linux, Node runs a userspace shim that opens a handle per
file and directory (fs: add recursive watch for linux nodejs/node#45098, shipped in v19.1.0 as "fs.watch recursive support on
Linux", commit
34bfef91a9) — which is also why it cannot be feature-detected, since theERR_FEATURE_UNAVAILABLE_ON_PLATFORMthrow no longer fires there.Make the exclusions gitignore-aware. Not a solution on its own, but it removes the largest
single component at essentially no cost and, unlike a hand-written denylist, does not rot. This
part belongs to Vite, not Vitest. It must not be done by un-gating the
outDirexclusion.At minimum, document it. The interaction between the top-level
root,test.projects[].rootand watch cost is not discoverable, and the natural reading — that a per-project
rootbounds thework — is wrong.
Scope note
A plain Vite dev server rooted at the same repository pays the identical crawl:
createServer({ root })with no Vitest returns in 137ms and then settles after 200.5s across181,692 handles, with an 87-second worst-case event-loop stall. So the crawl itself is Vite's,
and Vite's dev server legitimately wants to watch its root for HMR.
What is specific to Vitest is combining a root-sized watch with a workload that only needs the module
graph, having no supported way to opt out (the
??=above), and already holding the informationneeded to scope it. Happy to move this to vitejs/vite if you disagree.
Reproduction
https://github.com/asapach/vitest-watch-root-repro
--watchis required rather than cosmetic: the default is!isCI && process.stdin.isTTY && !isAgent(vitest/dist/chunks/defaults.D2ip7f-X.js:53), so aredirected or piped stdin silently gives run mode and no asymmetry at all. Confirm the banner reads
DEV, notRUN.The fixture is additive and has no cleanup step, so generate in ascending size order to reproduce the table.
System Info
System: OS: Windows 11 10.0.26100 CPU: (18) x64 Intel(R) Core(TM) Ultra 5 135H Memory: 9.36 GB / 31.46 GB Binaries: Node: 24.16.0 - C:\Program Files\nodejs\node.EXE npm: 11.13.0 - C:\Program Files\nodejs\npm.CMD Browsers: Chrome: 153.0.8010.53 Edge: Chromium (152.0.4191.53) Internet Explorer: 11.0.26100.8115 npmPackages: vite: ^8.3.0 => 8.3.0 vitest: ^5.0.0 => 5.0.0Used Package Manager
npm
Validations