Describe the bug
vi.mock(path, async (importOriginal) => …) in browser mode permanently deadlocks the run when the mocked module's import graph is large/slow to load. The run wedges at mock-init with zero tests executed; when the browser is later killed (CI timeout, etc.) it surfaces as:
Error: [vitest] There was an error when mocking a module …
Caused by: Error: [vitest] Browser connection was closed while running tests. Was the page closed unexpectedly?
Caused by: Error: [birpc] rpc is closed, cannot call "resolveManualMock"
This looks related to #546 (node-mode importActual recursion hang) and produces the same error signature as #8745 (closed, couldn't reproduce). I traced it end-to-end with instrumentation and can localize the hang precisely, though I could not distill it into a synthetic standalone repro (details below).
The deadlock geometry (instrumented trace)
I patched trace logging into the installed dists (@vitest/browser, @vitest/browser-playwright, @vitest/mocker, the tester bundle) and added a vite middleware logging request open/close. Every hop succeeds except the last:
registerMock (server) ✅ → mocker.register → playwright context.route(predicate, handler) registered ✅
- Route HIT for the mocked module's URL ✅ — handler starts
await module.resolve() (holds the paused request open by design)
rpc.resolveManualMock(url) server→tester ✅ — tester receives it ✅
resolveFactoryModule ✅ → registry has the mock ✅ → user factory runs ✅
importActual('./heavy-module') → rpc.resolveId resolves ✅
import("/src/.../heavy-module.ts?_vitest_original&ext.ts") never resolves. No error, no timeout — permanent wedge. Node event loop idle in kevent; renderer idle.
CDP protocol capture (DEBUG=pw:protocol) during the wedge:
- 395
Fetch.requestPaused events; 394 continued/fulfilled. The single never-continued request is the mocked module's bare URL — held by the vitest route handler awaiting the factory (step 2), i.e. by design.
- The
?_vitest_original request itself completed (Network.loadingFinished, served from HTTP cache in that run), and all of its subgraph requests (476) reached the vite server and completed (res.on("close") fired for every one).
So: all network traffic finishes, the vite server is idle and responsive (a manual curl for a module URL answers in 15ms mid-wedge), playwright continued everything it should, and yet the dynamic import's module namespace promise never settles — its evaluation appears to be transitively waiting on the module record of the bare URL that the mock route is holding, which in turn waits on the factory → cycle.
Reproduction conditions (and honest limitations)
In the affected project (private repo; Vite + @vitejs/plugin-react with the React Compiler babel plugin, @tailwindcss/vite, vite-plugin-wasm, a sizeable optimizeDeps.include list), this is 100% reproducible: any test that mocks a module whose graph pulls a large component tree (~476 requests: TSX + SCSS + prebundled deps) wedges every time when run cold/isolated. Tests mocking small modules with the identical factory shape pass. The trigger is timing-dependent: the same files passed for months inside warm full-suite runs (server transform cache collapses the graph-load time) and wedge deterministically in cold single-file invocations.
I attempted a standalone repro (300 → 1500 generated modules, serial chain and 3-ary tree, heavy function bodies, with and without an optimized dep like react in the mocked graph, including runs where "Re-optimizing dependencies" fired mid-startup) — all pass in ~3s. Plain esbuild-transformed TS appears to load too fast to open whatever window the real plugin pipeline (babel/sass/tailwind transform latency) opens. I'm happy to run any diagnostics/patches against the affected project and share full trace logs (the tracer-patched dist diffs, the request open/close log, and the pw:protocol capture).
Verified NOT fixed by: vitest 4.1.3 → 4.1.10, playwright 1.59.1 → 1.61.1, chromium headless shell 1217/1228, full chromium (channel: 'chromium'), fresh dep caches / full reinstall / reboot, server.warmup on the mocked module.
Workaround
vi.mock(path, { spy: true }) + selective mockImplementation — no factory rpc, no route hold, no cycle. The two affected test files pass in seconds with identical assertions.
Environment
- vitest 4.1.3 and 4.1.10 (both affected),
@vitest/browser-playwright 4.1.10
- playwright-core 1.59.1 and 1.61.1 (both affected)
- Chromium headless shell 1217/1228 and full chromium — all affected
- macOS 15 (Darwin 25.5.0) arm64 (Mac mini, 8 cores / 16 GB), Node 25.2.1, bun workspace
Validations
Describe the bug
vi.mock(path, async (importOriginal) => …)in browser mode permanently deadlocks the run when the mocked module's import graph is large/slow to load. The run wedges at mock-init with zero tests executed; when the browser is later killed (CI timeout, etc.) it surfaces as:This looks related to #546 (node-mode
importActualrecursion hang) and produces the same error signature as #8745 (closed, couldn't reproduce). I traced it end-to-end with instrumentation and can localize the hang precisely, though I could not distill it into a synthetic standalone repro (details below).The deadlock geometry (instrumented trace)
I patched trace logging into the installed dists (
@vitest/browser,@vitest/browser-playwright,@vitest/mocker, the tester bundle) and added a vite middleware logging request open/close. Every hop succeeds except the last:registerMock(server) ✅ →mocker.register→ playwrightcontext.route(predicate, handler)registered ✅await module.resolve()(holds the paused request open by design)rpc.resolveManualMock(url)server→tester ✅ — tester receives it ✅resolveFactoryModule✅ → registry has the mock ✅ → user factory runs ✅importActual('./heavy-module')→rpc.resolveIdresolves ✅import("/src/.../heavy-module.ts?_vitest_original&ext.ts")never resolves. No error, no timeout — permanent wedge. Node event loop idle in kevent; renderer idle.CDP protocol capture (
DEBUG=pw:protocol) during the wedge:Fetch.requestPausedevents; 394 continued/fulfilled. The single never-continued request is the mocked module's bare URL — held by the vitest route handler awaiting the factory (step 2), i.e. by design.?_vitest_originalrequest itself completed (Network.loadingFinished, served from HTTP cache in that run), and all of its subgraph requests (476) reached the vite server and completed (res.on("close")fired for every one).So: all network traffic finishes, the vite server is idle and responsive (a manual
curlfor a module URL answers in 15ms mid-wedge), playwright continued everything it should, and yet the dynamic import's module namespace promise never settles — its evaluation appears to be transitively waiting on the module record of the bare URL that the mock route is holding, which in turn waits on the factory → cycle.Reproduction conditions (and honest limitations)
In the affected project (private repo; Vite +
@vitejs/plugin-reactwith the React Compiler babel plugin,@tailwindcss/vite,vite-plugin-wasm, a sizeableoptimizeDeps.includelist), this is 100% reproducible: any test that mocks a module whose graph pulls a large component tree (~476 requests: TSX + SCSS + prebundled deps) wedges every time when run cold/isolated. Tests mocking small modules with the identical factory shape pass. The trigger is timing-dependent: the same files passed for months inside warm full-suite runs (server transform cache collapses the graph-load time) and wedge deterministically in cold single-file invocations.I attempted a standalone repro (300 → 1500 generated modules, serial chain and 3-ary tree, heavy function bodies, with and without an optimized dep like
reactin the mocked graph, including runs where "Re-optimizing dependencies" fired mid-startup) — all pass in ~3s. Plain esbuild-transformed TS appears to load too fast to open whatever window the real plugin pipeline (babel/sass/tailwind transform latency) opens. I'm happy to run any diagnostics/patches against the affected project and share full trace logs (the tracer-patched dist diffs, the request open/close log, and the pw:protocol capture).Verified NOT fixed by: vitest 4.1.3 → 4.1.10, playwright 1.59.1 → 1.61.1, chromium headless shell 1217/1228, full chromium (
channel: 'chromium'), fresh dep caches / full reinstall / reboot,server.warmupon the mocked module.Workaround
vi.mock(path, { spy: true })+ selectivemockImplementation— no factory rpc, no route hold, no cycle. The two affected test files pass in seconds with identical assertions.Environment
@vitest/browser-playwright4.1.10Validations