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:
- 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.
- 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.
Summary
A single
Locator.click()against a page whose click handler blocks the main thread dispatches the click repeatedly — dozens of times — and then raisesTimeoutErroreven 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:Playwright delivers exactly one click and returns successfully in both cases.
This has two distinct user-visible consequences:
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.TimeoutErrordespite 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.
Result
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:
Environment
ec130a64— both reproduceWhy 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:
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.