skip to content

Interaction Gating

What has to be true before an action runs, why a re-render breaks a handle but not a locator, and the few readiness signals still worth waiting on by hand.

on this pageshow

explore

questions

15

In Playwright, what must be true of an element before locator.click() actually clicks it?

level: juniorimportance: must knowfreq 82%

answer

  1. The action waits before it acts
  2. Four conditions, re-checked every attempt
  3. Layout box, animation frames, hit test
  4. A disabled ancestor counts too
  5. The call log names the failed one

basics

~20 s

Playwright retries a set of actionability checks until they all pass or the call times out: the element must be visible, stable in position, able to receive pointer events at the click point, and enabled.

solid answer

~50 s

Every Playwright action is gated on actionability checks that the runner re-evaluates until they pass or the call times out. For a click the element must be **visible** (a non-empty bounding box, and computed `visibility` not `hidden`), **stable** (its bounding box unchanged across two consecutive animation frames, so it is not mid-animation), **receiving events** (a hit test at the click point resolves to that element or a descendant, not to an overlay), and **enabled** (not `disabled`, including a `disabled` ancestor `fieldset`). Playwright also scrolls the element into view before hit-testing. Not every action needs all four: `fill` wants visible, enabled and editable but skips stability and the hit test, and `press` runs no checks at all. Failing checks are retried, so the click fires as soon as the app settles; if it never does, the timeout error's call log names the check that never passed.

code

typescript · 9 lines
typescript
import { test, expect } from '@playwright/test';

test('assign an issue from the board', async ({ page }) => {
  const card = page.getByRole('listitem', { name: 'ISSUE-412' });
  // click() waits for the Assign button to be visible, stable,
  // hit-testable and enabled before it dispatches anything.
  await card.getByRole('button', { name: 'Assign' }).click({ timeout: 5_000 });
  await expect(page.getByText('Assigned to you')).toBeVisible();
});

go deeper

for a junior

Recall that Playwright waits on its own before a click: the element has to be visible, stable, hit-testable and enabled. Do not reach for a sleep to make a click work.

for a middle

Explain each check in DOM terms - bounding box and computed visibility, two identical animation frames, a hit test at the click point, the disabled state - and say which actions skip which.

for a senior

Show that you read the call log to name the check that never passed, and that you fix the app or retarget the locator rather than opting out of the gate.

for a principal

Own the standard for how the team answers a failed check: read it and fix the cause, so the gate stays a signal about the product rather than something the suite routinely bypasses.

