Merge pull request #3530 from staylor/fix/superseded-document-lifecycle

Stop load events on a document superseded by navigation
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-16 15:46:01 +08:00
commit 828130fdcd
7 files changed
+335 -13

No files matched your search

+266 -12
View File
@@ -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.abortDocumentLoad();
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.abortedDocumentIsComplete();
}
// A script can have multiple competing navigation events, say it starts off
@@ -1097,13 +1099,16 @@ 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();
// 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;
@@ -1115,8 +1120,79 @@ 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;
}
// 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 {
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;
// 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 {
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 +1210,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 +1302,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,18 +1311,23 @@ 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();
}
}
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.
try self.dispatchQueuedEvents();
if (self.loadEventsAborted()) return;
// Dispatch window.load event.
const window_target = self.window.asEventTarget();
@@ -3790,6 +3873,177 @@ 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('<title>Rewritten</title>');
\\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: 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: 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();
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('<title>Later write</title>');
\\document.writeln('<title>Later writeln</title>');
\\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();
+19
View File
@@ -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();
+3
View File
@@ -904,6 +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.abortDocumentLoad();
live.frame.abortedDocumentIsComplete();
}
// Promote a pending replacement Page to be the live Page.
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<title>Original</title>
<script>
function navigateAndOpen() {
location.assign('../hi.html');
if (location.search === '?cancel') window.stop();
document.open();
document.write('<title>Rewritten</title>');
document.close();
}
if (location.search === '?interactive') {
document.addEventListener('readystatechange', () => {
if (document.readyState === 'interactive') navigateAndOpen();
});
} 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('<p id="late">late</p>');
window.stop();
} else {
navigateAndOpen();
}
</script>
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<iframe src="iframe_nav_cancel_child.html"></iframe>
@@ -0,0 +1,7 @@
<!doctype html>
<script>
// Abort our own load for a navigation, then cancel the navigation. Nothing
// will ever complete this document, so it must stop delaying the parent.
location.assign('runner1.html');
window.stop();
</script>
+9 -1
View File
@@ -65,6 +65,9 @@ _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,
// 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
@@ -996,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| {
@@ -1122,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;
}
@@ -1148,6 +1153,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();