diff --git a/e2e/soft-mode-happy-dom/fixtures/rstest.config.mts b/e2e/soft-mode-happy-dom/fixtures/rstest.config.mts
new file mode 100644
index 000000000..bda670482
--- /dev/null
+++ b/e2e/soft-mode-happy-dom/fixtures/rstest.config.mts
@@ -0,0 +1,14 @@
+import { defineConfig } from '@rstest/core';
+
+export default defineConfig({
+ experiments: {
+ softMode: true,
+ },
+ testEnvironment: 'happy-dom',
+ // Force single worker so file-a always precedes file-b — assertions
+ // about cross-file state reset are deterministic.
+ pool: {
+ maxWorkers: 1,
+ },
+ setupFiles: ['./test/setup.ts'],
+});
diff --git a/e2e/soft-mode-happy-dom/fixtures/test/a-init.test.ts b/e2e/soft-mode-happy-dom/fixtures/test/a-init.test.ts
new file mode 100644
index 000000000..5c582ab14
--- /dev/null
+++ b/e2e/soft-mode-happy-dom/fixtures/test/a-init.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from '@rstest/core';
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __soft_worker_pid__: number | undefined;
+}
+
+globalThis.__soft_worker_pid__ ??= process.pid;
+
+describe('soft mode happy-dom — file A', () => {
+ it('owns a fresh DOM body', () => {
+ expect(document.body.innerHTML).toBe('');
+ document.body.innerHTML = '
x
';
+ });
+
+ it('mutates storage', () => {
+ expect(localStorage.length).toBe(0);
+ localStorage.setItem('from-a', 'leaked-if-soft-reset-broken');
+ });
+});
diff --git a/e2e/soft-mode-happy-dom/fixtures/test/b-verify.test.ts b/e2e/soft-mode-happy-dom/fixtures/test/b-verify.test.ts
new file mode 100644
index 000000000..1c0fb91ee
--- /dev/null
+++ b/e2e/soft-mode-happy-dom/fixtures/test/b-verify.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from '@rstest/core';
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __soft_worker_pid__: number | undefined;
+}
+
+describe('soft mode happy-dom — file B', () => {
+ it('runs in the same worker as file A', () => {
+ expect(globalThis.__soft_worker_pid__).toBe(process.pid);
+ });
+
+ it('starts with a clean DOM body', () => {
+ expect(document.body.innerHTML).toBe('');
+ expect(document.querySelector('#from-a')).toBeNull();
+ });
+
+ it('starts with clean storage', () => {
+ expect(localStorage.length).toBe(0);
+ expect(localStorage.getItem('from-a')).toBeNull();
+ });
+
+ it('HTMLElement.prototype.focus descriptor restored across files', () => {
+ // setup.ts records the descriptor SHAPE on each module re-eval BEFORE
+ // re-patching. With softMode handling happy-dom, file-a saw 'value'
+ // (fresh DOM) and file-b also saw 'value' because softResetEnv's
+ // protoSnapshot restore wiped file-a's getter-only patch.
+ const history = (
+ globalThis as { __soft_focus_descriptor_history__?: Array }
+ ).__soft_focus_descriptor_history__;
+ expect(history).toEqual(['value', 'value']);
+ });
+});
diff --git a/e2e/soft-mode-happy-dom/fixtures/test/setup.ts b/e2e/soft-mode-happy-dom/fixtures/test/setup.ts
new file mode 100644
index 000000000..b31085957
--- /dev/null
+++ b/e2e/soft-mode-happy-dom/fixtures/test/setup.ts
@@ -0,0 +1,38 @@
+/**
+ * Mirrors the jsdom fixture's setup-spy.ts but for happy-dom. happy-dom
+ * exposes the same Element/HTMLElement/Node globals jsdom does, so the
+ * vendor-style focus monkey-patch + descriptor-history recording is
+ * portable. softResetEnv's protoSnapshot path handles both env names.
+ */
+if (typeof HTMLElement !== 'undefined') {
+ const preDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'focus',
+ );
+ (
+ globalThis as { __soft_focus_descriptor_history__?: Array }
+ ).__soft_focus_descriptor_history__ ??= [];
+ (
+ globalThis as { __soft_focus_descriptor_history__?: Array }
+ ).__soft_focus_descriptor_history__!.push(
+ !preDescriptor
+ ? 'missing'
+ : 'value' in preDescriptor && typeof preDescriptor.value === 'function'
+ ? 'value'
+ : preDescriptor.get && !preDescriptor.set
+ ? 'get-only'
+ : 'other',
+ );
+
+ const originalFocus = HTMLElement.prototype.focus;
+ Object.defineProperty(HTMLElement.prototype, 'focus', {
+ configurable: true,
+ get: () =>
+ function patchedFocus(this: HTMLElement, options?: FocusOptions) {
+ (globalThis as { __soft_focus_calls__?: number }).__soft_focus_calls__ =
+ ((globalThis as { __soft_focus_calls__?: number })
+ .__soft_focus_calls__ ?? 0) + 1;
+ return originalFocus.call(this, options);
+ },
+ });
+}
diff --git a/e2e/soft-mode-happy-dom/index.test.ts b/e2e/soft-mode-happy-dom/index.test.ts
new file mode 100644
index 000000000..b4d068fbc
--- /dev/null
+++ b/e2e/soft-mode-happy-dom/index.test.ts
@@ -0,0 +1,29 @@
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, it } from '@rstest/core';
+import { runRstestCli } from '../scripts/';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+describe('experiments.softMode — happy-dom', () => {
+ it('resets DOM/storage/prototype state between files (happy-dom env)', async ({
+ onTestFinished,
+ }) => {
+ // Mirrors the jsdom soft-mode fixture for happy-dom. Verifies that
+ // `softResetEnv` and the prototype-snapshot path handle the
+ // `happy-dom` env-name branch the same way they handle `jsdom`.
+ const { expectExecSuccess } = await runRstestCli({
+ command: 'rstest',
+ args: ['run'],
+ onTestFinished,
+ options: {
+ nodeOptions: {
+ cwd: join(__dirname, './fixtures'),
+ },
+ },
+ });
+
+ await expectExecSuccess();
+ });
+});
diff --git a/e2e/soft-mode-recycle/fixtures/rstest.config.mts b/e2e/soft-mode-recycle/fixtures/rstest.config.mts
new file mode 100644
index 000000000..6777c5937
--- /dev/null
+++ b/e2e/soft-mode-recycle/fixtures/rstest.config.mts
@@ -0,0 +1,15 @@
+import { defineConfig } from '@rstest/core';
+
+export default defineConfig({
+ // maxFilesPerWorker: 1 disposes the runner after every task, so each
+ // file gets a fresh worker process. With pool.maxWorkers: 2 and 4 files,
+ // every file's `process.pid` must be unique — the driver asserts that
+ // pidcount === filecount. Default soft-mode reuse would yield fewer
+ // pids than files (covered by e2e/soft-mode-reuse).
+ experiments: {
+ softMode: { maxFilesPerWorker: 1 },
+ },
+ pool: {
+ maxWorkers: 2,
+ },
+});
diff --git a/e2e/soft-mode-recycle/fixtures/test/f1.test.ts b/e2e/soft-mode-recycle/fixtures/test/f1.test.ts
new file mode 100644
index 000000000..00cad24c1
--- /dev/null
+++ b/e2e/soft-mode-recycle/fixtures/test/f1.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from '@rstest/core';
+import { recordPid } from './record-pid';
+
+recordPid('f1');
+
+describe('soft mode recycle — file 1', () => {
+ it('runs and records its pid', () => {
+ expect(true).toBe(true);
+ });
+});
diff --git a/e2e/soft-mode-recycle/fixtures/test/f2.test.ts b/e2e/soft-mode-recycle/fixtures/test/f2.test.ts
new file mode 100644
index 000000000..e5dde009f
--- /dev/null
+++ b/e2e/soft-mode-recycle/fixtures/test/f2.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from '@rstest/core';
+import { recordPid } from './record-pid';
+
+recordPid('f2');
+
+describe('soft mode recycle — file 2', () => {
+ it('runs and records its pid', () => {
+ expect(true).toBe(true);
+ });
+});
diff --git a/e2e/soft-mode-recycle/fixtures/test/f3.test.ts b/e2e/soft-mode-recycle/fixtures/test/f3.test.ts
new file mode 100644
index 000000000..593c31cc0
--- /dev/null
+++ b/e2e/soft-mode-recycle/fixtures/test/f3.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from '@rstest/core';
+import { recordPid } from './record-pid';
+
+recordPid('f3');
+
+describe('soft mode recycle — file 3', () => {
+ it('runs and records its pid', () => {
+ expect(true).toBe(true);
+ });
+});
diff --git a/e2e/soft-mode-recycle/fixtures/test/f4.test.ts b/e2e/soft-mode-recycle/fixtures/test/f4.test.ts
new file mode 100644
index 000000000..2d75c3e92
--- /dev/null
+++ b/e2e/soft-mode-recycle/fixtures/test/f4.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from '@rstest/core';
+import { recordPid } from './record-pid';
+
+recordPid('f4');
+
+describe('soft mode recycle — file 4', () => {
+ it('runs and records its pid', () => {
+ expect(true).toBe(true);
+ });
+});
diff --git a/e2e/soft-mode-recycle/fixtures/test/record-pid.ts b/e2e/soft-mode-recycle/fixtures/test/record-pid.ts
new file mode 100644
index 000000000..5d9ff07a0
--- /dev/null
+++ b/e2e/soft-mode-recycle/fixtures/test/record-pid.ts
@@ -0,0 +1,17 @@
+import { appendFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+
+/**
+ * Record this file's `process.pid` + the file's tag into a shared log.
+ * The driver reads this back after the fixture run and asserts the pid
+ * count matches the file count — proving the runner was disposed after
+ * each task (the configured `maxFilesPerWorker: 1` cap).
+ */
+export const PID_LOG_PATH =
+ process.env.RSTEST_SOFT_RECYCLE_LOG ??
+ join(tmpdir(), 'rstest-soft-mode-recycle.log');
+
+export const recordPid = (fileTag: string): void => {
+ appendFileSync(PID_LOG_PATH, `${process.pid}\t${fileTag}\n`);
+};
diff --git a/e2e/soft-mode-recycle/index.test.ts b/e2e/soft-mode-recycle/index.test.ts
new file mode 100644
index 000000000..4358558f2
--- /dev/null
+++ b/e2e/soft-mode-recycle/index.test.ts
@@ -0,0 +1,50 @@
+import { existsSync, readFileSync, unlinkSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from '@rstest/core';
+import { runRstestCli } from '../scripts/';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+describe('experiments.softMode — maxFilesPerWorker recycle', () => {
+ it('disposes the runner after each task when maxFilesPerWorker=1', async ({
+ onTestFinished,
+ }) => {
+ const logPath = join(
+ tmpdir(),
+ `rstest-soft-mode-recycle-${process.pid}-${Date.now()}.log`,
+ );
+ onTestFinished(() => {
+ if (existsSync(logPath)) unlinkSync(logPath);
+ });
+
+ const { expectExecSuccess } = await runRstestCli({
+ command: 'rstest',
+ args: ['run'],
+ onTestFinished,
+ options: {
+ nodeOptions: {
+ cwd: join(__dirname, './fixtures'),
+ env: {
+ RSTEST_SOFT_RECYCLE_LOG: logPath,
+ },
+ },
+ },
+ });
+ await expectExecSuccess();
+
+ // 4 fixture files × maxFilesPerWorker=1 → every runner is disposed
+ // after a single task, so every file MUST observe a unique pid. If
+ // recycling were broken (cap ignored or off-by-one), some pair of
+ // files would share a pid and this assertion would catch it.
+ const lines = readFileSync(logPath, 'utf8')
+ .split('\n')
+ .filter((l) => l.trim().length > 0);
+ expect(lines.length).toBe(4);
+
+ const pids = new Set(lines.map((line) => line.split('\t')[0]));
+ expect(pids.size).toBe(4);
+ });
+});
diff --git a/e2e/soft-mode-reuse/fixtures/rstest.config.mts b/e2e/soft-mode-reuse/fixtures/rstest.config.mts
new file mode 100644
index 000000000..002fa6f25
--- /dev/null
+++ b/e2e/soft-mode-reuse/fixtures/rstest.config.mts
@@ -0,0 +1,14 @@
+import { defineConfig } from '@rstest/core';
+
+export default defineConfig({
+ experiments: {
+ softMode: true,
+ },
+ // 4 test files + 2 workers → at least one worker handles ≥2 files,
+ // so at least one pair of files must share `process.pid`. The driver
+ // (e2e/soft-mode-reuse/index.test.ts) asserts the worker-reuse
+ // invariant after the fixture run by reading the recorded pids back.
+ pool: {
+ maxWorkers: 2,
+ },
+});
diff --git a/e2e/soft-mode-reuse/fixtures/test/f1.test.ts b/e2e/soft-mode-reuse/fixtures/test/f1.test.ts
new file mode 100644
index 000000000..7e76da6a8
--- /dev/null
+++ b/e2e/soft-mode-reuse/fixtures/test/f1.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from '@rstest/core';
+import { recordPid } from './record-pid';
+
+recordPid('f1');
+
+describe('soft mode reuse — file 1', () => {
+ it('runs and records its pid', () => {
+ expect(true).toBe(true);
+ });
+});
diff --git a/e2e/soft-mode-reuse/fixtures/test/f2.test.ts b/e2e/soft-mode-reuse/fixtures/test/f2.test.ts
new file mode 100644
index 000000000..fda971b2f
--- /dev/null
+++ b/e2e/soft-mode-reuse/fixtures/test/f2.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from '@rstest/core';
+import { recordPid } from './record-pid';
+
+recordPid('f2');
+
+describe('soft mode reuse — file 2', () => {
+ it('runs and records its pid', () => {
+ expect(true).toBe(true);
+ });
+});
diff --git a/e2e/soft-mode-reuse/fixtures/test/f3.test.ts b/e2e/soft-mode-reuse/fixtures/test/f3.test.ts
new file mode 100644
index 000000000..e7bbe9997
--- /dev/null
+++ b/e2e/soft-mode-reuse/fixtures/test/f3.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from '@rstest/core';
+import { recordPid } from './record-pid';
+
+recordPid('f3');
+
+describe('soft mode reuse — file 3', () => {
+ it('runs and records its pid', () => {
+ expect(true).toBe(true);
+ });
+});
diff --git a/e2e/soft-mode-reuse/fixtures/test/f4.test.ts b/e2e/soft-mode-reuse/fixtures/test/f4.test.ts
new file mode 100644
index 000000000..f43c6ebeb
--- /dev/null
+++ b/e2e/soft-mode-reuse/fixtures/test/f4.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from '@rstest/core';
+import { recordPid } from './record-pid';
+
+recordPid('f4');
+
+describe('soft mode reuse — file 4', () => {
+ it('runs and records its pid', () => {
+ expect(true).toBe(true);
+ });
+});
diff --git a/e2e/soft-mode-reuse/fixtures/test/record-pid.ts b/e2e/soft-mode-reuse/fixtures/test/record-pid.ts
new file mode 100644
index 000000000..989fa2892
--- /dev/null
+++ b/e2e/soft-mode-reuse/fixtures/test/record-pid.ts
@@ -0,0 +1,20 @@
+import { appendFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+
+/**
+ * Record this file's process.pid + the file's name into a shared log
+ * file. The driver test reads the log after the fixture finishes and
+ * asserts that fewer unique pids exist than files — proving the worker
+ * pool reused workers across files.
+ *
+ * Cannot use `globalThis` because each worker process has its own
+ * global, so a counter there is per-worker not workspace-wide.
+ */
+export const PID_LOG_PATH =
+ process.env.RSTEST_SOFT_REUSE_LOG ??
+ join(tmpdir(), 'rstest-soft-mode-reuse.log');
+
+export const recordPid = (fileTag: string): void => {
+ appendFileSync(PID_LOG_PATH, `${process.pid}\t${fileTag}\n`);
+};
diff --git a/e2e/soft-mode-reuse/index.test.ts b/e2e/soft-mode-reuse/index.test.ts
new file mode 100644
index 000000000..114d161e6
--- /dev/null
+++ b/e2e/soft-mode-reuse/index.test.ts
@@ -0,0 +1,54 @@
+import { existsSync, readFileSync, unlinkSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from '@rstest/core';
+import { runRstestCli } from '../scripts/';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+describe('experiments.softMode — multi-worker reuse', () => {
+ it('reuses workers across files when files > workers', async ({
+ onTestFinished,
+ }) => {
+ const logPath = join(
+ tmpdir(),
+ `rstest-soft-mode-reuse-${process.pid}-${Date.now()}.log`,
+ );
+ onTestFinished(() => {
+ if (existsSync(logPath)) unlinkSync(logPath);
+ });
+
+ const { expectExecSuccess } = await runRstestCli({
+ command: 'rstest',
+ args: ['run'],
+ onTestFinished,
+ options: {
+ nodeOptions: {
+ cwd: join(__dirname, './fixtures'),
+ env: {
+ RSTEST_SOFT_REUSE_LOG: logPath,
+ },
+ },
+ },
+ });
+ await expectExecSuccess();
+
+ // 4 fixture files × pool.maxWorkers=2 → by pigeon-hole, at least
+ // one pid must appear on ≥2 lines. If softMode silently fell back
+ // to `isolate: true`, every file would have its own pid and the
+ // unique-pid set would have 4 elements — the assertion below
+ // catches that regression.
+ const lines = readFileSync(logPath, 'utf8')
+ .split('\n')
+ .filter((l) => l.trim().length > 0);
+ expect(lines.length).toBe(4);
+
+ const pids = new Set(lines.map((line) => line.split('\t')[0]));
+ expect(pids.size).toBeLessThan(4);
+ // And at least one worker really was reused (not just one worker
+ // for all 4 — that would be a single-worker fallback regression).
+ expect(pids.size).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/e2e/soft-mode/fixtures/rstest.config.mts b/e2e/soft-mode/fixtures/rstest.config.mts
new file mode 100644
index 000000000..6a92a3f94
--- /dev/null
+++ b/e2e/soft-mode/fixtures/rstest.config.mts
@@ -0,0 +1,15 @@
+import { defineConfig } from '@rstest/core';
+
+export default defineConfig({
+ experiments: {
+ softMode: true,
+ },
+ testEnvironment: 'jsdom',
+ // Force a single worker so file-a runs before file-b deterministically.
+ // The fixture asserts cross-file state-reset semantics that only
+ // exercise the soft-mode code path when both files share one worker.
+ pool: {
+ maxWorkers: 1,
+ },
+ setupFiles: ['./test/setup-spy.ts'],
+});
diff --git a/e2e/soft-mode/fixtures/test/a-init.test.ts b/e2e/soft-mode/fixtures/test/a-init.test.ts
new file mode 100644
index 000000000..868a1a56a
--- /dev/null
+++ b/e2e/soft-mode/fixtures/test/a-init.test.ts
@@ -0,0 +1,53 @@
+/**
+ * @rstest-environment jsdom
+ *
+ * First file in a soft-mode worker. Establishes state (mutates DOM, sets
+ * storage, installs fake timers, exercises the focus patch). The next
+ * files assert all of this is reset between them.
+ */
+import { describe, expect, it, rstest } from '@rstest/core';
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __soft_worker_pid__: number | undefined;
+ // eslint-disable-next-line no-var
+ var __soft_file_seq__: string[] | undefined;
+}
+
+// Capture the worker's pid; later files assert the same pid (worker reuse).
+globalThis.__soft_worker_pid__ ??= process.pid;
+globalThis.__soft_file_seq__ ??= [];
+globalThis.__soft_file_seq__.push('file-a');
+
+describe('soft mode — file A (state setter)', () => {
+ it('owns a fresh DOM body', () => {
+ expect(document.body.innerHTML).toBe('');
+ document.body.innerHTML = 'x
';
+ expect(document.querySelector('#from-file-a')).not.toBeNull();
+ });
+
+ it('owns fresh storage', () => {
+ expect(localStorage.length).toBe(0);
+ localStorage.setItem('from-file-a', 'leaked-if-soft-reset-broken');
+ sessionStorage.setItem('from-file-a-s', 'leaked-if-soft-reset-broken');
+ });
+
+ it('installs fake timers without throwing', () => {
+ rstest.useFakeTimers();
+ rstest.setSystemTime(new Date('2020-01-01T00:00:00Z'));
+ expect(new Date().toISOString()).toBe('2020-01-01T00:00:00.000Z');
+ // Deliberately DO NOT call useRealTimers() — soft mode must restore
+ // real timers between files, otherwise a downstream file's
+ // useFakeTimers() throws "Can't install fake timers twice on the
+ // same global object".
+ });
+
+ it('exercises HTMLElement.prototype.focus patch', () => {
+ const el = document.createElement('button');
+ document.body.appendChild(el);
+ el.focus();
+ expect(
+ (globalThis as { __soft_focus_calls__?: number }).__soft_focus_calls__,
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/e2e/soft-mode/fixtures/test/b-leak.test.ts b/e2e/soft-mode/fixtures/test/b-leak.test.ts
new file mode 100644
index 000000000..ae6e45ad6
--- /dev/null
+++ b/e2e/soft-mode/fixtures/test/b-leak.test.ts
@@ -0,0 +1,45 @@
+/**
+ * @rstest-environment jsdom
+ *
+ * Deliberately leaks unawaited async work so the next file's
+ * `preparePool` exercises the `drainPendingAsyncFromPriorFile` absorber.
+ *
+ * If the absorber regresses (removed, drain count too low, listener
+ * install/remove order wrong), this file's leak surfaces as an
+ * `unhandledRejection` attributed to file-b or file-c → suite fails.
+ * With the absorber working, the leak is silently absorbed and
+ * subsequent files run cleanly.
+ */
+import { describe, expect, it } from '@rstest/core';
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __soft_file_seq__: string[] | undefined;
+}
+
+globalThis.__soft_file_seq__ ??= [];
+globalThis.__soft_file_seq__.push('file-leaker');
+
+describe('soft mode — leaker (exercises the absorber)', () => {
+ it('finishes without awaiting a deferred rejection', () => {
+ // Schedule a rejection that will fire AFTER this test's microtask
+ // queue has drained — i.e. after the file's slot has closed. The
+ // worker survives because soft mode drains + absorbs before
+ // installing the next file's per-file `unhandledRejection` handler.
+ setTimeout(() => {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ Promise.reject(
+ new Error('intentional leak — should be absorbed by drain'),
+ );
+ }, 0);
+
+ // Also schedule a deferred uncaughtException-style throw. Same idea:
+ // it must not surface in the next file's slot.
+ setTimeout(() => {
+ throw new Error('intentional leak — should be absorbed by drain');
+ }, 0);
+
+ // Assert something trivial so this counts as a passing test.
+ expect(true).toBe(true);
+ });
+});
diff --git a/e2e/soft-mode/fixtures/test/c-verify.test.ts b/e2e/soft-mode/fixtures/test/c-verify.test.ts
new file mode 100644
index 000000000..14499e89c
--- /dev/null
+++ b/e2e/soft-mode/fixtures/test/c-verify.test.ts
@@ -0,0 +1,97 @@
+/**
+ * @rstest-environment jsdom
+ *
+ * Runs after `a-init` and `b-leak` in the same worker. Asserts every
+ * kind of state soft mode is supposed to reset.
+ */
+import { describe, expect, it, rstest } from '@rstest/core';
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __soft_worker_pid__: number | undefined;
+ // eslint-disable-next-line no-var
+ var __soft_file_seq__: string[] | undefined;
+}
+
+globalThis.__soft_file_seq__ ??= [];
+globalThis.__soft_file_seq__.push('file-b');
+
+describe('soft mode — file B (state asserter)', () => {
+ it('runs in the same worker as a-init and b-leak', () => {
+ expect(globalThis.__soft_worker_pid__).toBe(process.pid);
+ // Each file pushed its tag; exact sequence pins file ordering AND
+ // proves the global survived softReset (which only clears env-side
+ // state, not arbitrary globals). `file-leaker` being present here
+ // also proves the leaker file completed successfully — its deferred
+ // unhandled rejection + throw were absorbed by
+ // `drainPendingAsyncFromPriorFile` before this file's `preparePool`
+ // installed per-file handlers. If the absorber regresses, those
+ // leaks would bubble into this file's slot and fail the suite.
+ expect(globalThis.__soft_file_seq__).toEqual([
+ 'file-a',
+ 'file-leaker',
+ 'file-b',
+ ]);
+ });
+
+ it('starts with a clean DOM body (file-a leaks reset)', () => {
+ expect(document.body.innerHTML).toBe('');
+ expect(document.querySelector('#from-file-a')).toBeNull();
+ });
+
+ it('starts with clean storage', () => {
+ expect(localStorage.length).toBe(0);
+ expect(sessionStorage.length).toBe(0);
+ expect(localStorage.getItem('from-file-a')).toBeNull();
+ expect(sessionStorage.getItem('from-file-a-s')).toBeNull();
+ });
+
+ it('useFakeTimers() does NOT throw after file-a installed them', () => {
+ // file-a installed fake timers and intentionally never uninstalled.
+ // Soft mode's between-file teardown should have called useRealTimers()
+ // via the captured api. If not, sinon's `install()` throws
+ // "Can't install fake timers twice on the same global object".
+ expect(() => {
+ rstest.useFakeTimers();
+ rstest.setSystemTime(new Date('2030-06-15T00:00:00Z'));
+ }).not.toThrow();
+ expect(new Date().toISOString()).toBe('2030-06-15T00:00:00.000Z');
+ rstest.useRealTimers();
+ });
+
+ it('HTMLElement.prototype.focus was restored to value-descriptor between files', () => {
+ // setup-spy.ts snapshots the descriptor shape on every module
+ // re-evaluation BEFORE re-patching. With soft mode working, every
+ // file's setupFile sees a freshly-restored `value` descriptor (the
+ // prior file's getter-only patch was wiped by softResetEnv's
+ // protoSnapshot restore). Without soft mode, file 2+ would see
+ // `'get-only'` — the prior file's patch lingers.
+ //
+ // 3 files in this fixture (a-init, b-leak, c-verify) → 3 captures,
+ // all should be `'value'`.
+ const history = (
+ globalThis as { __soft_focus_descriptor_history__?: Array }
+ ).__soft_focus_descriptor_history__;
+ expect(history).toBeDefined();
+ expect(history).toEqual(['value', 'value', 'value']);
+ });
+
+ it('Element.prototype.getBoundingClientRect spy is fresh', () => {
+ // setup-spy.ts re-installed the spy for this file. If file-a's spy
+ // wasn't restored across the file boundary, the new spy would wrap
+ // the OLD spy and `.mock.calls` would already be non-empty.
+ const div = document.createElement('div');
+ const mock = (
+ div.getBoundingClientRect as unknown as {
+ mock?: { calls: unknown[] };
+ }
+ ).mock;
+ // Assert it IS a mock — proves setup-spy.ts re-ran for file-b.
+ // Without this assertion the spy-restored check would silently pass
+ // when the spy didn't install at all (regression in setupFile re-eval).
+ expect(mock).toBeDefined();
+ expect(mock!.calls.length).toBe(0);
+ div.getBoundingClientRect();
+ expect(div.getBoundingClientRect().width).toBe(100);
+ });
+});
diff --git a/e2e/soft-mode/fixtures/test/setup-spy.ts b/e2e/soft-mode/fixtures/test/setup-spy.ts
new file mode 100644
index 000000000..26a80891b
--- /dev/null
+++ b/e2e/soft-mode/fixtures/test/setup-spy.ts
@@ -0,0 +1,72 @@
+// Shared setupFile: every test file in this fixture re-evaluates this
+// module (rstest re-runs setupFiles per file). The spy and the
+// HTMLElement.prototype.focus assignment are deliberate cross-file leaks
+// that soft mode must clean up between files.
+import { rstest } from '@rstest/core';
+
+// Spy on Element.prototype.getBoundingClientRect — registered with
+// tinyspy's worker-scope `spies` Set; soft mode's `restoreAll()` between
+// files restores the original method so file N+1's setupFile creates a
+// fresh spy on the original (not a spy-of-a-spy).
+if (typeof Element !== 'undefined') {
+ rstest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(
+ () =>
+ ({
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ top: 0,
+ left: 0,
+ right: 100,
+ bottom: 100,
+ toJSON: () => ({}),
+ }) as DOMRect,
+ );
+}
+
+// Vendor-style monkey-patch on HTMLElement.prototype.focus — mirrors what
+// `@testing-library/user-event`'s `patchFocus` does (replaces the value
+// descriptor with a getter-only one via `Object.defineProperty`). Without
+// soft mode's prototype snapshot+restore, file N+1's vendor code that
+// re-assigns via `prototype.focus = fn` would throw "has only a getter".
+//
+// Before re-patching, capture the current descriptor's *shape* so tests
+// can assert that soft mode reset it back to the original `value+writable`
+// form between files. After re-patching, the descriptor is getter-only
+// again, so we can't observe the reset by inspecting the descriptor at
+// test time — we have to look BEFORE the patch runs.
+if (typeof HTMLElement !== 'undefined') {
+ const preDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ 'focus',
+ );
+ (
+ globalThis as { __soft_focus_descriptor_history__?: Array }
+ ).__soft_focus_descriptor_history__ ??= [];
+ // 'value' = original / restored; 'get-only' = a prior file's patch is
+ // still in place; 'missing' = something destroyed the property.
+ (
+ globalThis as { __soft_focus_descriptor_history__?: Array }
+ ).__soft_focus_descriptor_history__!.push(
+ !preDescriptor
+ ? 'missing'
+ : 'value' in preDescriptor && typeof preDescriptor.value === 'function'
+ ? 'value'
+ : preDescriptor.get && !preDescriptor.set
+ ? 'get-only'
+ : 'other',
+ );
+
+ const originalFocus = HTMLElement.prototype.focus;
+ Object.defineProperty(HTMLElement.prototype, 'focus', {
+ configurable: true,
+ get: () =>
+ function patchedFocus(this: HTMLElement, options?: FocusOptions) {
+ (globalThis as { __soft_focus_calls__?: number }).__soft_focus_calls__ =
+ ((globalThis as { __soft_focus_calls__?: number })
+ .__soft_focus_calls__ ?? 0) + 1;
+ return originalFocus.call(this, options);
+ },
+ });
+}
diff --git a/e2e/soft-mode/index.test.ts b/e2e/soft-mode/index.test.ts
new file mode 100644
index 000000000..4befb0f52
--- /dev/null
+++ b/e2e/soft-mode/index.test.ts
@@ -0,0 +1,33 @@
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, it } from '@rstest/core';
+import { runRstestCli } from '../scripts/';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+describe('experiments.softMode', () => {
+ it('reuses workers across files while resetting per-file env state', async ({
+ onTestFinished,
+ }) => {
+ // The fixture asserts:
+ // - same worker pid for file-a and file-b (worker reuse)
+ // - DOM body, localStorage, sessionStorage cleared between files
+ // - HTMLElement.prototype mutations from file-a are reverted
+ // - `useFakeTimers()` in file-b doesn't throw "twice on the same
+ // global" after file-a installed them and never uninstalled
+ // - tinyspy-registered spies restored between files
+ const { expectExecSuccess } = await runRstestCli({
+ command: 'rstest',
+ args: ['run'],
+ onTestFinished,
+ options: {
+ nodeOptions: {
+ cwd: join(__dirname, './fixtures'),
+ },
+ },
+ });
+
+ await expectExecSuccess();
+ });
+});
diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts
index 5e69665c1..a3907330e 100644
--- a/packages/core/src/config.ts
+++ b/packages/core/src/config.ts
@@ -284,13 +284,44 @@ const createDefaultConfig = (): NormalizedConfig => ({
export const withDefaultConfig = (config: RstestConfig): NormalizedConfig => {
const merged = mergeRstestConfig(
- createDefaultConfig(),
+ // `createDefaultConfig()` returns `NormalizedConfig`, whose internal
+ // `isolate` is wider (`boolean | 'soft'`) than the public
+ // `RstestConfig.isolate`. The cast narrows it for the merge step;
+ // the `as NormalizedConfig` below re-widens to internal.
+ createDefaultConfig() as unknown as RstestConfig,
config,
) as NormalizedConfig;
merged.setupFiles = castArray(merged.setupFiles);
merged.globalSetup = castArray(merged.globalSetup);
+ // Normalize `experiments.softMode` to the internal `isolate: 'soft'`
+ // representation. The internal `IsolateMode` (`pool/types.ts`) keeps the
+ // three-way distinction (`true | false | 'soft'`) so the pool and worker
+ // can encode strict / reuse / reuse-with-reset without adding a fourth
+ // axis. The public surface stays `boolean | undefined` for `isolate` and
+ // gates soft on the experiments flag so the contract is clear: this
+ // mode is experimental and may change shape before stabilising.
+ //
+ // `softMode` accepts `true` or an options object (`{ maxFilesPerWorker }`);
+ // any truthy value enables the mode.
+ if (merged.experiments?.softMode) {
+ // Surface the conflict when a user has BOTH `isolate: false` (explicit
+ // reuse-without-reset) and `experiments.softMode` enabled (reuse-WITH-
+ // reset). softMode wins because it's the strictly-stronger semantic,
+ // but silent override is surprising; one warning per config-load is
+ // cheap and self-documenting.
+ if (merged.isolate === false) {
+ logger.warn(
+ '[rstest] `experiments.softMode` overrides `isolate: false`. ' +
+ 'Soft mode reuses workers AND resets per-file env state; ' +
+ '`isolate: false` is reuse without reset. Remove one to silence ' +
+ 'this warning.',
+ );
+ }
+ (merged as unknown as { isolate: 'soft' }).isolate = 'soft';
+ }
+
const outputDistPathRoot = getOutputDistPathRoot(merged.output?.distPath);
merged.output.distPath = {
root: formatRootStr(outputDistPathRoot, merged.root),
diff --git a/packages/core/src/pool/index.ts b/packages/core/src/pool/index.ts
index 5d4fc66cc..c8c671250 100644
--- a/packages/core/src/pool/index.ts
+++ b/packages/core/src/pool/index.ts
@@ -338,10 +338,17 @@ export const createPool = async ({
const numCpus = getNumCpus();
const {
- normalizedConfig: { pool: poolOptions, isolate },
+ normalizedConfig: { pool: poolOptions, isolate, experiments },
reporters,
} = context;
+ // `softMode` accepts `true` or `{ maxFilesPerWorker }`. The truthy check
+ // is mirrored in `withDefaultConfig` where the public flag is collapsed
+ // into the internal `isolate: 'soft'`; here we only need the tuning value.
+ const softModeOptions =
+ typeof experiments?.softMode === 'object' ? experiments.softMode : null;
+ const softModeMaxFilesPerWorker = softModeOptions?.maxFilesPerWorker;
+
const workerKind: PoolWorkerKind = poolOptions.type ?? 'forks';
const threadsCount =
@@ -374,6 +381,7 @@ export const createPool = async ({
isolate,
maxWorkers,
minWorkers,
+ softModeMaxFilesPerWorker,
execArgv: [
...(poolOptions?.execArgv ?? []),
...execArgv,
diff --git a/packages/core/src/pool/pool.ts b/packages/core/src/pool/pool.ts
index 731501e88..e7181bf6b 100644
--- a/packages/core/src/pool/pool.ts
+++ b/packages/core/src/pool/pool.ts
@@ -9,8 +9,21 @@ import { createPoolWorker } from './workers';
* - one task per worker at a time (concurrentTasksPerWorker=1)
* - parallel dispatch up to maxWorkers, slot-waiter blocks excess callers
* - isolate=true: fresh runner per task, stopped in the background
- * - isolate=false: idle runners reused, lazy-spawned on demand
+ * - isolate='soft': idle runners reused; worker resets test env per task
+ * - isolate=false: idle runners reused, no per-task reset
*/
+/**
+ * Default cap for tasks handled by a single worker in `experiments.softMode`
+ * (or `isolate: false`). Empirically chosen from a 74-file jsdom-heavy lib
+ * where heap grew from 71MB at file 1 to 4GB at file 65 in a single
+ * worker; recycling at 20 keeps the peak heap under ~1GB and prevents the
+ * 2-3× per-test slowdown that GC pressure inflicted at the top of that
+ * range. Strict isolate runners are single-use so this doesn't apply.
+ *
+ * Override via `experiments.softMode.maxFilesPerWorker`.
+ */
+const DEFAULT_SOFT_MODE_MAX_TASKS = 20;
+
export class Pool {
private readonly options: PoolOptions;
private readonly idleRunners: PoolRunner[] = [];
@@ -65,6 +78,10 @@ export class Pool {
return await runner.collectTests(task);
} finally {
this.options.memoryGate?.recordResolve(heapBaseline);
+ // Increment the runner's task counter BEFORE release so `isUsable()`
+ // can flip false when the cap is reached. `releaseRunner` then
+ // disposes instead of returning to the idle pool.
+ runner.recordTaskCompleted();
this.releaseRunner(runner);
}
}
@@ -113,7 +130,21 @@ export class Pool {
const workerId = this.acquireWorkerId();
const worker = createPoolWorker(task, this.options, workerId);
gate?.attachWorker(worker);
- const runner = new PoolRunner(worker, { workerId });
+ const runner = new PoolRunner(worker, {
+ workerId,
+ // Cap soft-mode reuse — heap accumulates monotonically across
+ // file evaluations (vendor modules + React fiber trees + JSDOM
+ // nodes), and by ~50 files on a jsdom-heavy lib the worker is
+ // GC-thrashing at multi-GB heap, slowing each test 2-3×. Default
+ // applies only to soft mode; strict-isolate runners are
+ // single-use anyway. `isolate: false` (reuse without reset) also
+ // benefits because the same heap pressure applies.
+ maxTasks:
+ this.options.isolate === true
+ ? 0
+ : (this.options.softModeMaxFilesPerWorker ??
+ DEFAULT_SOFT_MODE_MAX_TASKS),
+ });
this.activeRunners.add(runner);
try {
await runner.start();
@@ -175,12 +206,11 @@ export class Pool {
this.activeRunners.delete(runner);
// `isolate: true`, closing, or unusable — never reuse.
- if (
- this.options.isolate !== false ||
- this.isClosing ||
- this.isClosed ||
- !runner.isUsable()
- ) {
+ // `isolate: false` and `isolate: 'soft'` both reuse runners; only the
+ // worker-side per-file reset semantics differ.
+ const canReuse =
+ this.options.isolate === false || this.options.isolate === 'soft';
+ if (!canReuse || this.isClosing || this.isClosed || !runner.isUsable()) {
// Background dispose. The slot stays accounted for in `stoppingRunners`
// until the child actually exits, so `isolate: true` cannot transiently
// exceed `maxWorkers` and `close()` can drain in-flight stops.
diff --git a/packages/core/src/pool/poolRunner.ts b/packages/core/src/pool/poolRunner.ts
index 497ab8c33..92f8fe665 100644
--- a/packages/core/src/pool/poolRunner.ts
+++ b/packages/core/src/pool/poolRunner.ts
@@ -64,6 +64,20 @@ let nextTaskSeq = 0;
type PoolRunnerOptions = {
workerId: number;
+ /**
+ * For `experiments.softMode` (or `isolate: false`) where the worker is
+ * reused across tasks, this caps how many tasks a single worker handles
+ * before `isUsable()` reports false and the pool disposes it for a
+ * fresh one. Unbounded reuse leaks heap (vendor modules, React fiber
+ * trees, JSDOM nodes, accumulated closures) — by file 50+ on a
+ * jsdom-heavy lib we observed 4GB+ heap and 2-3× per-test slowdown
+ * from GC pressure.
+ *
+ * Default `0` = unbounded (legacy behavior). Recommended for soft
+ * mode: 20-30. Strict isolate (`isolate: true`) never reuses runners
+ * so this cap is irrelevant there.
+ */
+ maxTasks?: number;
};
/**
@@ -99,8 +113,18 @@ export class PoolRunner {
*/
private crashed = false;
+ /**
+ * Tasks completed on this runner. When `maxTasks > 0`, `isUsable()`
+ * starts returning false once the count reaches the cap, prompting the
+ * pool to dispose this runner and spawn a fresh one. See
+ * `PoolRunnerOptions.maxTasks` for rationale.
+ */
+ private tasksRun = 0;
+ private readonly maxTasks: number;
+
constructor(worker: PoolWorker, options: PoolRunnerOptions) {
this.workerId = options.workerId;
+ this.maxTasks = Math.max(0, options.maxTasks ?? 0);
this.worker = worker;
this.handleMessage = this.handleMessage.bind(this);
@@ -113,7 +137,19 @@ export class PoolRunner {
}
isUsable(): boolean {
- return this.state === 'STARTED' && !this.crashed;
+ if (this.state !== 'STARTED' || this.crashed) return false;
+ if (this.maxTasks > 0 && this.tasksRun >= this.maxTasks) return false;
+ return true;
+ }
+
+ /**
+ * Called by the pool after each completed task. Drives the soft-mode
+ * heap-pressure relief: once `tasksRun >= maxTasks`, `isUsable()` flips
+ * to false and the pool's `releaseRunner` path disposes us instead of
+ * returning us to the idle pool.
+ */
+ recordTaskCompleted(): void {
+ this.tasksRun++;
}
start(): Promise {
diff --git a/packages/core/src/pool/types.ts b/packages/core/src/pool/types.ts
index 1f0fde24c..2084b152c 100644
--- a/packages/core/src/pool/types.ts
+++ b/packages/core/src/pool/types.ts
@@ -10,11 +10,26 @@ export type PoolTask = {
rpcMethods: RuntimeRPC;
};
+/**
+ * Isolation strategy for the pool. See `RstestConfig.isolate` for full docs.
+ * - `true`: fresh runner per task (process-per-file isolation)
+ * - `'soft'`: reuse runner across tasks; worker resets test env per task
+ * - `false`: reuse runner across tasks with no per-task reset
+ */
+export type IsolateMode = boolean | 'soft';
+
export type PoolOptions = {
workerEntry: string;
maxWorkers: number;
minWorkers: number;
- isolate: boolean;
+ isolate: IsolateMode;
+ /**
+ * Cap on tasks dispatched to a single runner before it is disposed and
+ * a fresh one spawns. Only consulted when `isolate === 'soft'` (or
+ * `false`, where reuse also accrues heap). `0` or omitted means
+ * unbounded reuse.
+ */
+ softModeMaxFilesPerWorker?: number;
env?: Record;
execArgv?: string[];
/**
diff --git a/packages/core/src/runtime/worker/runInPool.ts b/packages/core/src/runtime/worker/runInPool.ts
index f6452a519..01a084bfd 100644
--- a/packages/core/src/runtime/worker/runInPool.ts
+++ b/packages/core/src/runtime/worker/runInPool.ts
@@ -1,3 +1,4 @@
+import { appendFileSync } from 'node:fs';
import type { FileCoverageData } from 'istanbul-lib-coverage';
import { isMainThread, threadId } from 'node:worker_threads';
import { install } from 'source-map-support';
@@ -58,6 +59,305 @@ const registerGlobalApi = (api: Rstest) => {
const globalCleanups: (() => void)[] = [];
let isTeardown = false;
+/**
+ * Worker-scope test-environment cache. In `isolate: 'soft' | false` the
+ * worker is reused across files, so paying the jsdom/happyDom setup cost
+ * per file is wasteful — and tearing the env down between files races with
+ * any async work the previous file scheduled (e.g. React commit phase
+ * accessing `window` after `dom.window.close()` already ran).
+ *
+ * The cached entry is reset between files via `softResetEnv` (clear DOM,
+ * reset URL) so leaked DOM state from file N doesn't contaminate file N+1.
+ *
+ * `protoSnapshot` captures the original property descriptors of well-known
+ * DOM prototypes so we can revert in-place mutations vendor packages make
+ * during a file run (e.g. `@testing-library/user-event`'s `patchFocus`
+ * which replaces `HTMLElement.prototype.focus` with a getter-only
+ * descriptor; the next file's bundle re-evaluation does
+ * `prototype.focus = newFn` and dies on "has only a getter").
+ */
+let cachedEnv:
+ | {
+ name: string;
+ teardown: (global: any) => MaybePromise;
+ protoSnapshot: ProtoEntry[];
+ }
+ | undefined;
+
+// In soft mode the per-file `cleanupFns` does NOT include the env teardown
+// (the env persists across files). Without this hook, a worker that exits
+// cleanly (no fatal error, pool drained) leaks the JSDOM window — virtual
+// console handlers, scheduled timers, the cookie jar, the global Proxy.
+// On `beforeExit` we still have a chance to flush; on `exit` (synchronous)
+// we just attempt the call and ignore.
+//
+// `process.exit` short-circuits `beforeExit`, but rstest's pool sends
+// graceful shutdown messages and the worker returns from the message loop,
+// so beforeExit fires in the common case.
+const teardownCachedEnvOnExit = (): void => {
+ if (!cachedEnv) return;
+ try {
+ // Fire-and-forget; the event loop is already winding down so awaiting
+ // a promise here is best-effort. JSDOM's teardown is synchronous as of
+ // jsdom 26.x — this stays meaningful even without await.
+ void cachedEnv.teardown(global);
+ } catch {
+ // best-effort
+ } finally {
+ cachedEnv = undefined;
+ }
+};
+process.on('beforeExit', teardownCachedEnvOnExit);
+process.on('exit', teardownCachedEnvOnExit);
+
+/**
+ * Snapshot every own-property descriptor on the well-known DOM prototypes
+ * the worker exposes via globals. This is the "all-keys" form: we capture
+ * each descriptor at env-setup time and re-apply it between files when its
+ * shape has drifted (e.g. `@testing-library/user-event`'s `patchFocus`
+ * replaces `HTMLElement.prototype.focus` with a getter-only descriptor on
+ * the first `userEvent.click()`; without restore, file N+1's vendor code
+ * that re-assigns via `prototype.focus = fn` throws "has only a getter").
+ *
+ * Why "all keys" and not a curated allow-list: vendor monkey-patching
+ * targets are open-ended (focus, blur, addEventListener, dispatchEvent,
+ * scrollIntoView, getBoundingClientRect, animate, matches, closest, …).
+ * A curated list misses one and you get confusing failures only on
+ * specific libs. The cost is small — ~3 prototypes × ~50-100 own keys
+ * each, descriptor lookups are O(1).
+ *
+ * `constructor` is excluded: rewriting it can break `instanceof` checks
+ * in any code that has a stale constructor reference.
+ */
+type ProtoEntry = {
+ proto: object;
+ descriptors: Record;
+};
+
+const SKIP_PROTO_KEYS = new Set(['constructor']);
+
+const captureProtoDescriptors = (
+ proto: object,
+): Record => {
+ const descriptors: Record = {};
+ const keys: PropertyKey[] = [
+ ...Object.getOwnPropertyNames(proto),
+ ...Object.getOwnPropertySymbols(proto),
+ ];
+ for (const key of keys) {
+ if (SKIP_PROTO_KEYS.has(key)) continue;
+ const d = Object.getOwnPropertyDescriptor(proto, key);
+ if (d) descriptors[key as string] = d;
+ }
+ return descriptors;
+};
+
+const captureProtoSnapshot = (win: any): ProtoEntry[] => {
+ const protos = [
+ win.HTMLElement?.prototype,
+ win.Element?.prototype,
+ win.Node?.prototype,
+ ].filter(Boolean) as object[];
+ return protos.map((proto) => ({
+ proto,
+ descriptors: captureProtoDescriptors(proto),
+ }));
+};
+
+const descriptorEquals = (
+ a: PropertyDescriptor,
+ b: PropertyDescriptor,
+): boolean =>
+ a.value === b.value &&
+ a.get === b.get &&
+ a.set === b.set &&
+ a.writable === b.writable &&
+ a.enumerable === b.enumerable &&
+ a.configurable === b.configurable;
+
+const restoreProtoSnapshot = (snapshot: ProtoEntry[]): void => {
+ for (const { proto, descriptors } of snapshot) {
+ const snapshotKeys: PropertyKey[] = [
+ ...Object.getOwnPropertyNames(descriptors),
+ ...Object.getOwnPropertySymbols(descriptors),
+ ];
+
+ // Step 1: restore mutated descriptors to their original shape.
+ for (const key of snapshotKeys) {
+ const original = (descriptors as any)[key] as PropertyDescriptor;
+ const current = Object.getOwnPropertyDescriptor(proto, key);
+ if (current && descriptorEquals(current, original)) continue;
+ try {
+ Object.defineProperty(proto, key, original);
+ } catch {
+ // Property is non-configurable (some Web IDL bindings) and was
+ // mutated in-place — we can't undo. Falls through; the caller
+ // accepts that some leaks may persist.
+ }
+ }
+
+ // Step 2: drop own-keys that didn't exist at snapshot time. A prior
+ // file could have added a brand-new method to the prototype (e.g.
+ // `HTMLElement.prototype.myCustomHelper = fn`); without symmetric
+ // removal it would persist into the next file. Symbols are included
+ // — vendor packages occasionally stash markers behind Symbols (e.g.
+ // user-event's `Symbol('patched...')` marker) that we want to clear.
+ const snapshotKeySet = new Set(snapshotKeys);
+ const currentKeys: PropertyKey[] = [
+ ...Object.getOwnPropertyNames(proto),
+ ...Object.getOwnPropertySymbols(proto),
+ ];
+ for (const key of currentKeys) {
+ if (SKIP_PROTO_KEYS.has(key)) continue;
+ if (snapshotKeySet.has(key)) continue;
+ try {
+ delete (proto as any)[key];
+ } catch {
+ // Same reason as above — non-configurable. Best-effort.
+ }
+ }
+ }
+};
+
+/**
+ * Per-step reset wrapper. Each step is independent — if one throws the
+ * others should still run. We log to stderr in `DEBUG=rstest:soft-mode`
+ * so silent failures can surface without forcing every consumer to opt
+ * into noisy logs.
+ *
+ * Returning `void` keeps the call sites readable; the caller doesn't
+ * need to know which step failed, just that the env wound up as clean
+ * as best-effort can make it.
+ */
+const softResetStep = (label: string, fn: () => void): void => {
+ try {
+ fn();
+ } catch (e) {
+ if (process.env.DEBUG?.includes('rstest:soft-mode')) {
+ const msg = e instanceof Error ? e.message : String(e);
+ process.stderr.write(
+ `[rstest:soft-mode] reset step "${label}" failed: ${msg}\n`,
+ );
+ }
+ }
+};
+
+/**
+ * Drain pending microtasks and a few macrotask cycles, absorbing any
+ * `unhandledRejection` / `uncaughtException` that fires during the drain.
+ *
+ * Why: under `experiments.softMode` (or `isolate: false`), a worker
+ * survives across files. If the previous file's tests started an XHR
+ * (e.g. via a `useEffect` that wasn't awaited in the test body), the
+ * XHR is still in flight when the file ends. Once the previous file's
+ * mock-server handlers have been reset and the file slot has closed, the
+ * XHR resolves — its `onUnhandledRequest` error bubbles up as an
+ * unhandled rejection and lands in the NEXT file's slot, wrongly
+ * attributing the failure.
+ *
+ * In `isolate: true`, the worker process dies between files, so the
+ * pending XHR dies with it. In Jest, the per-file `vm.Context` is torn
+ * down — same effect. Worker-reuse modes don't get that for free, so
+ * we recreate the moral equivalent: give pending async ~5 macrotask
+ * cycles to finish and absorb any errors that surface during the drain.
+ *
+ * Errors absorbed here are NOT attributable to the next file in any
+ * useful way — they originate in the previous file and only manifest
+ * now. Surfacing them as the next file's failure is worse than dropping
+ * them (the previous file already passed its assertions; if it had a
+ * latent leak, that's a test-code hygiene issue users should address
+ * separately). Set `DEBUG=rstest:soft-mode` to log the absorbed count.
+ */
+const drainPendingAsyncFromPriorFile = async (): Promise => {
+ const absorbed: unknown[] = [];
+ const swallow = (e: unknown) => {
+ absorbed.push(e);
+ };
+ process.on('unhandledRejection', swallow);
+ process.on('uncaughtException', swallow);
+ try {
+ // Each iteration awaits one `setImmediate` cycle: that drains all
+ // currently-queued microtasks (Promise jobs run before the next macro)
+ // plus one macrotask batch. 5 cycles is enough to settle typical
+ // fetch → response → React commit chains; more is wasted budget.
+ for (let i = 0; i < 5; i++) {
+ await new Promise((resolve) =>
+ getRealTimers().setImmediate
+ ? getRealTimers().setImmediate!(resolve)
+ : getRealTimers().setTimeout!(resolve, 0),
+ );
+ }
+ } finally {
+ process.removeListener('unhandledRejection', swallow);
+ process.removeListener('uncaughtException', swallow);
+ }
+ if (absorbed.length > 0 && process.env.DEBUG?.includes('rstest:soft-mode')) {
+ process.stderr.write(
+ `[rstest:soft-mode] absorbed ${absorbed.length} async error(s) from prior file\n`,
+ );
+ }
+};
+
+const softResetEnv = (envName: string, protoSnapshot?: ProtoEntry[]): void => {
+ if (envName !== 'jsdom' && envName !== 'happy-dom') return;
+ const g = global as unknown as {
+ document?: {
+ body?: { innerHTML: string };
+ head?: { innerHTML: string };
+ cookie?: string;
+ };
+ window?: {
+ history?: { replaceState?: Function };
+ scrollTo?: Function;
+ localStorage?: { clear?: () => void };
+ sessionStorage?: { clear?: () => void };
+ };
+ };
+
+ softResetStep('body.innerHTML', () => {
+ if (g.document?.body) g.document.body.innerHTML = '';
+ });
+ softResetStep('head.innerHTML', () => {
+ if (g.document?.head) g.document.head.innerHTML = '';
+ });
+ softResetStep('history.replaceState', () => {
+ g.window?.history?.replaceState?.(null, '', '/');
+ });
+ softResetStep('scrollTo', () => {
+ g.window?.scrollTo?.(0, 0);
+ });
+ softResetStep('localStorage.clear', () => {
+ g.window?.localStorage?.clear?.();
+ // Also clear via globalThis in case test code references the global
+ // shortcut rather than `window.localStorage` (some helpers do).
+ (globalThis as any).localStorage?.clear?.();
+ });
+ softResetStep('sessionStorage.clear', () => {
+ g.window?.sessionStorage?.clear?.();
+ (globalThis as any).sessionStorage?.clear?.();
+ });
+ softResetStep('cookies', () => {
+ // Setting `cookie` to an expired version of each existing pair drops
+ // it. Note: cookies set with a non-root `path` won't be wiped by this
+ // — tests that set such cookies need to clear them in afterEach.
+ if (g.document && typeof g.document.cookie === 'string') {
+ const cookies = g.document.cookie.split(';');
+ for (const c of cookies) {
+ const eqIx = c.indexOf('=');
+ const name = (eqIx > -1 ? c.slice(0, eqIx) : c).trim();
+ if (name) {
+ g.document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
+ }
+ }
+ }
+ });
+ if (protoSnapshot) {
+ softResetStep('protoSnapshot.restore', () => {
+ restoreProtoSnapshot(protoSnapshot);
+ });
+ }
+};
+
const setErrorName = (error: Error, type: string): Error => {
try {
error.name = type;
@@ -128,6 +428,38 @@ const preparePool = async (
});
globalCleanups.length = 0;
+ // If a cachedEnv exists, the worker is being reused for another file.
+ // Drain any pending async work scheduled by the previous file BEFORE we
+ // install this file's `unhandledRejection` listener — otherwise leftover
+ // XHRs / promise chains from the previous file would resolve into this
+ // file's slot and surface as misattributed errors. This is the moral
+ // equivalent of the per-file vm.Context teardown Jest gets for free.
+ if (cachedEnv && context.runtimeConfig.isolate !== true) {
+ await drainPendingAsyncFromPriorFile();
+ }
+
+ // DIAGNOSTIC: heap usage at file boundary, gated on env var so it doesn't
+ // pollute normal runs. Tracks across the worker's file sequence so we can
+ // see heap growth + GC behaviour.
+ // Optional diagnostic: heap usage at file boundary. Set
+ // `RSTEST_HEAP_TRACE=1` and read the per-pid log files under /private/tmp
+ // to observe heap growth across a worker's file sequence — useful when
+ // tuning soft-mode worker recycle cap.
+ if (process.env.RSTEST_HEAP_TRACE === '1') {
+ try {
+ const m = process.memoryUsage();
+ const g = globalThis as { __rstest_file_seq__?: number };
+ g.__rstest_file_seq__ = (g.__rstest_file_seq__ ?? 0) + 1;
+ const seq = g.__rstest_file_seq__;
+ appendFileSync(
+ `/private/tmp/rstest-heap-${process.pid}.log`,
+ `${seq}\t${context.runtimeConfig.isolate}\t${(m.heapUsed / 1024 / 1024).toFixed(1)}\t${(m.heapTotal / 1024 / 1024).toFixed(1)}\t${(m.rss / 1024 / 1024).toFixed(1)}\t${(m.external / 1024 / 1024).toFixed(1)}\t${testPath.split('/').slice(-2).join('/')}\n`,
+ );
+ } catch {
+ // best-effort diagnostic; don't fail the file
+ }
+ }
+
const taskContext = createNodeTaskContext();
setRealTimers();
@@ -168,7 +500,13 @@ const preparePool = async (
silent,
},
emitInterceptedLog: async (log) => {
- await rpc.onConsoleLog(log);
+ try {
+ await rpc.onConsoleLog(log);
+ } catch {
+ // RPC may already be closed if a pending async log (e.g. React
+ // commit or microtask) fires after teardown. Drop it; the log is
+ // best-effort and we don't want to spam unhandled rejections.
+ }
},
writeOriginalLog: createOriginalLogWriter(),
});
@@ -231,43 +569,91 @@ const preparePool = async (
const uncaughtException = (e: Error) => handleError(e, 'uncaughtException');
const unhandledRejection = (e: Error) => handleError(e, 'unhandledRejection');
- process.on('uncaughtException', uncaughtException);
- process.on('unhandledRejection', unhandledRejection);
-
+ // Register the cleanup BEFORE the listener install. Without this order
+ // an early throw between `process.on(...)` and the matching
+ // `globalCleanups.push(...)` would leak the listener — across many such
+ // throws in soft mode the worker accumulates duplicate handlers and any
+ // `unhandledRejection` fires N times, attributing the same error to N
+ // earlier file slots.
globalCleanups.push(() => {
process.off('uncaughtException', uncaughtException);
process.off('unhandledRejection', unhandledRejection);
});
+ process.on('uncaughtException', uncaughtException);
+ process.on('unhandledRejection', unhandledRejection);
const { api, runner } = await createRstestRuntime(workerState, {
taskContext,
});
tracker?.transition('envSetup');
- switch (testEnvironment.name) {
- case 'node':
- break;
- case 'jsdom': {
- const { environment } = await import('./env/jsdom');
- const { teardown } = await environment.setup(
- global,
- testEnvironment.options || {},
- );
- cleanupFns.push(() => teardown(global));
- break;
+ const isolateMode = context.runtimeConfig.isolate;
+ const canReuseEnv =
+ isolateMode !== true &&
+ cachedEnv !== undefined &&
+ cachedEnv.name === testEnvironment.name;
+
+ if (canReuseEnv) {
+ // Worker is being reused for another file; soft-reset the env in place
+ // (clear DOM + restore mutated DOM prototype descriptors) instead of
+ // paying the full setup cost again. Pending-async drain already ran
+ // at the top of preparePool (before per-file listeners installed).
+ softResetEnv(cachedEnv!.name, cachedEnv!.protoSnapshot);
+ } else {
+ // Tear down any prior env of the wrong type before installing a fresh one.
+ if (cachedEnv) {
+ try {
+ await cachedEnv.teardown(global);
+ } catch {
+ // ignore — installing the new env will overwrite globals anyway.
+ }
+ cachedEnv = undefined;
}
- case 'happy-dom': {
- const { environment } = await import('./env/happyDom');
- const { teardown } = await environment.setup(
- global,
- testEnvironment.options || {},
- );
- cleanupFns.push(async () => teardown(global));
- break;
+ switch (testEnvironment.name) {
+ case 'node':
+ break;
+ case 'jsdom': {
+ const { environment } = await import('./env/jsdom');
+ const { teardown } = await environment.setup(
+ global,
+ testEnvironment.options || {},
+ );
+ cachedEnv = {
+ name: 'jsdom',
+ teardown,
+ protoSnapshot: captureProtoSnapshot(global as any),
+ };
+ break;
+ }
+ case 'happy-dom': {
+ const { environment } = await import('./env/happyDom');
+ const { teardown } = await environment.setup(
+ global,
+ testEnvironment.options || {},
+ );
+ cachedEnv = {
+ name: 'happy-dom',
+ teardown,
+ protoSnapshot: captureProtoSnapshot(global as any),
+ };
+ break;
+ }
+ default:
+ throw new Error(`Unknown test environment: ${testEnvironment.name}`);
}
- default:
- throw new Error(`Unknown test environment: ${testEnvironment.name}`);
}
+
+ // In strict isolation, the env is torn down after the file via `cleanupFns`.
+ // In soft mode, the cached env persists; teardown only fires when the
+ // worker exits (handled by the pool, not this function).
+ if (isolateMode === true && cachedEnv) {
+ const env = cachedEnv;
+ cleanupFns.push(async () => {
+ await env.teardown(global);
+ cachedEnv = undefined;
+ });
+ }
+
tracker?.transition('prepare');
if (globals) {
@@ -317,7 +703,7 @@ const loadFiles = async ({
runtimeDistPath?: string;
testPath: string;
interopDefault: boolean;
- isolate: boolean;
+ isolate: boolean | 'soft';
outputModule: boolean;
tracker?: PhaseTracker;
}): Promise => {
@@ -326,7 +712,10 @@ const loadFiles = async ({
: await import('./loadModule');
// clean rstest core cache manually
- if (!isolate) {
+ // Runs for any non-strict isolate (`false` or `'soft'`): the worker
+ // process is reused across files, so rstest's internal state needs to
+ // start clean for each new file.
+ if (isolate !== true) {
await loadModule({
codeContent: `if (global && typeof global.__rstest_clean_core_cache__ === 'function') {
global.__rstest_clean_core_cache__();
@@ -410,13 +799,50 @@ export const runInPool = async (
process.exit = exit;
});
+ // Captured by preparePool — used by teardown to perform per-file resets
+ // when the worker is reused (`isolate !== true`).
+ let perFileApi: Rstest | undefined;
+
const teardown = async () => {
await new Promise((resolve) => getRealTimers().setTimeout!(resolve));
+ // Soft/non-strict isolate: process is reused for the next file, so we
+ // must reset per-file global state that would otherwise leak.
+ //
+ // Per-api rstest.restoreAllMocks() only reaches spies registered to the
+ // CURRENT file's `mocks` Set — spies from prior files are orphaned when
+ // their api is GC'd, leaving the property descriptors patched. Use
+ // tinyspy's worker-scope `restoreAll()` instead: it walks the same
+ // module-level `spies` Set that `internalSpyOn` registers into, so it
+ // restores every spy in the worker regardless of which api created it.
+ //
+ // The fake-timers reset is also critical: sinon's `install()` rejects a
+ // second install on the same global, so the next file's
+ // `useFakeTimers()` would throw "Can't install fake timers twice on the
+ // same global object" without this.
+ if (isolate !== true) {
+ if (perFileApi) {
+ try {
+ if (perFileApi.rstest.isFakeTimers()) {
+ perFileApi.rstest.useRealTimers();
+ }
+ } catch {
+ // api may already be in a torn-down state; the next file's
+ // preparePool will create a fresh one.
+ }
+ }
+ try {
+ const { restoreAll } = await import('tinyspy');
+ restoreAll();
+ } catch {
+ // tinyspy not available or registry already empty — nothing to do.
+ }
+ }
+
// Run teardown
await Promise.all(cleanups.map((fn) => fn()));
- if (!isolate) {
+ if (isolate !== true) {
const { clearModuleCache } = options.context.outputModule
? await import('./loadEsModule')
: await import('./loadModule');
@@ -435,7 +861,9 @@ export const runInPool = async (
cleanup,
unhandledErrors,
interopDefault,
+ api,
} = await preparePool(options);
+ perFileApi = api;
const { assetFiles, sourceMaps: sourceMapsFromAssets } =
assets || (await rpc.getAssetsByEntry());
sourceMaps = sourceMapsFromAssets;
@@ -500,6 +928,7 @@ export const runInPool = async (
interopDefault,
taskContext: preparedTaskContext,
} = await preparePool(options, tracker);
+ perFileApi = api;
taskContext = preparedTaskContext;
if (detectAsyncLeaks) {
asyncLeakDetector = createAsyncLeakDetector(taskContext);
diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts
index deb3a7a39..bf4bb5711 100644
--- a/packages/core/src/types/config.ts
+++ b/packages/core/src/types/config.ts
@@ -242,6 +242,26 @@ export type EnvironmentWithOptions = {
options?: Record;
};
+/**
+ * Tuning knobs for `experiments.softMode`. Reserved for soft-mode-specific
+ * concerns; keep general pool options on `RstestPoolOptions`.
+ */
+export interface SoftModeOptions {
+ /**
+ * Cap on the number of test files a single worker handles before it is
+ * disposed and a fresh one spawns. Bounds the heap growth that accumulates
+ * from per-file module state (vendor caches, React fiber trees, JSDOM
+ * nodes) across a reused worker.
+ *
+ * Set higher for libs with light tests, lower for heavy React/JSDOM
+ * workloads where heap pressure dominates wall time. Has no effect when
+ * `softMode` is disabled.
+ *
+ * @default 20
+ */
+ maxFilesPerWorker?: number;
+}
+
export interface RstestConfig {
/**
* Extend configuration from adapters
@@ -326,11 +346,68 @@ export interface RstestConfig {
*/
pool?: RstestPoolType | RstestPoolOptions;
/**
- * Run tests in an isolated environment
+ * Isolation strategy between test files.
+ *
+ * - `true` (default): each test file runs in a fresh worker process. Strongest
+ * isolation but pays the cost of process fork + bundle load + env setup
+ * on every file.
+ * - `false`: workers persist with no per-file env reset. Fastest but
+ * leaks every kind of state between files.
+ *
+ * For an in-between option that reuses workers but resets per-file env
+ * state (DOM, timers, spies, storage, prototype mutations), opt into
+ * the experimental `experiments.softMode` flag instead — most tests are
+ * idempotent enough to benefit without the per-file fork cost.
*
* @default true
*/
isolate?: boolean;
+ /**
+ * Experimental features. Shape may change before stabilising; opt in
+ * deliberately and pin your `@rstest/core` version if you depend on
+ * the exact semantics.
+ */
+ experiments?: {
+ /**
+ * Reuse worker processes across test files while still resetting
+ * per-file env state between them.
+ *
+ * What's reset between files:
+ * - DOM contents (`document.body/head` innerHTML, URL, scroll)
+ * - Web storage (`localStorage`, `sessionStorage`, document cookies)
+ * - DOM prototype descriptors mutated by vendor packages (e.g.
+ * `HTMLElement.prototype.focus` patched by
+ * `@testing-library/user-event`)
+ * - Sinon fake timers (`useRealTimers()` on the prior file's api)
+ * - Tinyspy spies via the worker-scope `restoreAll()`
+ *
+ * What survives across files (this is the speedup):
+ * - The worker process itself (no fork cost)
+ * - Node's module cache for vendor deps (no `import('jsdom')` cold load)
+ * - JSDOM window instance (no `new JSDOM()` per file)
+ *
+ * Trade-offs vs `isolate: true`:
+ * - Tests that depend on per-file `rstest.mock(...)` module factory
+ * re-application may need to stay on `isolate: true`. The module
+ * mock registry is per-bundle (one bundle per file in rstest), but
+ * tests that interact with module-level singletons inside vendors
+ * (final-form's `keysCache`, msw's interceptor state) can leak.
+ * - Heap grows monotonically across files in a reused worker; the
+ * `maxFilesPerWorker` cap (default 20) recycles workers
+ * periodically to prevent GC pressure. Tune higher for libs with
+ * light tests, lower for jsdom-heavy React workloads.
+ * - Has precedence over `isolate`: if both are set, soft semantics win.
+ *
+ * Pass `true` to enable with defaults, or an object to customise:
+ * ```ts
+ * experiments: { softMode: { maxFilesPerWorker: 10 } }
+ * ```
+ *
+ * @default false
+ * @experimental
+ */
+ softMode?: boolean | SoftModeOptions;
+ };
/**
* Provide global APIs
*
@@ -571,7 +648,8 @@ type OptionalKeys =
| 'hideSkippedTestFiles'
| 'resolveSnapshotPath'
| 'extends'
- | 'shard';
+ | 'shard'
+ | 'experiments';
export type NormalizedBrowserModeConfig = {
enabled: boolean;
@@ -597,6 +675,7 @@ export type NormalizedConfig = Required<
| 'testEnvironment'
| 'browser'
| 'output'
+ | 'isolate'
>
> &
Partial> & {
@@ -611,6 +690,13 @@ export type NormalizedConfig = Required<
override?: boolean;
};
output: NormalizedOutputConfig;
+ /**
+ * Internal three-way isolation mode. The public `RstestConfig.isolate`
+ * is `boolean`; `'soft'` is set internally when
+ * `experiments.softMode === true`. Pool/worker code switches on this
+ * to encode strict / reuse / reuse-with-reset behavior.
+ */
+ isolate: boolean | 'soft';
};
export type NormalizedProjectConfig = Required<