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('