Skip to content

Locator.click() dispatches the click repeatedly on a busy page, then reports TimeoutError #197

Description

@adhami3310

Summary

A single Locator.click() against a page whose click handler blocks the main thread dispatches the click repeatedly — dozens of times — and then raises TimeoutError even though every one of those clicks was delivered and handled.

The repeat count tracks timeout / handler_duration, so the click appears to be re-fired once per handler completion until the overall deadline expires:

handler blocks click timeout click events delivered
300ms 20s 63–64
1200ms 20s 17

Playwright delivers exactly one click and returns successfully in both cases.

This has two distinct user-visible consequences:

  1. Repeated side effects. Any handler that is not idempotent runs many times from one click(). On a toggle (popover, menu, dropdown, checkbox) an even number of dispatches leaves the UI exactly as it started, so the action silently does nothing.
  2. False negatives. The call raises TimeoutError despite the clicks having landed, so callers cannot distinguish "click failed" from "click succeeded many times".

Repro

Deterministic, local, no network. 10/10 on this machine.

import asyncio, http.server, socket, sys, threading

def page_html(block_ms):
    return ("""<!doctype html><html><body style="margin:0">
<button id="toggle" style="width:160px;height:32px">toggle</button>
<div id="panel" hidden>panel</div>
<script>
window.__clicks = 0;
document.getElementById('toggle').addEventListener('click', function () {
  window.__clicks++;
  const p = document.getElementById('panel');
  p.hidden = !p.hidden;
  const end = Date.now() + """ + str(block_ms) + """;
  while (Date.now() < end) {}          // block the main thread, like a heavy re-render
});
</script></body></html>""").encode()

class H(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    block_ms = 0
    def do_GET(self):
        body = page_html(self.block_ms)
        self.send_response(200); self.send_header("Content-Length", str(len(body))); self.end_headers()
        self.wfile.write(body)
    def log_message(self, *a): pass

def serve(block_ms):
    cls = type("H2", (H,), {"block_ms": block_ms})
    s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close()
    srv = http.server.ThreadingHTTPServer(("127.0.0.1", p), cls); srv.daemon_threads = True
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    return p

async def run(module, ports, n=10):
    api = __import__(f"{module}.async_api", fromlist=["async_playwright"])
    async with api.async_playwright() as pw:
        b = await pw.chromium.launch(headless=True)
        page = await b.new_page()
        for block_ms, port in ports.items():
            counts = {}
            for _ in range(n):
                await page.goto(f"http://127.0.0.1:{port}/")
                raised = None
                try:
                    await page.locator("#toggle").click(timeout=20000)
                except Exception as exc:
                    raised = type(exc).__name__
                c = await page.evaluate("() => window.__clicks")
                key = f"clicks={c}" + (f" raised={raised}" if raised else " ok")
                counts[key] = counts.get(key, 0) + 1
            print(f"    {module:11s} handler blocks {block_ms:5d}ms: {counts}")
        await b.close()

ports = {ms: serve(ms) for ms in (300, 1200)}
for m in sys.argv[1:] or ["playwright", "rustwright"]:
    asyncio.run(run(m, ports))

Result

    playwright  handler blocks   300ms: {'clicks=1 ok': 10}
    playwright  handler blocks  1200ms: {'clicks=1 ok': 10}
    rustwright  handler blocks   300ms: {'clicks=63 raised=TimeoutError': 1, 'clicks=64 raised=TimeoutError': 9}
    rustwright  handler blocks  1200ms: {'clicks=17 raised=TimeoutError': 10}

Expected: one click delivered, call returns successfully, as playwright does.

A related warning is emitted on stderr in some runs, which may point at the retry path:

rustwright: mouse release cleanup received no confirmation after committed click: timed out after 5000 ms

Environment

  • rustwright 0.1.1 (PyPI) and git ec130a64 — both reproduce
  • playwright 1.61.0 for comparison
  • Chromium headless shell 1228, Linux x86_64, Python 3.14

Why it matters

Found while evaluating rustwright as a drop-in for a Playwright suite against a React app, where a real (non-synthetic) re-render is enough to trigger it. In the application the effect was milder but harder to diagnose: the page recovered quickly, so exactly two clicks were delivered to a popover trigger — opening and then immediately closing it. The call returned successfully, the popover was closed, and the subsequent assertion failed with no indication that the click had been delivered twice.

Instrumenting the trigger showed a perfect correlation over repeated runs:

click events reaching the trigger popover opened
1 yes (3/3)
2 no (3/3)

The repeated-side-effect case is the more serious one: a single click() on a non-idempotent control (submit, delete, purchase) on a page that is briefly busy can invoke it many times.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions