Describe the issue
When using @stylexjs/unplugin with Vitest (middleware-mode Vite dev server), every transformAsync() call triggers Babel's automatic config-file discovery. Because babelrc: false is set but configFile is not explicitly disabled, Babel proceeds with root config file discovery, triggering findRootConfig() → readConfig() for every transform call. Each invocation launches up to 7 parallel file-system probes for config file variants (babel.config.{js,cjs,mjs,json,cts,mts,ts}) in the project root. When running in async mode (via transformAsync), these use callback-based fs.readFile/fs.stat operations whose handles are tracked as async resources. In a medium-sized project (~80 modules) this accumulates to ~581 leaked FILEHANDLE resources reported by Vitest's --reporter=hanging-process.
A secondary contributor is the setInterval polling timer in the Vite configureServer() hook. The timer is never unref()'d, so when Vitest runs the plugin in middleware mode (no HTTP server emitting 'close'), the interval keeps the Node.js event loop alive and prevents clean process exit.
Root Cause Analysis
Cause 1 — Babel config-file discovery (FILEHANDLE ×581)
In packages/@stylexjs/unplugin/src/core.js, the runBabelTransform() helper calls transformAsync() with babelrc: false but omits configFile. When configFile is not explicitly set to false, Babel proceeds with root config file discovery, triggering up to 7 parallel file-system probes per transform. In async mode, these use callback-based fs.readFile/fs.stat operations whose handles are tracked as async resources by Vitest's reporter.
Cause 2 — Unreferenced setInterval timer (Timeout ×1)
In packages/@stylexjs/unplugin/src/vite.js, the configureServer() hook starts a setInterval that polls the shared store version every 150 ms to push CSS updates over WebSocket. It attaches a cleanup listener to server.httpServer?.once('close', ...), but when Vitest runs Vite in middleware mode httpServer is null. The interval therefore runs forever, keeping the Node.js event loop alive.
Expected behavior
Zero leaked FILEHANDLE resources. The process should exit immediately once tests complete, with no hanging operations reported by vitest run --reporter=hanging-process.
Steps to reproduce
see https://github.com/lecstor/stylex-filehandle-repro
- Create a Vite + React project using
@stylexjs/unplugin as the StyleX integration.
- Add Vitest with a working test suite that imports at least a handful of modules containing StyleX calls.
- Run:
bash npx vitest run --reporter=hanging-process
- After the test suite completes, observe the reporter output.
There are 20 handle(s) keeping the process running
# FILEHANDLE (unknown stack trace) x20
close timed out after 10000ms
Tests closed successfully but something prevents Vite server from exiting
Environment
| Component |
Version |
@stylexjs/unplugin |
0.17.5 |
@babel/core |
7.x |
| Vite |
7.x |
| Vitest |
3.x |
| Node.js |
v24.1.0 |
| OS |
macOS (Darwin arm64) |
Test case
https://github.com/lecstor/stylex-filehandle-repro
Additional comments
I have a fix ready for both causes and will open a PR shortly.
Workaround
Until this is fixed upstream, users can work around the hang by reducing teardownTimeout in their Vitest config:
// vitest.config.ts
test: {
teardownTimeout: 1000, // force-exit after 1s instead of waiting 10s
}
Notes on the fix
- Both changes are minimal, targeted, one-line fixes.
babelrc: false was already set — adding configFile: false is the symmetric completion of "don't look for external config."
interval.unref() is the standard Node.js pattern for optional timers (used in Vite core itself).
- No new dependencies, no public API changes, no behavior changes in production builds.
- The
server.httpServer?.once('close', ...) cleanup listener is preserved as a belt-and-suspenders safeguard for non-middleware environments.
Describe the issue
When using
@stylexjs/unpluginwith Vitest (middleware-mode Vite dev server), everytransformAsync()call triggers Babel's automatic config-file discovery. Becausebabelrc: falseis set butconfigFileis not explicitly disabled, Babel proceeds with root config file discovery, triggeringfindRootConfig()→readConfig()for every transform call. Each invocation launches up to 7 parallel file-system probes for config file variants (babel.config.{js,cjs,mjs,json,cts,mts,ts}) in the project root. When running in async mode (viatransformAsync), these use callback-basedfs.readFile/fs.statoperations whose handles are tracked as async resources. In a medium-sized project (~80 modules) this accumulates to ~581 leakedFILEHANDLEresources reported by Vitest's--reporter=hanging-process.A secondary contributor is the
setIntervalpolling timer in the ViteconfigureServer()hook. The timer is neverunref()'d, so when Vitest runs the plugin in middleware mode (no HTTP server emitting'close'), the interval keeps the Node.js event loop alive and prevents clean process exit.Root Cause Analysis
Cause 1 — Babel config-file discovery (
FILEHANDLE×581)In
packages/@stylexjs/unplugin/src/core.js, therunBabelTransform()helper callstransformAsync()withbabelrc: falsebut omitsconfigFile. WhenconfigFileis not explicitly set tofalse, Babel proceeds with root config file discovery, triggering up to 7 parallel file-system probes per transform. In async mode, these use callback-basedfs.readFile/fs.statoperations whose handles are tracked as async resources by Vitest's reporter.Cause 2 — Unreferenced
setIntervaltimer (Timeout×1)In
packages/@stylexjs/unplugin/src/vite.js, theconfigureServer()hook starts asetIntervalthat polls the shared store version every 150 ms to push CSS updates over WebSocket. It attaches a cleanup listener toserver.httpServer?.once('close', ...), but when Vitest runs Vite in middleware modehttpServerisnull. The interval therefore runs forever, keeping the Node.js event loop alive.Expected behavior
Zero leaked
FILEHANDLEresources. The process should exit immediately once tests complete, with no hanging operations reported byvitest run --reporter=hanging-process.Steps to reproduce
see https://github.com/lecstor/stylex-filehandle-repro
@stylexjs/unpluginas the StyleX integration.bash npx vitest run --reporter=hanging-processEnvironment
@stylexjs/unplugin@babel/coreTest case
https://github.com/lecstor/stylex-filehandle-repro
Additional comments
I have a fix ready for both causes and will open a PR shortly.
Workaround
Until this is fixed upstream, users can work around the hang by reducing
teardownTimeoutin their Vitest config:Notes on the fix
babelrc: falsewas already set — addingconfigFile: falseis the symmetric completion of "don't look for external config."interval.unref()is the standard Node.js pattern for optional timers (used in Vite core itself).server.httpServer?.once('close', ...)cleanup listener is preserved as a belt-and-suspenders safeguard for non-middleware environments.