# test\(web\): disposable render helper and lint rule for browser tests · PR \#22187 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/22187)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: merged

Source repository: pingdotgg/t3code

Source branch: test/web-test-audit

Target branch: main

Head commit: 7b47897f81695d01a3108f14face541c07a5c0e6

[Discussion and reviews](https://git.cafe/pingdotgg/t3code/pull/22187)

[Commits](https://git.cafe/pingdotgg/t3code/pull/22187/commits?format=markdown)

[Files changed](https://git.cafe/pingdotgg/t3code/pull/22187/diffs?format=markdown)

## Description

---

Web browser tests managed React roots, `act` and globals by hand in `beforeEach`/`afterEach`. The first version of this PR replaced that with a helper that pushed screens onto a module-level array for an `afterEach` to drain. State could still leak between tests, and whatever pattern is in the tree is the one agents copy. This version gives every browser test a resource it owns and disposes.

## The pattern

```tsx
it("saves on Enter", async () => {
  const onSave = vi.fn();
  using _now = vi.spyOn(Date, "now").mockReturnValue(0);
  await using screen = await render(<TitleEditor onSave={onSave} />, { router: "/threads/1" });
  await userEvent.type(screen.getByRole("textbox"), "Title{Enter}");
  expect(onSave).toHaveBeenCalledWith("Title");
});
```

`apps/web/test/render.tsx` exports:

- `render(ui, { container?, router? })` returns a `Screen`: container-scoped locators, `container`, `router`, `rerender`, `unmount`, and `Symbol.asyncDispose`. Disposing it unmounts the root and removes the container. Unmounting also unsubscribes the router from its history. `router` defaults to `"/"`; pass `false` to render without one. If the first render throws, the helper cleans up before rethrowing.
- `act(cb)` turns on `IS_REACT_ACT_ENVIRONMENT` only while `cb` runs and restores the previous value afterwards, so updates driven by `userEvent`, timers or the network never trigger act warnings.
- `fakeTimers(config?)` and `stubGlobal(name, value)` return `Disposable`s. Vitest spies are already disposable, so `using _spy = vi.spyOn(...)` works.

For a fixture that needs several of these at once, use `AsyncDisposableStack` and hand ownership to the returned screen with `stack.move()` (see `RightPanelTabs.keyboard` and `colorPickers`).

`src/testRender.browser.test.tsx` checks that disposal unmounts the tree, removes the container and empties the router history's subscribers. It also checks that a render that throws leaves nothing in `document.body`, that the act environment is off outside the helper, and that `stubGlobal` restores the global.

**Toolchain.** TypeScript and Chromium already supported `using` and `AsyncDisposableStack`. The types only reached `apps/web` through a `/// <reference lib="esnext.disposable" />` in `@types/node`, so `apps/web/tsconfig.json` now lists `ESNext.Disposable` explicitly. That is the only config change.

## Why no lifecycle hooks

A hook runs for every test in its scope, so whatever it sets up is shared, and its cleanup only runs if the hook itself is correct. With `using`, a test acquires exactly what it changes, and disposal runs when the block exits, pass or fail, in reverse order. Each test reads top to bottom with nothing hidden.

`vi.mock` factories are hoisted and can only read module-level state. Files that need this keep it in `vi.hoisted` state and reset it with a `setup()` call on the first line of each test, so every test still starts from a known state.

## Lint rule

`t3code/require-disposable-render` applies to `apps/web/**/*.test.tsx` and reports:

- imports from `react-dom/client`
- `act` (or `unstable_act`) imported from `react`
- assignments to `IS_REACT_ACT_ENVIRONMENT`, and `vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", …)`
- `beforeEach`/`afterEach`/`beforeAll`/`afterAll` in `*.browser.test.tsx`

It is set to `warn` so main stays green. It currently reports 635 warnings in 132 files. Change it to `error` in `vite.config.ts` once `vp lint` reports no `t3code(require-disposable-render)` warnings. The comment next to the override says the same. AGENTS.md (Verifying) documents the convention with the example above.

## Migrated

These nine files have no lifecycle hooks, no module-level screens or containers, and no `cleanup()`:
BranchToolbar.panel, GitActionsControl, RightPanelTabs.keyboard, MessagesTimeline, WelcomeWizard.import, PreviewView, PullRequestDetailPanel, PullRequestSummaryTab and colorPickers. All of them became independent without a hook:

- Tests that rendered twice now use `screen.rerender`. Tests that need a fresh mount use a block-scoped `await using`.
- `MessagesTimeline` builds each thread key from a fresh random id instead of a module counter.
- `PullRequestSummaryTab` scopes its `localStorage` clearing to the two filter tests with `using`.
- `RightPanelTabs.keyboard` now renders with `router: false`, as it did on main. Behind the test router, the Escape focus-return test failed in about 1 of 3 runs when all nine files ran together. It passed 5 of 5 on the previous head. It has passed every run since this change.

`vitest-browser-react` is removed again. `render` is about 40 lines on `react-dom/client`, and owning the root is what makes disposal exact.

## Verification

- The nine files plus the helper test (189 tests) ran with `--reporter=verbose`. The last four runs in a row passed, with 0 "not wrapped in act" warnings, 0 act-environment warnings and 0 router warnings in each. The full web suite was not run.
- `t3code/require-disposable-render` has fixture tests (11 cases).
- `apps/web` and `oxlint-plugin-t3code` typecheck. `vp lint` and `vp fmt --check` are clean on the changed files.

## Remaining 133 files

133 browser test files still use the old pattern (132 of them trip the rule). The plan:

1. Migrate by directory in PRs of about 15 to 20 files, each verified the same way: three consecutive runs and zero React warnings.
2. Each PR lowers the warning count. Nothing new can add warnings, because review and the lint output flag them.
3. When the count reaches zero, change the rule to `error` in the same PR.

Model: Claude Opus 5.5 (claude-opus-5-5) in Claude Code, inside T3 Code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)



### Scope and approval

Maintainer-driven test infrastructure change, no product runtime impact. Approved by @juliusmarminge (maintainer) in the coordinating T3 thread on 2026-10-20: the disposable `await using render` convention was requested after rejecting the earlier shared-array/afterEach helper, the foundation was approved with "Test foundation seems fine. Go update all the other react tests with it", and the full stack (foundation plus slices #22267 to #22276) was approved to merge with "merge the full testing stack now". No linked issue; this is internal test tooling.

