Playwright auto-wait is great, until your component re-renders mid-action
Why Playwright's actionability check can fire on a stale element, the React state-update pattern that triggers it, and the locator strategy that survives the re-render.
A Playwright test clicks a button. The button is visible, enabled, and not animating. The click fires, then nothing happens — or worse, the test fails with Error: locator.click: Element is not attached to the DOM. The button was there a moment ago. Now it is not.
We see this pattern often enough on Mergify Test Insights that it earned its own slot in our flaky Playwright catalog. The cause is a re-render landing between Playwright’s actionability check and the action itself. The fix is a locator strategy that requeries instead of holding a reference.
What you see
import { expect, test } from "@playwright/test";
test("submit creates an order", async ({ page }) => {
await page.goto("/checkout");
await page.getByRole("button", { name: "Place order" }).click();
await expect(page.getByText("Order confirmed")).toBeVisible();
});
Most of the time this works. About 5% of CI runs fail with Element is not attached to the DOM on the click. The button never visibly disappears. The screenshot Playwright captures shows the button right where it should be.
What actually happened: between the moment Playwright resolved the getByRole query and the moment it dispatched the click, React re-rendered the checkout form. The old button instance was unmounted. The DOM has a new button at the same position, with the same role, looking identical. Playwright is holding a reference to the old, detached one.
Why React’s reconciler causes this
React reconciles by component identity, not DOM identity. A state update inside the form (a field validation, a focus change, an effect that fires once on mount) can cause React to rebuild the button’s parent tree, which means React unmounts the old button and creates a new one in the same place. Visually identical. Programmatically, two different DOM nodes.
The window for this race is small. Playwright resolves the locator, checks actionability (visible, enabled, stable position), and dispatches the action. Between the actionability check and the dispatch is usually under 50ms. On a fast laptop, React’s re-render almost always lands outside that window. On the slower CI runner, it sometimes lands inside.
The auto-wait that Playwright sells you on covers the initial wait: it polls until the button exists, is visible, is interactable. It does not poll once it has decided to act. The element it found is the element it acts on, even if React has replaced it by then.
The naive fix and why it is incomplete
await page.getByRole("button", { name: "Place order" }).click({ timeout: 30000 });
Bumping the timeout does nothing for this race. The timeout governs how long Playwright waits before finding an actionable element. Once it finds one, the timeout is not in play. The race is in the gap between resolution and action, which the timeout cannot extend.
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Place order" }).click();
Waiting for network idle works for forms whose state updates come from network responses. It does not work for state updates triggered by client-side events, focus changes, or mount-time effects. And networkidle is itself flaky in SPAs that maintain long-lived WebSocket connections — it never settles.
The fix that holds
Anchor the locator to a stable parent and let Playwright requery on retry:
await page
.getByRole("form", { name: "Checkout" })
.getByRole("button", { name: "Place order" })
.click();
This still resolves to the same button. The difference is that on retry, Playwright requeries from the form down. If the button got replaced, the requery picks up the new one because it traverses from a parent that did not change.
Playwright’s locator chain is lazy. The chain is not “find form, hold reference, find button, hold reference.” It is a description of what to find when an action fires. Each retry runs the description fresh. As long as the parent in your description survives the re-render, the requery picks up the new child.
For forms that re-render frequently, scope to a data-testid on a stable container:
<form data-testid="checkout-form">
{/* contents that re-render */}
</form>
await page
.getByTestId("checkout-form")
.getByRole("button", { name: "Place order" })
.click();
data-testid is the contract: the parent will not change identity even when its children do. Use it sparingly, on the parent only, not on every child.
When the re-render is the bug
Sometimes the test is the canary. A button that gets unmounted and remounted on every render is a real performance bug. If the failure correlates with a recent change to the checkout component, the right fix is to stabilize the component, not the test. React.memo on the button, a useCallback on the parent’s handler, or a key on the form that does not change every render.
The test catches this by failing flakily. Stabilizing the locator just lets the test pass through; stabilizing the component fixes the underlying perf issue too.
How Mergify catches this before you ship
Detached-DOM failures look like noise to a manual triager. They are not always easy to attribute: the test author thinks “the button is right there” and reaches for --retry or extends the timeout, neither of which actually helps.
Test Insights groups detached-DOM failures distinctly from logic failures. The dashboard tags them as re-render races and surfaces the test alongside any recent commit to the component named in the locator. You see “PlaceOrderButton failure” next to “checkout component changed in PR #142.” Quarantine kicks in once the pattern is confirmed, so the merge queue keeps moving while you anchor the locator (or fix the underlying re-render).
If you spot this pattern in your suite, point Mergify at it. Works with Playwright’s built-in JUnit reporter or any JUnit-compatible output.
More patterns like this
Auto-wait races on re-renders are one of the eight patterns in the flaky-tests-in-Playwright guide. The others are variants of the same theme: tests that race against the page in ways the framework’s “auto” wait does not cover. Route handlers registered after page.goto, networkidle that never settles in SPAs, waitForResponse subscribed too late, locator strict-mode violations after a UI change. Cause and symptom usually live in different files.
The upside: each one has a clean fix once you can name it. Most are locator decisions, not test rewrites.