From 1a9557e41ee1a24b573265cc262854b7bf92a3a2 Mon Sep 17 00:00:00 2001 From: Scott Taylor Date: Tue, 15 Sep 2026 11:31:09 -0400 Subject: [PATCH 1/3] frame: stop a superseded document's load events once navigation begins A script that navigates away during parsing (a locale redirect, say) left the old document to finish loading normally: DOMContentLoaded and load fired on it, and clients waiting on those signals were told the page was ready just as it was being replaced. Chrome fires none of them: the document still transitions readyState to "complete", but DOMContentLoaded and load never come. Mark the document's load aborted when a cross-document navigation is scheduled (or started directly), and keep it that way through queue consumption and a discarded or failed replacement. document.open() cancels the queued navigation, as in Chrome, and the rewritten document does not get the aborted one's load back. Tests cover the six trigger points against Chrome's event sequences, pending and discarded replacements, open()-during-navigation, and a readystatechange handler that renavigates and throws. --- src/browser/Frame.zig | 151 +++++++++++++++++++++++++++++--- src/browser/Session.zig | 4 + src/browser/webapi/Document.zig | 4 + 3 files changed, 148 insertions(+), 11 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index ebf0b9119..96504e3e2 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -988,6 +988,7 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url // Navigation: kill in-flight HTTP transfers, but leave WebSockets // alive — they're cross-document by spec. + target.document._load_aborted = true; session.browser.http_client.abortRequests(&target._http_owner); // Capture the originating frame's URL as the Referer for this @@ -1029,7 +1030,8 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url } target._queued_navigation = qn; - return session.scheduleNavigation(target); + try session.scheduleNavigation(target); + target.documentIsComplete(); } // A script can have multiple competing navigation events, say it starts off @@ -1097,14 +1099,7 @@ pub fn stopLoading(self: *Frame) void { self.child_frames.items[i].stopLoading(); } - if (self._queued_navigation) |qn| { - const queued = self.page.queued_navigation; - if (std.mem.indexOfScalar(*Frame, queued.items, self)) |idx| { - _ = queued.swapRemove(idx); - } - qn.arena.release(); - self._queued_navigation = null; - } + self.cancelQueuedNavigation(); const http_client = &self._session.browser.http_client; if (http_client.findTransfer(self._req_id)) |transfer| { @@ -1115,8 +1110,24 @@ pub fn stopLoading(self: *Frame) void { http_client.cancelRequests(&self._http_owner); } +fn loadEventsAborted(self: *const Frame) bool { + if (self.document._load_aborted or self.js.env.terminatePending()) return true; + const parent = self.parent orelse return false; + return parent.loadEventsAborted(); +} + +pub fn cancelQueuedNavigation(self: *Frame) void { + const qn = self._queued_navigation orelse return; + const queued = self.page.queued_navigation; + if (std.mem.indexOfScalar(*Frame, queued.items, self)) |idx| { + _ = queued.swapRemove(idx); + } + qn.arena.release(); + self._queued_navigation = null; +} + pub fn documentIsLoaded(self: *Frame) void { - if (self._load_state != .parsing) { + if (self._load_state != .parsing or self.loadEventsAborted()) { // Ideally, documentIsLoaded would only be called once, but if a // script is dynamically added from an async script after // documentIsLoaded is already called, then ScriptManager will call @@ -1134,6 +1145,7 @@ pub fn documentIsLoaded(self: *Frame) void { fn _documentIsLoaded(self: *Frame) !void { try self.dispatchReadyStateChange(); + if (self.loadEventsAborted()) return; const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self.page); try self._event_manager.dispatch( @@ -1225,6 +1237,7 @@ pub fn documentIsComplete(self: *Frame) void { // documentIsLoaded, if there were _only_ async scripts if (self._load_state == .parsing) { self.documentIsLoaded(); + if (self._load_state == .complete) return; } self._load_state = .complete; @@ -1233,7 +1246,7 @@ pub fn documentIsComplete(self: *Frame) void { else => log.err(.frame, "document is complete", .{ .err = err, .type = self._type, .url = self.url }), }; - if (self._maybe_meta_refresh) { + if (self._maybe_meta_refresh and !self.loadEventsAborted()) { self._maybe_meta_refresh = false; self.metaRefreshOnLoad(); } @@ -1242,9 +1255,11 @@ pub fn documentIsComplete(self: *Frame) void { fn _documentIsComplete(self: *Frame) !void { self.document._ready_state = .complete; try self.dispatchReadyStateChange(); + if (self.loadEventsAborted()) return; // Run element load/error events before window.load. try self.dispatchQueuedEvents(); + if (self.loadEventsAborted()) return; // Dispatch window.load event. const window_target = self.window.asEventTarget(); @@ -3790,6 +3805,120 @@ test "Page: isSameOrigin" { try testing.expectEqual(false, frame.isSameOrigin("//origin.com/foo")); } +test "Frame: superseded documents omit DOMContentLoaded and load" { + const cases = [_]struct { trigger: []const u8, expected: []const u8 }{ + .{ .trigger = "location.assign('/next');", .expected = "complete" }, + .{ + .trigger = "document.addEventListener('readystatechange', () => { if (document.readyState === 'interactive') location.assign('/next'); });", + .expected = "interactive|complete", + }, + .{ + .trigger = "document.addEventListener('DOMContentLoaded', () => location.assign('/next'));", + .expected = "interactive|dcl|complete", + }, + .{ + .trigger = "document.addEventListener('readystatechange', () => { if (document.readyState === 'complete') location.assign('/next'); });", + .expected = "interactive|dcl|complete", + }, + .{ .trigger = "location.hash = 'section';", .expected = "interactive|dcl|complete|load" }, + .{ .trigger = "history.replaceState({}, '', '?same-document=1');", .expected = "interactive|dcl|complete|load" }, + }; + for (cases) |case| { + const page = try testing.pageTest("hi.html", .{}); + defer page.close(); + const frame = page.frame().?; + var ls: JS.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + try ls.local.eval( + \\globalThis.events = []; + \\document.addEventListener('readystatechange', () => events.push(document.readyState)); + \\document.addEventListener('DOMContentLoaded', () => events.push('dcl')); + \\window.addEventListener('load', () => events.push('load')); + , null); + frame._load_state = .parsing; + frame.document._ready_state = .loading; + try ls.local.eval(case.trigger, null); + frame.documentIsComplete(); + const events = try ls.local.exec("events.join('|')", null); + try testing.expectEqual(case.expected, try events.toStringSlice()); + } +} + +test "Frame: pending or discarded replacements do not resume old load events" { + for ([_]bool{ false, true }) |discard| { + const page = try testing.pageTest("hi.html", .{}); + defer page.close(); + const frame = page.frame().?; + var ls: JS.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + try ls.local.eval( + \\globalThis.events = []; + \\document.addEventListener('readystatechange', () => events.push(document.readyState)); + \\document.addEventListener('DOMContentLoaded', () => events.push('dcl')); + \\window.addEventListener('load', () => events.push('load')); + , null); + frame._load_state = .parsing; + frame.document._ready_state = .loading; + try frame._session.initiateRootNavigation(frame._frame_id, "http://127.0.0.1:9582/src/browser/tests/hi.html?replacement", .{}); + const replacement = frame.page.replacement.?; + if (discard) frame._session.discardPendingPage(replacement); + try testing.expectEqual(null, frame._queued_navigation); + frame.documentIsComplete(); + const events = try ls.local.exec("events.join('|')", null); + try testing.expectEqual("complete", try events.toStringSlice()); + if (!discard) frame._session.discardPendingPage(replacement); + } +} + +test "Frame: readystatechange during an aborted load may renavigate or throw" { + const page = try testing.pageTest("hi.html", .{}); + defer page.close(); + const frame = page.frame().?; + var ls: JS.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + try ls.local.eval( + \\globalThis.events = []; + \\document.addEventListener('readystatechange', () => { + \\ events.push(document.readyState); + \\ if (document.readyState === 'complete') { location.assign('/second'); throw new Error('handler'); } + \\}); + \\window.addEventListener('load', () => events.push('load')); + , null); + frame._load_state = .parsing; + frame.document._ready_state = .loading; + testing.silenceLog(&.{ .js, .event, .frame }); + try ls.local.eval("location.assign('/first');", null); + try testing.expectEqual("complete", try (try ls.local.exec("events.join('|')", null)).toStringSlice()); + try testing.expectEqual(true, std.mem.endsWith(u8, frame._queued_navigation.?.url, "/second")); + try testing.expectEqual(1, frame.page.queued_navigation.items.len); + frame.documentIsComplete(); + try testing.expectEqual("complete", try (try ls.local.exec("events.join('|')", null)).toStringSlice()); +} + +test "Frame: document.open cancels the queued navigation without reviving load" { + const page = try testing.pageTest("hi.html", .{}); + defer page.close(); + const frame = page.frame().?; + var ls: JS.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + try ls.local.eval( + \\globalThis.events = []; + \\window.addEventListener('load', () => events.push('load')); + \\location.assign('/next'); + \\document.open(); + \\document.write('Rewritten'); + \\document.close(); + , null); + try testing.expectEqual(null, frame._queued_navigation); + try testing.expectEqual(0, frame.page.queued_navigation.items.len); + try testing.expectEqual("Rewritten", try (try ls.local.exec("document.title", null)).toStringSlice()); + try testing.expectEqual("", try (try ls.local.exec("events.join('|')", null)).toStringSlice()); +} + test "Frame: static immediate meta refresh navigates" { const page = try testing.pageTest("fixtures/meta_refresh.html", .{}); defer page.close(); diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 039c983a4..6108dc18e 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -880,6 +880,9 @@ pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, const page = try self.allocatePage(frame_id); errdefer self.queuePageDestruction(page); + // The old document's load stays aborted even if the pending request fails. + live.frame.document._load_aborted = true; + // Reuses `live`'s frame_id: the replacement IS the same browsing context. // `replaces` keeps `live` addressable until commit; the `replacement` // back-pointer is its inverse @@ -904,6 +907,7 @@ pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, log.err(.browser, "pending navigation start", .{ .err = err, .url = url }); return err; }; + live.frame.documentIsComplete(); } // Promote a pending replacement Page to be the live Page. diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index c9ba54688..2927295ed 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -65,6 +65,7 @@ _content_type: ?[]const u8 = null, // createDocument) are UTF-8 regardless of the frame's encoding _charset: ?[]const u8 = null, _ready_state: ReadyState = .loading, +_load_aborted: bool = false, _current_script: ?*Element.Html.Script = null, _elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty, // Track IDs that were removed from the map - they might have duplicates in the tree @@ -1148,6 +1149,9 @@ pub fn open(self: *Document, call_frame: *Frame) !*Document { self._style_sheets = null; self._implementation = null; self._ready_state = .loading; + // open() cancels an ongoing navigation; the aborted document's load is + // gone for good, as in Chrome. + frame.cancelQueuedNavigation(); self._script_created_parser = Parser.Streaming.init(frame.arena, doc_node, frame, .{ .allow_declarative_shadow = true }); try self._script_created_parser.?.start(); From 39dde6db7c5a88297563b5398aa7eb0b5a061d64 Mon Sep 17 00:00:00 2001 From: Scott Taylor Date: Tue, 15 Sep 2026 12:23:13 -0400 Subject: [PATCH 2/3] document: keep an aborted parser from restarting after navigation Track the active-parser-was-aborted flag independently of load state. Navigation can move readyState to complete while the original parser is still on the stack; open/write/close must not start a second parser and trip ScriptManagerBase.staticScriptsDone. Cover inline navigation, post-parse navigation cancellation, and writes after cancelling an aborted parser. Validated with 1531 passing tests on macOS arm64 and Chromium comparisons for the inline and cancellation cases. Assisted-By: devx/f397c207-eb48-428c-a6c5-f94d95fd8ae4 --- src/browser/Frame.zig | 57 ++++++++++++++++++- src/browser/Session.zig | 2 +- .../tests/fixtures/navigation_open.html | 21 +++++++ src/browser/webapi/Document.zig | 6 +- 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/browser/tests/fixtures/navigation_open.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 96504e3e2..a0172772b 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -988,7 +988,7 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url // Navigation: kill in-flight HTTP transfers, but leave WebSockets // alive — they're cross-document by spec. - target.document._load_aborted = true; + target.abortDocumentLoad(); session.browser.http_client.abortRequests(&target._http_owner); // Capture the originating frame's URL as the Referer for this @@ -1110,6 +1110,20 @@ pub fn stopLoading(self: *Frame) void { http_client.cancelRequests(&self._http_owner); } +pub fn abortDocumentLoad(self: *Frame) void { + self.document._load_aborted = true; + + // readyState will become complete even if the parser is still on the + // stack. Remember its aborted state separately so document.open/write cannot + // start another parser, even after the original parse has unwound. + if (self._load_state == .parsing and !self._script_manager.base.static_scripts_done) { + self.document._active_parser_aborted = true; + } + if (self.document._script_created_parser) |parser| { + if (parser.handle != null) self.document._active_parser_aborted = true; + } +} + fn loadEventsAborted(self: *const Frame) bool { if (self.document._load_aborted or self.js.env.terminatePending()) return true; const parent = self.parent orelse return false; @@ -3919,6 +3933,47 @@ test "Frame: document.open cancels the queued navigation without reviving load" try testing.expectEqual("", try (try ls.local.exec("events.join('|')", null)).toStringSlice()); } +test "Frame: document.open after inline navigation does not restart the parser" { + const page = try testing.pageTest("fixtures/navigation_open.html", .{}); + defer page.close(); + + try testing.expect(std.mem.endsWith(u8, page.frame().?.url, "/hi.html")); +} + +test "Frame: document.open can cancel navigation once parsing has finished" { + inline for (.{ "?interactive", "?dcl" }) |query| { + const page = try testing.pageTest("fixtures/navigation_open.html" ++ query, .{}); + defer page.close(); + const frame = page.frame().?; + + try testing.expect(std.mem.endsWith(u8, frame.url, "/navigation_open.html" ++ query)); + try testing.expectEqual(false, frame.document._active_parser_aborted); + try testing.expectEqual(null, frame._queued_navigation); + try testing.expectEqual("Rewritten", (try frame.getTitle()).?); + } +} + +test "Frame: cancelling navigation does not revive an aborted parser" { + const page = try testing.pageTest("fixtures/navigation_open.html?cancel", .{}); + defer page.close(); + const frame = page.frame().?; + + try testing.expect(std.mem.endsWith(u8, frame.url, "/navigation_open.html?cancel")); + var ls: JS.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + // The original parse has unwound, but the active-parser-was-aborted flag + // must still prevent open/write/writeln from replacing the document. + try ls.local.eval( + \\document.open(); + \\document.write('Later write'); + \\document.writeln('Later writeln'); + \\document.close(); + , null); + try testing.expectEqual("Original", try (try ls.local.exec("document.title", null)).toStringSlice()); + try testing.expectEqual(null, frame.document._script_created_parser); +} + test "Frame: static immediate meta refresh navigates" { const page = try testing.pageTest("fixtures/meta_refresh.html", .{}); defer page.close(); diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 6108dc18e..b1f35a95a 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -881,7 +881,7 @@ pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, errdefer self.queuePageDestruction(page); // The old document's load stays aborted even if the pending request fails. - live.frame.document._load_aborted = true; + live.frame.abortDocumentLoad(); // Reuses `live`'s frame_id: the replacement IS the same browsing context. // `replaces` keeps `live` addressable until commit; the `replacement` diff --git a/src/browser/tests/fixtures/navigation_open.html b/src/browser/tests/fixtures/navigation_open.html new file mode 100644 index 000000000..f915c3a0d --- /dev/null +++ b/src/browser/tests/fixtures/navigation_open.html @@ -0,0 +1,21 @@ + +Original + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 2927295ed..0a283a8e2 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -66,6 +66,8 @@ _content_type: ?[]const u8 = null, _charset: ?[]const u8 = null, _ready_state: ReadyState = .loading, _load_aborted: bool = false, +// HTML's "active parser was aborted" flag also makes open/write no-ops. +_active_parser_aborted: bool = false, _current_script: ?*Element.Html.Script = null, _elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty, // Track IDs that were removed from the map - they might have duplicates in the tree @@ -997,6 +999,8 @@ fn writeInternal(self: *Document, text: []const []const u8, append_newline: bool return error.InvalidStateError; } + if (self._active_parser_aborted) return; + const html = blk: { var joined: std.ArrayList(u8) = .empty; for (text) |str| { @@ -1123,7 +1127,7 @@ pub fn open(self: *Document, call_frame: *Frame) !*Document { return error.InvalidStateError; } - if (frame._load_state == .parsing) { + if (self._active_parser_aborted or frame._load_state == .parsing) { return self; } From 21a210fe1631c03f8b8f388516f9144880d27fef Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 16 Sep 2026 14:08:11 +0800 Subject: [PATCH 3/3] Narrow frame suspension The original work was aimed at ensuring that a page which is being navigated away doesn't trigger events that it shouldn't, i.e. `DOMContentLoaded` and `load`. This just narrows down the scope of that little, for example ensuring the new page actually gets its navigate() off being signaling the old page to abort. --- src/browser/Frame.zig | 92 ++++++++++++++++--- src/browser/Runner.zig | 19 ++++ src/browser/Session.zig | 7 +- .../tests/fixtures/navigation_open.html | 7 ++ .../tests/runner/iframe_nav_cancel.html | 3 + .../tests/runner/iframe_nav_cancel_child.html | 7 ++ 6 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 src/browser/tests/runner/iframe_nav_cancel.html create mode 100644 src/browser/tests/runner/iframe_nav_cancel_child.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index a0172772b..9282ca0c4 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1031,7 +1031,7 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url target._queued_navigation = qn; try session.scheduleNavigation(target); - target.documentIsComplete(); + target.abortedDocumentIsComplete(); } // A script can have multiple competing navigation events, say it starts off @@ -1101,6 +1101,16 @@ pub fn stopLoading(self: *Frame) void { self.cancelQueuedNavigation(); + // HTML's "active parser was aborted" flag. Stopping is the only thing that + // actually kills the parser: a merely *scheduled* navigation leaves it + // running until the replacement commits, and Chrome keeps honouring + // document.write until then. + if (self.parserIsRunning()) { + self.document._active_parser_aborted = true; + } else if (self.document._script_created_parser) |parser| { + if (parser.handle != null) self.document._active_parser_aborted = true; + } + const http_client = &self._session.browser.http_client; if (http_client.findTransfer(self._req_id)) |transfer| { // the main navigation is still transfering, force it to finish now, @@ -1110,18 +1120,38 @@ pub fn stopLoading(self: *Frame) void { http_client.cancelRequests(&self._http_owner); } +// A cross-document navigation has been scheduled (or started) for this frame: +// its current document is superseded and must never fire DOMContentLoaded or +// load, even if the replacement is discarded or fails. Deliberately does NOT +// touch _load_state — the parser can still be on the stack, and open/write/ +// maybeCheckpoint key off it. pub fn abortDocumentLoad(self: *Frame) void { self.document._load_aborted = true; +} - // readyState will become complete even if the parser is still on the - // stack. Remember its aborted state separately so document.open/write cannot - // start another parser, even after the original parse has unwound. - if (self._load_state == .parsing and !self._script_manager.base.static_scripts_done) { - self.document._active_parser_aborted = true; - } - if (self.document._script_created_parser) |parser| { - if (parser.handle != null) self.document._active_parser_aborted = true; +// The navigation parser is on the stack, i.e. an inline script is running from +// inside parser.parse(). Narrower than `_load_state == .parsing`, which stays +// true through deferred and async scripts. +fn parserIsRunning(self: *const Frame) bool { + return switch (self._parse_state) { + .html => true, + else => false, + }; +} + +// Chrome moves a superseded document's readyState to "complete" but never +// fires DOMContentLoaded or load. `_load_aborted` suppresses the events; this +// is the readyState half, run as soon as the navigation is scheduled. The +// guard makes it idempotent: a handler that renavigates lands here again. +pub fn abortedDocumentIsComplete(self: *Frame) void { + if (self.document._ready_state == .complete) { + return; } + self.document._ready_state = .complete; + self.dispatchReadyStateChange() catch |err| switch (err) { + error.JsException => {}, // already logged + else => log.err(.frame, "aborted document is complete", .{ .err = err, .type = self._type, .url = self.url }), + }; } fn loadEventsAborted(self: *const Frame) bool { @@ -1138,6 +1168,27 @@ pub fn cancelQueuedNavigation(self: *Frame) void { } qn.arena.release(); self._queued_navigation = null; + + // Our own load is aborted and the replacement that would have completed it + // is now gone, so _documentIsComplete will never reach the parent. Release + // the parent's load delay here or it waits forever. + if (self.document._load_aborted) { + self.releaseParentLoadDelay(); + } +} + +// Stop delaying the parent's load event without dispatching the iframe +// element's load event: that event belongs to a document that actually +// finished loading, and this one never will. +fn releaseParentLoadDelay(self: *Frame) void { + const parent = self.parent orelse return; + if (self._parent_notified) { + return; + } + self._parent_notified = true; + if (self._delays_parent_load) { + parent.pendingLoadCompleted(); + } } pub fn documentIsLoaded(self: *Frame) void { @@ -1267,8 +1318,11 @@ pub fn documentIsComplete(self: *Frame) void { } fn _documentIsComplete(self: *Frame) !void { - self.document._ready_state = .complete; - try self.dispatchReadyStateChange(); + // abortedDocumentIsComplete may already have done this half. + if (self.document._ready_state != .complete) { + self.document._ready_state = .complete; + try self.dispatchReadyStateChange(); + } if (self.loadEventsAborted()) return; // Run element load/error events before window.load. @@ -3953,6 +4007,22 @@ test "Frame: document.open can cancel navigation once parsing has finished" { } } +test "Frame: a scheduled navigation does not abort the parser" { + const page = try testing.pageTest("fixtures/navigation_open.html?write", .{}); + defer page.close(); + const frame = page.frame().?; + + try testing.expect(std.mem.endsWith(u8, frame.url, "/navigation_open.html?write")); + var ls: JS.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + // The write landed: only window.stop() sets the active-parser-was-aborted + // flag, scheduling the navigation doesn't. + const late = try ls.local.exec("document.getElementById('late').textContent", null); + try testing.expectEqual("late", try late.toStringSlice()); + try testing.expectEqual(true, frame.document._active_parser_aborted); +} + test "Frame: cancelling navigation does not revive an aborted parser" { const page = try testing.pageTest("fixtures/navigation_open.html?cancel", .{}); defer page.close(); diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index 2f0820fd0..e85dc09b4 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -559,6 +559,25 @@ test "Runner: lazy iframe does not delay the load event" { try testing.expectEqual(true, lazy_child._parent_notified); } +test "Runner: iframe that cancels its own navigation stops delaying the parent" { + const page = try testing.pageTest("runner/iframe_nav_cancel.html", .{ .wait_until_done = false }); + defer page.close(); + + var runner = page.session.runner(.{}); + try runner.waitForFrame(page.frame_id, 2000, .{ .until = .load }); + + const frame = page.frame().?; + try testing.expectEqual(true, frame._load_state == .complete); + try testing.expectEqual(0, frame._pending_loads); + + // The child aborted its load for a navigation it then cancelled, so no + // replacement frame will ever notify the parent on its behalf. + const child = frame.child_frames.items[0]; + try testing.expectEqual(true, child.document._load_aborted); + try testing.expectEqual(null, child._queued_navigation); + try testing.expectEqual(true, child._parent_notified); +} + test "Runner: idle notifications advance past a resolved condition" { const page = try testing.pageTest("runner/runner1.html", .{}); defer page.close(); diff --git a/src/browser/Session.zig b/src/browser/Session.zig index b1f35a95a..402b4892d 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -880,9 +880,6 @@ pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, const page = try self.allocatePage(frame_id); errdefer self.queuePageDestruction(page); - // The old document's load stays aborted even if the pending request fails. - live.frame.abortDocumentLoad(); - // Reuses `live`'s frame_id: the replacement IS the same browsing context. // `replaces` keeps `live` addressable until commit; the `replacement` // back-pointer is its inverse @@ -907,7 +904,9 @@ pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, log.err(.browser, "pending navigation start", .{ .err = err, .url = url }); return err; }; - live.frame.documentIsComplete(); + + live.frame.abortDocumentLoad(); + live.frame.abortedDocumentIsComplete(); } // Promote a pending replacement Page to be the live Page. diff --git a/src/browser/tests/fixtures/navigation_open.html b/src/browser/tests/fixtures/navigation_open.html index f915c3a0d..0ad8d2750 100644 --- a/src/browser/tests/fixtures/navigation_open.html +++ b/src/browser/tests/fixtures/navigation_open.html @@ -15,6 +15,13 @@ if (location.search === '?interactive') { }); } else if (location.search === '?dcl') { document.addEventListener('DOMContentLoaded', navigateAndOpen); +} else if (location.search === '?write') { + // Scheduling a navigation does not abort the parser: Chrome keeps honouring + // document.write until the replacement commits. window.stop() cancels the + // navigation so the test can see what landed. + location.assign('../hi.html'); + document.write('

late

'); + window.stop(); } else { navigateAndOpen(); } diff --git a/src/browser/tests/runner/iframe_nav_cancel.html b/src/browser/tests/runner/iframe_nav_cancel.html new file mode 100644 index 000000000..1b3a6e50f --- /dev/null +++ b/src/browser/tests/runner/iframe_nav_cancel.html @@ -0,0 +1,3 @@ + + + diff --git a/src/browser/tests/runner/iframe_nav_cancel_child.html b/src/browser/tests/runner/iframe_nav_cancel_child.html new file mode 100644 index 000000000..2645550af --- /dev/null +++ b/src/browser/tests/runner/iframe_nav_cancel_child.html @@ -0,0 +1,7 @@ + +