mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-18 01:34:44 -04:00
Merge pull request #3530 from staylor/fix/superseded-document-lifecycle
Stop load events on a document superseded by navigation
This commit is contained in:
7 files changed
+335
-13
No files matched your search
+266
-12
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user