Plan: Q22 — Minimal upstream repro for vitest-dev/vitest
Companion to
docs/plans/q22-playwright-ct.md.Authored: iteration 102 (2026-04-26).
Status: 🗄️ SUPERSEDED (iteration 146, 2026-04-27) — never executed; not pursued. The upstream-repro plan was authored at iter 102 as a hedge in case the Playwright CT migration (Q22 follow-up #3) failed to close the Q22 fingerprint at the source-runner level. That migration succeeded instead: by iter 105 (Q22 ✅), iter 107 (Q23 ✅), iter 108 (preemptive MobileMenu CT migration ✅), iter 109 (Q24 ✅), and iter 121-124 (Q22 follow-up #3 / Q26 / Q27 ✅), the entire Q22 → Q28 saga reached "no carried open work" without requiring an upstream
vitest-dev/vitestfix. Status flipped belatedly iter 146 (the iter-144 audit pass missed this entry because its Status line uses proseStatus: **DRAFT — ...**rather than the^Status:.*DRAFTpattern the iter-145 grep checklist later codified). Plan is preserved in-tree as historical record; if a future similar issue surfaces in another package's Vitest+jsdom surface, the repro recipe and 3-environment verification matrix below remain a useful starting point.Original status (iteration 102, preserved for archeology):
DRAFT — execute in parallel with the Playwright CT migration so the upstream issue is filed regardless of whether we move off Vitest for this surface.
Why bother with an upstream repro if we're migrating off Vitest?
Three reasons:
- Other packages still use Vitest + jsdom + fireEvent. If a future
commit re-introduces a
useEffectcleanup + conditional remount pattern inMobileMenuorLayoutSwitcher, we want to learn about the regression source from upstream rather than rediscover it. - Our diagnostic data is high-quality. Iterations 97-101 produced a matrix of pool/version/reporter/test-count outcomes that significantly narrows the bug surface for upstream maintainers. That work shouldn't be wasted.
- A community response may unblock the Vitest path entirely, removing the need for the Playwright CT migration on Windows. Worth one upstream issue to find out.
Repro recipe (target shape)
A single-file pnpm project that hangs identically to our FilterBar
case but mentions only Preact, Vitest, jsdom, and Node — no @ever-works/*
packages.
q22-repro/
├── package.json ← pnpm + vitest 4.1.5 + preact 10.29.1 + jsdom 29 + @testing-library/preact
├── pnpm-lock.yaml
├── vitest.config.ts ← pool: 'forks', maxWorkers: 1, environment: 'jsdom'
├── tsconfig.json ← preact JSX
├── src/
│ ├── FilterBarRepro.tsx ← Preact component: 3 useState + 2 useEffect + 1 conditional remount
│ └── FilterBarRepro.test.tsx ← 16 trivial tests, 1 of them uses fireEvent.click
└── README.md ← Reproduction instructions, version table, expected vs. actual
Component (FilterBarRepro.tsx)
A stripped-down FilterBar that retains only the structural pattern that
triggers the crash:
import { useState, useEffect, useCallback } from 'preact/hooks';
export default function FilterBarRepro() {
const [a, setA] = useState<string | null>(null);
const [b, setB] = useState<string[]>([]);
const [_c, _setC] = useState<number>(0);
useEffect(() => { /* sync from props equivalent */ }, [a]);
useEffect(() => { /* sync from props equivalent */ }, [b]);
const onClickA = useCallback(() => {
setA((prev) => (prev === 'a' ? null : 'a'));
}, []);
const showClear = a !== null || b.length > 0;
return (
<div data-testid="root">
<button type="button" onClick={onClickA}>Toggle A</button>
{showClear && (
<button type="button" onClick={() => { setA(null); setB([]); }}>
Clear
</button>
)}
</div>
);
}
Test file (FilterBarRepro.test.tsx)
/** @jsxImportSource preact */
import { describe, it, expect } from 'vitest';
import { render, fireEvent, screen } from '@testing-library/preact';
import FilterBarRepro from './FilterBarRepro';
describe('FilterBarRepro', () => {
// 15 trivial render-only tests (passes individually)
for (let i = 0; i < 15; i++) {
it(`render ${i}`, () => {
const { container } = render(<FilterBarRepro />);
expect(container.querySelector('[data-testid="root"]')).toBeTruthy();
});
}
// 1 fireEvent test (triggers conditional remount of "Clear" button)
it('toggles A and reveals Clear', async () => {
render(<FilterBarRepro />);
const toggle = screen.getByText('Toggle A');
fireEvent.click(toggle);
// The "Clear" button now appears — this is the conditional remount.
expect(screen.getByText('Clear')).toBeTruthy();
});
});
Vitest config
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
pool: 'forks',
maxWorkers: 1,
},
});
package.json (key bits)
{
"name": "q22-repro",
"type": "module",
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"@testing-library/preact": "^3.2.4",
"jsdom": "^29.0.2",
"preact": "^10.29.1",
"typescript": "^6.0.3",
"vitest": "^4.1.5"
}
}
Verification before filing
Before opening the upstream issue:
pnpm install && pnpm teston Windows + Node 24 → confirm the repro crashes with[vitest-pool]: Worker forks emitted error / Worker exited unexpectedly. Capture the full stderr.pnpm teston Linux + Node 24 (WSL2 if available) → confirm whether the bug reproduces. If Linux passes, it's a Windows + Node 24 bug; include this in the upstream report.pnpm teston Windows + Node 22 LTS (manuallynvm use 22, this also resolves Q22 Option E) → confirm whether the bug reproduces. If Node 22 passes, it's a Node 24 IPC regression; include this in the upstream report.pnpm exec vitest --version→ confirm Vitest 4.1.5.
Where to file
Primary: https://github.com/vitest-dev/vitest/issues
Secondary considerations (file only if maintainers redirect):
- https://github.com/preactjs/preact/issues — only if maintainers say Preact's event delegation is the cause, not Vitest.
- https://github.com/jsdom/jsdom/issues — only if maintainers say jsdom 29 has a teardown handle leak under fork IPC.
- https://github.com/nodejs/node/issues — only if Node 22 vs. Node 24
testing in step 3 above isolates the bug to Node 24's
child_process.forkIPC.
Issue template (paste into GitHub)
### Summary
`vitest run` hangs/crashes after partial test execution when a Preact
component with `useState`+`useEffect`+conditional remount is exercised via
`@testing-library/preact` `fireEvent` under `pool: 'forks'`,
`environment: 'jsdom'`, on Windows + Node 24.14.0.
### Versions (verified)
- Vitest 4.1.5 (and 3.2.4 — *also* crashes, see "Bisect" below)
- @testing-library/preact 3.2.4
- preact 10.29.1
- jsdom 29.0.2
- Node 24.14.0
- Windows 10 19045
- pnpm 10
### Repro
1. `git clone <repro repo>` (link the repo)
2. `pnpm install`
3. `pnpm test`
### Expected
All 16 tests pass.
### Actual
- Vitest 4.1.5: 5 of 16 tests pass, then `[vitest-pool]: Worker forks
emitted error / Worker exited unexpectedly` (run terminates ~17 min wall).
- Vitest 3.2.4: 2 of 16 tests pass, then `Unhandled Rejection: Error:
Channel closed` `code: 'ERR_IPC_CHANNEL_CLOSED'` from
`tinypool/dist/index.js:140 (ProcessWorker.send)`.
### Bisect
Vitest 3.2.4 is *worse* than 4.1.5 (2/16 vs 5/16 before crash), so this is
**not** a regression from the 4.x pool rewrite (PR #8705) — the bug
pre-dates that change.
### Diagnostic matrix
- `pool: 'forks'` → hangs after 5 tests (4.1.5)
- `pool: 'threads'` → hangs after 4 tests
- `pool: 'vmThreads'` → hangs after 3 tests
- `--reporter=json` → hangs identically (rules out stdout pipe backpressure)
- `--no-isolate` → hangs identically
- Single-test isolation (`-t`) → passes 1/1
- Skipping the first 3 tests via `-t pattern` still hangs *before* test 4
runs — so the hang is not cumulative state from completed tests.
### File-split workaround
Splitting the 16-test file into 5 files of ≤5 tests each:
- Render-only file (5 tests) → passes 5/5 in 5.38 s.
- `fireEvent`-using file (3 tests) → 0/3 + worker crash at 386 s.
So the boundary is **`fireEvent` + this component**, not test count.
### Hypothesized layer
Preact event delegation + `useEffect` cleanup interacting with jsdom
event-target teardown when the conditional `Clear` button mounts after the
first `fireEvent.click`, causing a handle leak that crashes the worker on
Node 24 + Windows.
### Cross-platform
[ ] Will fill in: pass/fail on Linux + Node 24 (WSL2)
[ ] Will fill in: pass/fail on Windows + Node 22 LTS
### Workaround
We are migrating affected tests to **`@playwright/experimental-ct-react`** with a `react` → `preact/compat` Vite alias (real Chromium mount, no jsdom). Playwright does not publish a first-party `experimental-ct-preact` package; the React variant + alias is the documented Preact pattern. See linked downstream tracking issue.
After filing
- Add the upstream issue URL to
docs/questions.mdQ22 status block. - Add a
docs/log.mdentry:Filed upstream issue vitest-dev/vitest#XXXX. - Subscribe the user (
[email protected]) to the issue for updates. - Re-evaluate every 2-3 iterations whether upstream has responded; if a fix lands, plan a Vitest-only restoration (revert the Playwright CT migration, restore the original test file from git history).
Estimated time
~2-3 hours total to author the repro repo, verify on the 3 environment combinations, and write the issue. Independent of the Playwright CT migration, so it can run in parallel.