ReviewOS

stacks/very-happy-dom

Locators resolve once and never wait: no locator.waitFor(), and no action auto-waits

#1604
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Problem

Locator resolves synchronously, once, and gives up. _one() throws the moment nothing matches, and every action calls it exactly once:

async click(): Promise<void> {
  const element = this._one()   // Locator.ts:306 — one look, no retry
  element.focus?.()
  element.click?.()
}

There is no locator.waitFor() at all:

const loc = page.locator('.late')
typeof loc.waitFor   // 'undefined'

So a locator can only see what is already in the document on the tick it is called:

const page = new Browser().newPage()
const document = page.mainFrame.window.document

setTimeout(() => { document.body.innerHTML = '<button>Save</button>' }, 10)

await page.getByRole('button').click()
// Error: No element matches getByRole("button")

Playwright's contract is the opposite: every locator action auto-waits for the element to be attached, visible, stable and enabled, up to the timeout, and only then acts.

Why it matters

"No real browser, so nothing is async" is not true here. Timers run, fetch resolves, microtasks drain, MutationObserver fires, custom elements upgrade. Anything rendered in response to one of those is invisible to a locator that looks once.

The workaround is to sprinkle await page.waitForSelector(...) before each action, which is the pattern Playwright's locators exist to delete — and it has to be repeated at every call site, so one missed spot is a flake rather than an error.

This is also the substrate the web-first matchers need. A toBeVisible() that samples once is the same bug wearing a nicer API.

Proposed scope

The polling machinery already exists — waitForSelector (BrowserPage.ts:208) and waitForFunction (:234) both loop on a 50ms/100ms interval against _defaultTimeoutMs(). Reuse it:

  • locator.waitFor({ state: 'attached' | 'detached' | 'visible' | 'hidden', timeout }).
  • Make the actions auto-wait: click, fill, type, check, uncheck, hover, focus resolve the element by polling rather than by a single _one().
  • Keep the resolution honest about what it cannot check. There is no layout pass, so "stable" (not animating) and "receives events" (not covered) are not answerable — those should be documented as skipped rather than faked.
  • Reads (textContent, count, isVisible) should stay one-shot. Playwright does the same: count() does not wait, and a retrying count() would make the matchers' own retry loop double up.
  • The strict-mode error should survive: waiting for one match, then finding two, must still throw rather than silently pick the first.

Acceptance

  • The reproduction above clicks the button that appears on a later tick.
  • await locator.waitFor({ state: 'hidden' }) resolves when an element is removed or hidden, and rejects on timeout.
  • A timeout names the locator and the state it was waiting for.
  • One-shot reads keep their current behaviour, asserted, so the change does not quietly make count() block for the full timeout when the answer is zero.

Found while assessing whether wildloop's Playwright specs could port here.

Sign in to comment on this issue.