diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index ebf0b9119..9282ca0c4 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.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('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: 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('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/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 b0998fbec..f885095a0 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -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. diff --git a/src/browser/tests/fixtures/navigation_open.html b/src/browser/tests/fixtures/navigation_open.html new file mode 100644 index 000000000..0ad8d2750 --- /dev/null +++ b/src/browser/tests/fixtures/navigation_open.html @@ -0,0 +1,28 @@ + +Original + 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 @@ + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index c9ba54688..0a283a8e2 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -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();