page: replay POST method/body/header on Page.reload

doReload built a NavigateOpts with only url + kind=.reload; method/body/header
defaulted to GET/null/null, so any prior POST navigation regressed to a GET
on reload. The HTML reload navigation re-fetches the document that produced
the current entry, and Chrome replays the same HTTP request that loaded the
page (including method, body, and Content-Type) — Lightpanda dropped all
three.

Retain the prior request body and content-type header in Frame.NavigatedOpts
(duped into the frame arena), and have doReload capture them into the CDP
command's arena before replacePage() frees the old frame. The reload's
frame.navigate call carries the replayed method/body/header so the request
the page was loaded with is the request that runs again.

Closes #2258
This commit is contained in:
Navid EMAD
2026-04-27 06:28:58 +02:00
parent a578f4d6ad
commit ea6b228f9d
3 changed files with 90 additions and 1 deletions

View File

@@ -614,6 +614,8 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
.cdp_id = opts.cdp_id,
.reason = opts.reason,
.method = opts.method,
.body = if (opts.body) |b| try self.arena.dupe(u8, b) else null,
.header = if (opts.header) |h| try self.arena.dupeZ(u8, h) else null,
};
var headers = try http_client.newHeaders();
@@ -3443,6 +3445,10 @@ pub const NavigatedOpts = struct {
cdp_id: ?i64 = null,
reason: NavigateReason = .address_bar,
method: HttpClient.Method = .GET,
// Retained on the frame's arena so Page.reload can replay the prior
// navigation's HTTP method — matches Chrome's F5 behavior on POST pages.
body: ?[]const u8 = null,
header: ?[:0]const u8 = null,
};
const NavigationType = enum {

View File

@@ -333,8 +333,20 @@ fn doReload(cmd: *CDP.Command) !void {
const session = bc.session;
var frame = session.currentFrame() orelse return error.FrameNotLoaded;
// Dupe URL before replacePage() frees the old frame's arena.
// Capture URL plus the prior navigation's method/body/header before
// replacePage() frees the old frame's arena. Replaying the same HTTP
// method on reload matches Chrome's F5 behavior — POST navigations
// re-submit, GET navigations re-fetch.
const reload_url = try cmd.arena.dupeZ(u8, frame.url);
const prev_nav = frame._navigated_options;
const prev_body: ?[]const u8 = if (prev_nav) |p|
if (p.body) |b| try cmd.arena.dupe(u8, b) else null
else
null;
const prev_header: ?[:0]const u8 = if (prev_nav) |p|
if (p.header) |h| try cmd.arena.dupeZ(u8, h) else null
else
null;
if (frame._load_state != .waiting) {
// Reset isolated world identities to disable V8 weak callbacks before
@@ -351,6 +363,9 @@ fn doReload(cmd: *CDP.Command) !void {
.cdp_id = cmd.input.id,
.kind = .reload,
.force = if (params) |p| p.ignoreCache orelse false else false,
.method = if (prev_nav) |p| p.method else .GET,
.body = prev_body,
.header = prev_header,
});
}
@@ -1003,6 +1018,61 @@ test "cdp.frame: reload" {
}
}
test "cdp.frame: reload replays POST navigation" {
var ctx = try testing.context();
defer ctx.deinit();
// Manually wire up the browser context: loadBrowserContext only does GET
// navigations, but we need the first navigation to be POST.
const cdp_inst = ctx.cdp();
_ = try cdp_inst.createBrowserContext();
var bc = &cdp_inst.browser_context.?;
bc.id = "BID-A6";
bc.session_id = "SID-X";
bc.target_id = "TID-A6-0000000".*;
// First navigation: POST a form-style payload to /echo_method.
{
const f = try bc.session.createPage();
try f.navigate("http://127.0.0.1:9582/echo_method", .{
.method = .POST,
.body = "key=value",
.header = "Content-Type: application/x-www-form-urlencoded",
});
var runner = try bc.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
}
// Sanity: the body confirms a POST round-tripped.
{
const f = bc.session.currentFrame() orelse unreachable;
var ls: js.Local.Scope = undefined;
f.js.localScope(&ls);
defer ls.deinit();
const v = try ls.local.exec("document.body.innerText.includes('method=POST')", null);
try testing.expect(v.toBool());
}
// Trigger a CDP reload. With the fix in place, doReload captures the
// prior POST method/body/header and replays them. Without it (regression
// guard), the second request would silently fall back to GET.
try ctx.processMessage(.{ .id = 50, .method = "Page.reload" });
{
var runner = try bc.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
}
{
const f = bc.session.currentFrame() orelse unreachable;
var ls: js.Local.Scope = undefined;
f.js.localScope(&ls);
defer ls.deinit();
const v = try ls.local.exec("document.body.innerText.includes('method=POST')", null);
try testing.expect(v.toBool());
}
}
test "cdp.frame: addScriptToEvaluateOnNewDocument" {
var ctx = try testing.context();
defer ctx.deinit();

View File

@@ -636,6 +636,19 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
});
}
if (std.mem.eql(u8, path, "/echo_method")) {
// Echo the request method back as HTML so tests can assert on what
// method the navigation used. Used by the Page.reload-replays-POST test.
const method_name = @tagName(req.head.method);
var html_buf: [128]u8 = undefined;
const html = try std.fmt.bufPrint(&html_buf, "<html><body>method={s}</body></html>", .{method_name});
return req.respond(html, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/html; charset=utf-8" },
},
});
}
if (std.mem.startsWith(u8, path, "/src/browser/tests/")) {
// strip off leading / so that it's relative to CWD
return TestHTTPServer.sendFile(req, path[1..]);