Skip to content

page: Page.navigate("about:blank") fires load events but does not replace the document on a non-blank tab #2363

@navidemad

Description

@navidemad

Note

Fixed by #2523. Synthetic root navigations (about:blank, blob:) went through Session.initiateRootNavigation, which allocates a pending Page that is only promoted to active when HTTP response headers arrive (frameHeaderDoneCallback). about:blank makes no HTTP request, so the pending Page was never committed and the previous document stayed active. The fix routes synthetic URLs to the immediate-swap path (replaceRootImmediate) instead. The "Likely fix location" called out below was correct.


Summary

Page.navigate("about:blank") issued against a tab that's currently on a non-blank document fires the entire navigation event sequence (frameStartedNavigating, executionContextsCleared, frameNavigated, executionContextCreated, domContentEventFired, loadEventFired, frameStoppedLoading) but doesn't actually replace the document. After the events settle, window.location.href, document.URL, document.body.innerHTML, and Page.getFrameTree's main-frame URL all still report the previous page. Plain http→http navigation works correctly; the regression is specific to about:blank as a destination.

The behavior changed between build 6005 (HEAD 0420802f, public nightly 2026-05-04 03:44 UTC) and build 6051 (HEAD d360fcc0, public nightly 2026-05-05 ~03:30 UTC). The most plausible suspect is #2297 (replacement of replacePage with Session.initiateRootNavigation + the pending-pages model, merged 2026-05-04) — the new model creates a pending page for the about:blank navigation but never seems to commit it to active.

Today's behavior

