Identifying State Leak Signatures #
The tell-tale signs are DOM elements holding stale data and network requests firing at the start of a test that never requested them. Capture the evidence: Playwright’s --trace records network and DOM activity per test, while Cypress’s command log surfaces requests that fire outside any intercept scope. Compare heap allocations before and after each spec to spot retention spikes.
Isolating the Leak Source #
Run sequentially with --retries=0 and bisect to the failing spec. Add beforeEach/afterEach hooks that clear React state stores and reset mocked timers, then correlate the leak with a specific useEffect dependency array. Narrowing to one effect is faster than auditing the whole component tree.
Implementing Deterministic Cleanup #
Every useEffect that starts async work must return a teardown that aborts fetches and clears intervals. Pair that with fresh browser contexts per spec so nothing survives the boundary.
// Playwright: a fresh context per test guarantees no carry-over.
import { test } from '@playwright/test';
test.use({ storageState: undefined }); // no auth cookies/localStorage bleed between tests
// React: the return runs on unmount or dep change — without it the interval outlives the test.
useEffect(() => {
const id = setInterval(fetchData, 5000);
return () => clearInterval(id); // trade-off: none — omitting this is the leak
}, [fetchData]);
Validation & Regression Prevention #
Lock the fix in place so it cannot regress: monitor heap deltas and unhandled rejections in CI, snapshot global stores after each test, and lint for missing cleanup returns.
// cypress.config.ts — isolation + retries surface any regression fast.
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
testIsolation: true, // clears cookies/localStorage/sessionStorage between tests
retries: { runMode: 2, openMode: 0 }, // a leak that survives isolation still shows as a retry-pass
},
});
Where React State Actually Escapes a Test #
“State leak” covers several distinct mechanisms in a React application, and knowing which one you are looking at determines the fix.
Module-scope caches are the most common and the least visible. A query client, a store instance, a memoised selector cache or a context default created at module level is shared by every test in the worker, so data fetched in one test is served from cache in the next. The symptom is a component rendering data it never requested — frequently the previous test’s data, which makes the failure look like a mixed-up fixture.
Subscriptions that outlive the component keep firing after unmount: an event listener on window, a store subscription, an interval started in an effect with no cleanup. These produce errors attributed to the next test, often referencing a component that is no longer mounted.
In-flight requests land after teardown. The response arrives, a setter runs against an unmounted tree, and either a warning or an error appears somewhere unrelated. The distinguishing feature is that the failure moves when test order or timing changes.
Portal and overlay nodes attached to document.body are not removed by unmounting a container, so a query for a dialog in the next test can match the previous one’s markup — which reads as a duplicate-element error rather than as leakage.
Each has a different remedy, and the diagnosis is quick: a solo re-run separates leakage from everything else, and the error’s reference to an unmounted component or to a stale value usually names which of the four is involved.
// Reset the shared surfaces React applications typically create at module scope.
// Trade-off: recreating the client per test costs a little set-up time and is
// what stops one test's fetched data being served to the next.
beforeEach(() => {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
});
afterEach(() => {
queryClient.clear();
cleanup(); // unmount, run effect cleanups, remove portals
});
Proving the Leak Is Gone #
A fix here is easy to believe and hard to verify, because the failure was intermittent to begin with. Three checks together make the verification defensible.
Re-run the previously failing test in the suite, repeatedly. The rate before the fix is the baseline; the same count after it is the evidence. A single green run proves almost nothing when the original rate was a few percent.
Run the suite under several shuffle seeds. Leakage is order-sensitive by nature, so a fix that survives twenty seeds is materially stronger than one that survives the order that happened to be used today.
Add a probe. A two-test pair — one that deliberately writes the state in question, one that asserts it is absent — fails immediately if the cleanup is ever removed, which is what turns a fix into a guarantee rather than a moment in time.
The probe is the underrated one. Cleanup code is regularly deleted during refactors because nothing depends on it visibly, and a probe makes that dependency explicit at the cost of a few lines.
Common Pitfalls #
- Relying on
setTimeout/cy.wait()instead of explicit state assertions. - Missing cleanup returns in
useEffectfor async fetches. - Sharing browser contexts or
localStorageacross E2E files. - Ignoring React StrictMode’s intentional double-invoke — it surfaces missing cleanup; give every effect teardown.
- Leaving pending XHR/fetch calls un-aborted before unmount.
FAQ #
How do I distinguish a state leak from a network race condition? A leak persists across test boundaries and shifts DOM or memory baselines; a network race is transient within one test. Disable network intercepts — if the failure persists, it is leaked state, not timing.
Does React StrictMode cause false-positive flakiness in E2E tests? StrictMode double-invokes effects in development to expose missing cleanup. Give every async effect a teardown return. Most E2E setups run the production build, where StrictMode does not double-invoke.
What is the most reliable way to force React unmounting between tests?
Navigate to a blank route or use a fresh browser context, then assert document.querySelector('#root') has no children before mounting the next component.
Is an act warning worth chasing? Yes. It is the framework reporting that state updated outside the window the test controlled, which is the same late-work problem in a more diagnosable form. Silencing it hides an ordering issue that will resurface as an intermittent failure elsewhere; resolving it — by awaiting the settled state or cancelling the work in teardown — removes the cause.
Why does the leak only appear in CI? Because timing decides whether the late work lands inside the test that started it or the one after. On a fast machine the response arrives before teardown and nothing escapes; on a slower, contended runner it arrives afterwards. That is why a leak can be genuinely invisible locally and consistent in CI, and why reproducing under CI-like CPU limits is more productive than repeating on an idle laptop.
Does a fresh browser context per test remove the problem? It removes the browser-side half — storage, cookies, in-page state — and none of the process-side half in component or unit tests, where the module registry, caches and timers live in the test runner rather than in a browser. Both halves need their own reset, and assuming the runner’s isolation covers everything is a common source of surprise.
Cleanup That Cannot Be Forgotten #
The most durable version of every fix in this guide is one that a future contributor cannot omit. Cleanup written into an individual spec protects that spec; cleanup expressed in shared setup protects the file somebody adds next quarter.
Three placements are worth using together: a global teardown that unmounts, clears timers and resets mocks; a shared fixture that constructs per-test instances of anything that would otherwise be module-scoped; and a lint rule forbidding module-level mutable exports in application code, which removes the surface entirely rather than resetting it.
Each is small on its own, and together they change the default from “isolated if remembered” to “isolated unless deliberately broken”.
When a leak resists identification, narrowing the suite is faster than reasoning about it: run half the files, keep the half that reproduces, and repeat. Six rounds is usually enough to name the file, and the procedure needs no understanding of what the leak actually is.
Cleanup expressed in shared setup outlives the person who wrote it.