## What "actionability" means in Playwright Playwright does not act on whatever the selector matched at the instant you called it. Every action -- `locator.click()`, `locator.check()`, `locator.hover()`, `locator.fill()` -- first runs a set of **actionability checks** against the element the locator resolves to, and it re-runs the whole cycle until every required check passes or the call's timeout expires. That loop is what people mean by Playwright's auto-waiting: there is no separate wait step because the gate is built into the action itself. ## The five checks - **Visible** -- the element has a non-empty bounding box and its computed `visibility` is not `hidden`. An element with `display: none`, a zero-height container, or one that has not been rendered yet fails here. - **Visible, the surprising part** -- an element with `opacity: 0` **passes**. It still occupies layout, so Playwright counts it as visible and will happily click something the user cannot see. - **Stable** -- the element's bounding box is identical across two consecutive animation frames. A card sliding into a board column, a modal easing open, or a list reflowing after a fetch is unstable until the movement stops. - **Receives events** -- Playwright hit-tests the point it is about to act on. The node found at that point must be the target element or one of its descendants. A cookie banner, a toast, a modal backdrop or a stray full-screen `div` fails this check, and the error names the node that got in the way. - **Enabled** -- the element is not disabled. For form controls that includes the disabled state inherited from an ancestor `<fieldset>`. - **Editable** -- the element is enabled and not `readonly`. Only the value-writing actions ask for it. ## Which action requires which check | Action | Visible | Stable | Receives events | Enabled | Editable | |---|---|---|---|---|---| | `click`, `dblclick`, `tap`, `check`, `uncheck` | yes | yes | yes | yes | -- | | `hover`, `dragTo` | yes | yes | yes | -- | -- | | `selectOption` | yes | yes | -- | yes | -- | | `fill`, `clear` | yes | -- | -- | yes | yes | | `screenshot` | yes | yes | -- | -- | -- | | `focus`, `press`, `dispatchEvent`, `setInputFiles` | -- | -- | -- | -- | -- | Two things fall out of that table. First, the gate is **per action**, so "Playwright waits for the element" is too coarse an answer in an interview -- the interesting follow-up is always which checks a given call skips. Second, a handful of calls require nothing at all, which is exactly why they can drive an element a real user could never reach. ## What one attempt actually does 1. Re-resolve the locator against the live DOM. 2. Run the checks this action requires against the resolved element. 3. Scroll the element into view if it is not already there. 4. Compute the action point and, for pointer actions, hit-test it. 5. Perform the action -- or start another attempt if a check failed. Because step 1 runs every time, an issue card that re-renders between attempts is simply found again. Because steps 2-4 run every time, a condition that turns true halfway through the window is picked up on the next pass, and the action fires as soon as the app settles rather than after a fixed delay. ## When the window runs out The failure is a `TimeoutError` whose message names the call and the budget, followed by a call log that replays the attempt: ``` TimeoutError: locator.click: Timeout 5000ms exceeded. Call log: - waiting for getByRole('button', { name: 'Assign' }) - locator resolved to <button disabled>Assign</button> - attempting click action - waiting for element to be visible, enabled and stable - element is not enabled - retrying click action, attempt #2 ``` The last line the log reached is the check that never passed. That is the whole diagnostic value of the error, and it is why raising the timeout before reading the log is the wrong first move. ## What the gate does and does not promise - It promises the element was **interactive** at the moment of the action -- laid out, still, reachable and not disabled. - It does not promise the element is the **right** one: a locator that matches a stale issue card passes every check. - It does not promise the action **worked**; the event was dispatched, and whether the app handled it is what the next assertion is for. - Passing `force: true` removes the promise while leaving the dispatch in place, and `trial: true` keeps the promise while removing the dispatch.

  • Does an element with `opacity: 0` pass Playwright's visibility check?
    Yes. Playwright calls an element visible when it has a non-empty bounding box and its computed `visibility` is not `hidden`, and a zero-opacity element still occupies layout. So the click proceeds on something the user cannot see. If it should count as hidden, the app needs `visibility: hidden`, `display: none`, or to not render it.
  • Which clock bounds the retry loop when you pass no timeout to the action?
    In `@playwright/test` the per-action `timeout` falls back to the `actionTimeout` setting, which is `0` -- unbounded -- out of the box, so the checks retry until the test's own timeout ends the attempt. Setting `actionTimeout` in the config, or passing `timeout` on the call, gives you a shorter and far more legible failure.

The action behaves like a driver at a level crossing rather than one on a stopwatch: it does not move after a set number of seconds, it moves when the barrier is up, the track is clear and the light is green.

saying these in an interview costs you the question

  • Says Playwright clicks immediately so you must add a sleep
  • Thinks opacity zero fails the visibility check
  • Believes every action runs the same set of checks
  • Claims the checks run once instead of being retried
  • Ignores disabled inherited from an ancestor fieldset
open as a page

In Playwright, what is the difference between a Locator and an ElementHandle?

level: juniorimportance: must knowfreq 76%

basics

~10 s

A Playwright Locator stores a recipe for finding an element and re-runs it on every use. An ElementHandle points at one DOM node captured once, so it breaks when the page re-renders that node.

open as a page

In Playwright, what does page.goto() wait for before it resolves?

level: juniorimportance: must knowfreq 78%

basics

~10 s