sequenceDiagram
    participant Client as CDP Client
    participant LP as Lightpanda
    participant Frame as main frame
    Client->>LP: Page.navigate(http://example/)
    LP->>Frame: navigate
    Frame-->>LP: load complete
    LP-->>Client: Page.loadEventFired
    Client->>LP: Runtime.evaluate(window.location.href)
    LP-->>Client: "http://example/"  ✓
    Client->>LP: Page.navigate(about:blank)
    LP->>Frame: initiateRootNavigation pending
    Frame-->>LP: events fire (frameNavigated x2, executionContextCreated, loadEventFired)
    LP-->>Client: Page.loadEventFired
    Client->>LP: Runtime.evaluate(window.location.href)
    LP-->>Client: "http://example/"  ✗ pending page never replaced active
Loading

Expected behavior

Per the HTML Living Standard navigation algorithm and Chrome behavior, Page.navigate("about:blank") should replace the active document with a fresh empty about:blank document. After loadEventFired, window.location.href should be "about:blank" and document.body should be empty.

sequenceDiagram
    participant Client as CDP Client
    participant LP as Lightpanda
    participant Frame as main frame
    Client->>LP: Page.navigate(about:blank)
    LP->>Frame: replace with about:blank
    Frame-->>LP: load complete
    LP-->>Client: Page.loadEventFired
    Client->>LP: Runtime.evaluate(window.location.href)
    LP-->>Client: "about:blank"  ✓
Loading

Reproducer

Self-contained Node script. Requires Node >= 22 (built-in WebSocket) and a running lightpanda serve --host 127.0.0.1 --port 9876. No external npm deps.

aboutblank-repro.mjs:

// Page.navigate("about:blank") doesn't replace the document on a non-blank tab.
import { createServer } from "http";

const httpServer = createServer((_, res) => {
  res.writeHead(200, { "content-type": "text/html" });
  res.end("<!DOCTYPE html><html><head><title>PAGE-A</title></head><body><h1>PAGE-A</h1></body></html>");
});
const httpPort = await new Promise((r) =>
  httpServer.listen(0, "127.0.0.1", () => r(httpServer.address().port)),
);

let id = 0;
const pending = new Map();
const ws = new WebSocket("ws://127.0.0.1:9876/");
const send = (m, p = {}, sessionId) =>
  new Promise((r, j) => {
    const i = ++id;
    pending.set(i, { r, j });
    ws.send(JSON.stringify({ id: i, method: m, params: p, ...(sessionId && { sessionId }) }));
  });
ws.addEventListener("message", (e) => {
  const msg = JSON.parse(e.data);
  if (msg.id && pending.has(msg.id)) {
    const { r, j } = pending.get(msg.id);
    pending.delete(msg.id);
    msg.error ? j(new Error(JSON.stringify(msg.error))) : r(msg.result);
  }
});
await new Promise((r) => ws.addEventListener("open", r, { once: true }));

const { targetId } = await send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
await send("Runtime.enable", {}, sessionId);
await send("Page.enable", {}, sessionId);

const waitForLoad = () =>
  new Promise((r) => {
    const onMsg = (e) => {
      if (JSON.parse(e.data).method === "Page.loadEventFired") {
        ws.removeEventListener("message", onMsg);
        r();
      }
    };
    ws.addEventListener("message", onMsg);
    setTimeout(() => { ws.removeEventListener("message", onMsg); r(); }, 3000);
  });

const probe = async () => {
  const r = await send("Runtime.evaluate", {
    expression: "JSON.stringify({ url: window.location.href, body: document.body && document.body.innerHTML.slice(0,40) })",
    returnByValue: true,
  }, sessionId);
  return JSON.parse(r.result.value);
};

const url = `http://127.0.0.1:${httpPort}/`;
let p = waitForLoad();
await send("Page.navigate", { url }, sessionId);
await p;
console.log(`After Page.navigate(${url}):`);
console.log(`  ${JSON.stringify(await probe())}`);

p = waitForLoad();
await send("Page.navigate", { url: "about:blank" }, sessionId);
await p;
await new Promise((r) => setTimeout(r, 200));
const after = await probe();
console.log(`\nAfter Page.navigate("about:blank"):`);
console.log(`  ${JSON.stringify(after)}`);
console.log(`  expected: {"url":"about:blank","body":""}`);

const ok = after.url === "about:blank";
console.log(`\n${ok ? "PASS (Chrome parity)" : "FAIL (regression — page was not replaced)"}`);

ws.close();
httpServer.close();
process.exit(ok ? 0 : 1);

Run (with lightpanda serve --host 127.0.0.1 --port 9876 running in another terminal):

node aboutblank-repro.mjs

Today against build 6051: prints

After Page.navigate(http://127.0.0.1:NNNN/):
  {"url":"http://127.0.0.1:NNNN/","body":"<h1>PAGE-A</h1>"}

After Page.navigate("about:blank"):
  {"url":"http://127.0.0.1:NNNN/","body":"<h1>PAGE-A</h1>"}
  expected: {"url":"about:blank","body":""}

FAIL (regression — page was not replaced)

and exits 1.

Expected after the fix: prints "url":"about:blank","body":"" and exits 0.

Bisect

HEAD Build Behavior
0420802f (just before #2297) 6005 works correctly — about:blank navigation replaces the page
d360fcc0 (current main) 6051 broken — events fire, document not replaced

Validation of the 6005 baseline came from running the consumer's existing test suite (capybara-lightpanda's Driver#reset spec, which calls Page.navigate("about:blank") and then asserts window.location.href === "about:blank") against the public nightly that was at HEAD 0420802f — it passed there and breaks at HEAD d360fcc0. http→http navigation works on both builds.

Likely fix location

src/browser/Session.ziginitiateRootNavigation / pending-pages plumbing introduced by #2297. The pending page for the about:blank navigation appears to be created and torn down (its executionContextCreated fires) without ever being promoted to the active page, leaving the previous active document in place. Worth checking the special-casing of about:blank URLs in the new pending-pages flow — Chrome's about:blank handling is typically synchronous-ish and may not match the new pending-pages assumption that the navigation flows through the network stack.

Environment

  • Lightpanda: 1.0.0-nightly.6051+d360fcc0 (current public nightly, downloaded 2026-05-05) and 1.0.0-dev.6024+d360fcc0 (locally built from main HEAD)
  • OS: macOS aarch64 (darwin)
  • CDP client: Node 25 built-in WebSocket (no npm deps)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions