[Bug]: With javaScriptEnabled: false, an action retry backoff never completes (in-page setTimeout never fires)
Version
1.59.1
Steps to reproduce
With javaScriptEnabled: false, an actionability retry that has to wait never finishes waiting. The retry backoff is an in-page setTimeout, and timers don't run while script execution is disabled.
Self-contained reproduction (no server; the page is served by route):
// repro.mjs — node repro.mjs
import { chromium } from "playwright";
// The button is covered for 300 ms by an overlay that a pure-CSS animation hides; no script involved.
const html = `<!doctype html><style>
#cover{position:fixed;inset:0;background:rgba(0,0,0,.1);animation:gone 0s linear .3s forwards}
@keyframes gone{to{visibility:hidden}}
</style><form action="/done"><button>Continue</button></form><div id="cover"></div>`;
for (const [label, opts] of [["bundled", {}], ["chrome", { channel: "chrome" }]]) {
const browser = await chromium.launch(opts);
for (const javaScriptEnabled of [true, false]) {
const context = await browser.newContext({ javaScriptEnabled });
await context.route("http://repro.test/**", (r) => r.fulfill({ contentType: "text/html", body: r.request().url().includes("/done") ? "done" : html }));
const page = await context.newPage();
await page.goto("http://repro.test/");
const t = performance.now();
const outcome = await page.getByRole("button", { name: "Continue" }).click({ timeout: 90_000 }).then(() => "clicked", (e) => e.name);
console.log(label, browser.version(), `javaScriptEnabled=${javaScriptEnabled}`, outcome, `${((performance.now() - t) / 1000).toFixed(2)}s`);
await context.close();
}
await browser.close();
}Expected behavior
The click succeeds shortly after the overlay disappears (~0.3–1 s), as it does with JavaScript enabled.
Actual behavior
| Browser | javaScriptEnabled: true |
javaScriptEnabled: false |
|---|---|---|
| Bundled Chromium headless shell 147.0.7727.15 | 0.34 s | 30.13 s |
| Google Chrome 153.0.8010.54, headless | 0.38 s | never completes (TimeoutError at 90 s) |
| Google Chrome 153.0.8010.54, headed | — | never completes (90 s timeout; separate run) |
DEBUG=pw:api,pw:protocol for the 147 case shows the sequence:
pw:api element is not stable (or: <div id="cover"> intercepts pointer events)
pw:api retrying click action
pw:api waiting 20ms
pw:protocol SEND ► Runtime.callFunctionOn (utilityScript.evaluate …) ← the backoff
… ~30 s, nothing …
pw:protocol ◀ RECV {"error":{"code":-32000,"message":"Promise was collected"}}
pw:api element was detached from the DOM, retryingThe backoff is implemented in the page. lib/server/dom.js in 1.59.1 (around line 270):
progress.log(` waiting ${timeout}ms`);
const result2 = await progress.race(this.evaluateInUtility(([injected, node, timeout2]) => new Promise((f) => setTimeout(f, timeout2)), timeout));With script execution disabled, setTimeout callbacks don't run, even in the utility world. The promise stays pending until V8 collects it. In the headless shell that took ~30 s, and Playwright then misreports it as the element being detached (compare #41826, where "Promise was collected" surfaced as a navigation error). In Chrome 153 it was never collected within 90 s, so the action times out.
requestAnimationFrame in the utility world does still fire in the same page, which is why the stability check itself works and only the backoff hangs.
Additional context
Proposed fix: do the retry backoff on the driver side, e.g. await progress.wait(timeout), instead of evaluating a setTimeout promise in the page. That removes the dependency on the page's timers. As a smaller change, map the Promise was collected protocol error to a message that says what happened, not "element was detached".
Workaround we use: in no-JS tests, assert the button with expect(button).toBeVisible() / toBeEnabled() (these poll from the driver and aren't affected), then submit natively with field.press("Enter").
Environment
- OS: macOS 27.0 (arm64)
- Node.js: 22.22.0
- Playwright: 1.59.1 (@playwright/test)
- Browsers: bundled chromium-headless-shell 147.0.7727.15; Google Chrome 153.0.8010.54 (channel "chrome")Source: microsoft/playwright