page.goto() resolves when the load event fires on the new document. The waitUntil option moves that gate to domcontentloaded, commit, or the discouraged networkidle, and the call returns the main resource response.

open as a page

How do you use a Playwright timeout error's call log to find which actionability check never passed?

level: seniorimportance: must knowfreq 58%

basics

~20 s

Read the call log printed under the error. It replays the attempt in order and stops at the condition that never held - element is not visible, not stable, not enabled, or a named node that intercepts pointer events.

open as a page

In Playwright, why does locator.fill() succeed on an input covered by an overlay when locator.click() times out?

level: middleimportance: should knowfreq 46%

basics

~10 s

Different actions require different checks. Filling waits only for visible, enabled and editable, then sets the value directly. Clicking additionally requires stability and a hit test at the click point, which the overlay fails.

open as a page

What does Playwright's locator.elementHandle() do before it returns a handle?

level: middleimportance: should knowfreq 38%

basics

~20 s

It waits for the locator to resolve to exactly one attached element and returns a handle to that DOM node. It waits for attachment only, not visibility, and throws when several elements match or when the timeout expires.

open as a page

Why does Playwright mark page.$() and page.$$() as discouraged?

level: middleimportance: should knowfreq 46%

basics

~20 s

Both query once and return handles to whatever exists at that instant: page.$() resolves to null when nothing matches and page.$$() to an empty array, with no waiting. Locators re-resolve and auto-wait instead, so these two calls reintroduce flakiness.

open as a page

Why does Playwright discourage waiting on the networkidle load state in tests?

level: middleimportance: should knowfreq 62%

basics

~20 s

networkidle resolves only after 500 ms with no network connections, which is a proxy for readiness that a polling or streaming app never satisfies. Playwright marks it discouraged and tells you to assert on the rendered result instead.

open as a page

When is Playwright's locator.waitFor() still the right call rather than a retrying assertion?

level: middleimportance: should knowfreq 48%

basics

~20 s

Use locator.waitFor() when an element must reach a state but there is nothing to assert - a precondition, or before a raw read. Where the test would assert, a retrying matcher is better: it reports the actual value.

open as a page

In Playwright, what does adding force: true to a click that keeps failing actually hide?

level: seniorimportance: should knowfreq 47%

basics

~10 s

Forcing hides the failed check, not its cause. Playwright skips the actionability checks but still dispatches a real event at the computed point, so an overlay that owned that point still receives the click.

open as a page

Your Playwright test fails with 'Element is not attached to the DOM' after the issue board refreshes, so how do you find and fix the cause?

level: seniorimportance: should knowfreq 50%

basics

~20 s

That error comes from acting through an ElementHandle whose node was replaced. Find where the handle is created and stored, usually a page-object field or a hook, and replace it with a locator that re-resolves on every action.

open as a page

In a Playwright test, why does page.waitForLoadState() after a click often fail to make the test wait?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Because it inspects whichever document is current when the call runs. If the click has not committed a navigation yet, or never will because the app routes client-side, the old document already satisfies the state and it returns instantly.

open as a page

What does Playwright's locator.click({ trial: true }) actually do?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

A trial run performs the actionability checks and then stops. Playwright waits for the element to be visible, stable, hit-testable and enabled, and never dispatches the click, so it verifies readiness without changing the page.

open as a page

In Playwright, when is passing an ElementHandle into page.evaluate() better than locator.evaluate()?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

Use locator.evaluate() for one element: it resolves the locator, runs your function with that element and disposes the handle for you. Pass handles into page.evaluate() only when one function needs several live objects at once.

open as a page

Your team's Playwright suite is littered with explicit lifecycle waits; how do you decide which ones stay?

level: principalimportance: nice to knowfreq 30%

basics

~10 s

Rank the signals by how directly they express what the test needs. Assertions on rendered content first, page.waitForURL for routing, locator.waitFor for preconditions, page.waitForFunction as a documented last resort, and networkidle nowhere at all.

open as a page