Skip to content

Browser mode: manual mock factory awaiting importOriginal() deadlocks when the mocked module has a heavy import graph #10747

Description

@jibize

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:

  1. registerMock (server) ✅ → mocker.register → playwright context.route(predicate, handler) registered ✅
  2. Route HIT for the mocked module's URL ✅ — handler starts await module.resolve() (holds the paused request open by design)
  3. rpc.resolveManualMock(url) server→tester ✅ — tester receives it ✅
  4. resolveFactoryModule ✅ → registry has the mock ✅ → user factory runs
  5. importActual('./heavy-module')rpc.resolveId resolves ✅
  6. 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

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