Repository navigation
Description
Version
1.64.1 (the same logic is on main in packages/playwright-core/src/server/frames.ts)
Steps to reproduce
Save this as repro.mts in a folder with @playwright/test installed, then run node repro.mts:
import { createServer } from "node:http";
import { chromium } from "@playwright/test";
const server = createServer((request, response) => {
if (request.url === "/data") return void setTimeout(() => response.end("ok"), 50);
response.setHeader("content-type", "text/html");
response.end(`<iframe srcdoc="<p>preview</p>"></iframe>`);
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as { port: number };
const attempt = async (removeIframe: boolean) => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`http://localhost:${port}/`);
await page.waitForLoadState("networkidle");
await page.evaluate(() => fetch("/data").then((r) => r.text()));
await page.waitForTimeout(700);
if (removeIframe) await page.evaluate(() => document.querySelector("iframe")!.remove());
const started = Date.now();
const outcome = await page
.waitForLoadState("networkidle", { timeout: 5_000 })
.then(() => `resolved in ${Date.now() - started} ms`)
.catch(() => "TIMED OUT after 5 s");
await browser.close();
return outcome;
};
console.log("fetch, no iframe change: ", await attempt(false));
console.log("fetch, then iframe removed: ", await attempt(true));
server.close();Expected behavior
Both waits resolve. The page has had no request in flight for well over 500 ms.
Actual behavior
fetch, no iframe change: resolved in 0 ms
fetch, then iframe removed: TIMED OUT after 5 s
After the iframe is removed, networkidle never fires again unless the page makes another request. In our test suite, this made waitForLoadState("networkidle") wait until the test timed out. It happened intermittently on a client-side route change that unmounted a srcdoc preview iframe.
Additional context
This is how we read the code in Frame (frames.ts):
- The frame goes idle.
networkidleis added to_firedLifecycleEvents, and_firedNetworkIdleSelfistrue. - The fetch calls
_stopNetworkIdleTimer, which sets_firedNetworkIdleSelf = false. - When the fetch finishes,
_startNetworkIdleTimerreturns early because_firedLifecycleEventsstill hasnetworkidle. So_firedNetworkIdleSelfstaysfalse. - Removing the iframe triggers
_recalculateNetworkIdle. It sees_firedNetworkIdleSelf === falseand deletesnetworkidlefrom_firedLifecycleEvents. - No idle timer is running, so nothing adds
networkidleback.
Any lifecycle recalculation would have the same effect after step 3, not just an iframe detach.
Waits straight after goto / reload aren't affected, because a new document resets the state. We've worked around it by waiting on goto / reload only, and by counting in-flight requests ourselves elsewhere.
Environment
OS: macOS 26.6.2 (arm64)
Node.js: 24.22.2
@playwright/test: 1.64.1
Browser: Chromium (bundled)
Activity
dibas-np commentedon Nov 3, 2026
This looks like the same root cause as #42598. That issue had a different trigger:
domcontentloadedremoved the event there, instead of an iframe detach._startNetworkIdleTimerreturns early whilenetworkidleis still recorded, so once anything recalculates lifecycle state and removes it, no timer is left to bring it back.Two PRs proposed fixes for that one (#42621, #42599). Both were closed, and the maintainers' answer on #42598 was:
So a fix here seems unlikely. Your workaround is what the docs point to: wait for the specific response (
page.waitForResponse) or for the UI state you care about (web-first assertions such asexpect(locator).toBeVisible()), notnetworkidle.pavelfeldman commentedon Nov 3, 2026
We can't reason about networkidle on a non-deteministic app, its usage is documented as discouraged.