Skip to content

@stylexjs/unplugin leaks ~581 file handles per Vitest run — Babel config discovery + unrefed timer #1533

@lecstor

Description

@lecstor

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

  1. Create a Vite + React project using @stylexjs/unplugin as the StyleX integration.
  2. Add Vitest with a working test suite that imports at least a handful of modules containing StyleX calls.
  3. Run: bash npx vitest run --reporter=hanging-process
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions