From 7efd9b3656aa9a76fdfcc59d1166bc8f16c59952 Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Wed, 28 Oct 2026 13:19:08 +0300 Subject: [PATCH 1/3] feat(page): track out-of-process iframes until they finish loading in their own target A frame swapped into another process leaves the page's frame tree, so a click or navigation stopped waiting for it, and with site isolation on a cross-site iframe was never waited for. Contexts now enables the Page domain on every iframe target before resuming it, and the page follows the iframe's loading over that target's session, so idling? waits for it like any other frame. --- CHANGELOG.md | 2 ++ lib/ferrum/contexts.rb | 9 ++--- lib/ferrum/page.rb | 1 + lib/ferrum/page/frames.rb | 45 ++++++++++++++++++++++-- sig/ferrum/contexts.rbs | 2 +- sig/ferrum/page.rbs | 1 + sig/ferrum/page/frames.rbs | 6 ++++ spec/frame_spec.rb | 11 ++++++ spec/support/views/cross_site_iframe.erb | 17 +++++++++ spec/support/views/slow_image.erb | 6 ++++ 10 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 spec/support/views/cross_site_iframe.erb create mode 100644 spec/support/views/slow_image.erb diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d4b159..16f0dca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ undid `--blink-settings` overrides. `mobile:` defaults to `nil` and leaves touch as is, `false` turns it off [#664] - A click or navigation timed out when the last loading iframe moved to another process, e.g. submitting a form that opens an inline PDF in Chrome 145+, where the PDF viewer runs out of process [#672] +- A click or navigation didn't wait for an out-of-process iframe, e.g. a cross-site iframe with site isolation on, + and timed out when it was the last one loading. Ferrum now follows its loading in the iframe's own target ### Removed diff --git a/lib/ferrum/contexts.rb b/lib/ferrum/contexts.rb index c9a68d07..b1215efa 100644 --- a/lib/ferrum/contexts.rb +++ b/lib/ferrum/contexts.rb @@ -230,17 +230,18 @@ def handle_attach(target, session_id, params, parent) elsif target.worker? || target.shared_worker? connect_worker(target) else - add_extensions_and_resume(session_id, params["waitingForDebugger"]) + add_extensions_and_resume(target, session_id, params["waitingForDebugger"]) end end # A page or iframe is paused until we resume it, so the extensions we # register now run before any script of its first document. An - # out-of-process iframe ignores them until the Page domain is enabled. - def add_extensions_and_resume(session_id, waiting) + # out-of-process iframe ignores them until the Page domain is enabled, + # and its parent page needs the Page domain to see it finish loading. + def add_extensions_and_resume(target, session_id, waiting) extensions = @client.options.extensions session = @client.session(session_id) - session.command("Page.enable", async: true) if extensions.any? + session.command("Page.enable", async: true) if extensions.any? || target.iframe? extensions.each do |source| session.command("Page.addScriptToEvaluateOnNewDocument", async: true, source: source) end diff --git a/lib/ferrum/page.rb b/lib/ferrum/page.rb index 3597d5b0..35704faa 100644 --- a/lib/ferrum/page.rb +++ b/lib/ferrum/page.rb @@ -107,6 +107,7 @@ def initialize(client, context_id:, target_id:, proxy: nil) @options = client.options @frames = Concurrent::Map.new + @remote_frames = Concurrent::Map.new @main_frame = Frame.new(nil, self) @event = Utils::Event.new.tap(&:set) @document_version = 0 diff --git a/lib/ferrum/page/frames.rb b/lib/ferrum/page/frames.rb index 447a61df..1a864db2 100644 --- a/lib/ferrum/page/frames.rb +++ b/lib/ferrum/page/frames.rb @@ -74,6 +74,7 @@ def frame_by(id: nil, name: nil, execution_id: nil) def frames_subscribe subscribe_frame_attached subscribe_frame_detached + subscribe_remote_frames subscribe_frame_started_navigating subscribe_frame_started_loading subscribe_frame_navigated @@ -104,11 +105,50 @@ def subscribe_frame_detached frame.execution_id = nil else @frames.delete(params["frameId"]) - @event.set if @main_frame.idle? && frame&.idle? == false && frames.size == 1 + forget_remote_frame(params["frameId"]) unless params["reason"] == "swap" + @event.set if @main_frame.idle? && frame&.idle? == false && frames.size == 1 && @remote_frames.values.none? end end end + # A frame swapped into another process (`Page.frameDetached` with reason `swap`) leaves the page's frame tree + # and loads in its own `iframe` target, so its loading events come over that target's session. Until it stops + # loading there {#idling?} stays false, as it does for any other loading frame. + def subscribe_remote_frames + on("Target.attachedToTarget") do |params| + info, session_id = params.values_at("targetInfo", "sessionId") + next unless info["type"] == "iframe" + + frame_id = info["targetId"] + @remote_frames.put_if_absent(frame_id, true) + subscribe_remote_frame_loading(client.session(session_id), frame_id) + end + + on("Target.detachedFromTarget") do |params| + forget_remote_frame(params["targetId"]) + end + end + + def subscribe_remote_frame_loading(session, frame_id) + session.on("Page.frameStartedLoading") do |params| + next unless params["frameId"] == frame_id && @remote_frames.key?(frame_id) + + @remote_frames[frame_id] = true + @event.reset + end + + session.on("Page.frameStoppedLoading") do |params| + next unless params["frameId"] == frame_id && @remote_frames.key?(frame_id) + + @remote_frames[frame_id] = false + @event.set if idling? + end + end + + def forget_remote_frame(frame_id) + @event.set if @remote_frames.delete(frame_id) && idling? + end + # Tracks the +loaderId+ for each navigating frame and clears stale # lifecycle events from the previous navigation. def subscribe_frame_started_navigating @@ -257,11 +297,12 @@ def subscribe_execution_contexts_cleared end children.each { |id| @frames.delete(id) } + @remote_frames.clear end end def idling? - @frames.values.all?(&:idle?) + @frames.values.all?(&:idle?) && @remote_frames.values.none? end end end diff --git a/sig/ferrum/contexts.rbs b/sig/ferrum/contexts.rbs index ba9fbcaf..0352d0e9 100644 --- a/sig/ferrum/contexts.rbs +++ b/sig/ferrum/contexts.rbs @@ -55,7 +55,7 @@ module Ferrum def connect_worker: (Target target) -> void - def add_extensions_and_resume: (String session_id, bool? waiting) -> void + def add_extensions_and_resume: (Target target, String session_id, bool? waiting) -> void def resume: (String session_id) -> void diff --git a/sig/ferrum/page.rbs b/sig/ferrum/page.rbs index b73620fe..fca351f8 100644 --- a/sig/ferrum/page.rbs +++ b/sig/ferrum/page.rbs @@ -32,6 +32,7 @@ module Ferrum @target_id: String @options: Browser::Options @frames: ::Concurrent::Map[String, Frame] + @remote_frames: ::Concurrent::Map[String, bool] @main_frame: Frame @event: Utils::Event @mouse: Mouse diff --git a/sig/ferrum/page/frames.rbs b/sig/ferrum/page/frames.rbs index fd4d23ae..c05a01df 100644 --- a/sig/ferrum/page/frames.rbs +++ b/sig/ferrum/page/frames.rbs @@ -15,6 +15,12 @@ module Ferrum def subscribe_frame_detached: () -> untyped + def subscribe_remote_frames: () -> untyped + + def subscribe_remote_frame_loading: (SessionClient session, String frame_id) -> untyped + + def forget_remote_frame: (String? frame_id) -> untyped + def subscribe_frame_started_navigating: () -> untyped def subscribe_frame_started_loading: () -> untyped diff --git a/spec/frame_spec.rb b/spec/frame_spec.rb index c3127df8..4ec27234 100644 --- a/spec/frame_spec.rb +++ b/spec/frame_spec.rb @@ -148,6 +148,17 @@ expect(page.evaluate("location.pathname")).to eq("/slow") end + it "waits for a cross-site frame a click adds", :launch do + browser = Ferrum::Browser.new(base_url: base_url, + browser_options: { "site-per-process" => nil, "disable-features" => "Translate" }) + browser.go_to("/cross_site_iframe") + browser.at_css("button").click + + expect(browser.evaluate("window.frameLoaded")).to be(true) + ensure + browser&.quit + end + context "with lazy loading iframes" do it "does not block visit on lazy iframe" do expect { page.go_to("/lazy_iframe") }.not_to raise_error diff --git a/spec/support/views/cross_site_iframe.erb b/spec/support/views/cross_site_iframe.erb new file mode 100644 index 00000000..a3dfd394 --- /dev/null +++ b/spec/support/views/cross_site_iframe.erb @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/spec/support/views/slow_image.erb b/spec/support/views/slow_image.erb new file mode 100644 index 00000000..801effc2 --- /dev/null +++ b/spec/support/views/slow_image.erb @@ -0,0 +1,6 @@ + + + + + + From f1d183cdb17ab46f3b2f58e9e7074521975ea4e0 Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Wed, 28 Oct 2026 13:23:37 +0300 Subject: [PATCH 2/3] docs(changelog): reference #685 for out-of-process iframe tracking --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16f0dca1..c05e73ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - A click or navigation timed out when the last loading iframe moved to another process, e.g. submitting a form that opens an inline PDF in Chrome 145+, where the PDF viewer runs out of process [#672] - A click or navigation didn't wait for an out-of-process iframe, e.g. a cross-site iframe with site isolation on, - and timed out when it was the last one loading. Ferrum now follows its loading in the iframe's own target + and timed out when it was the last one loading. Ferrum now follows its loading in the iframe's own target [#685] ### Removed From 3aba94120729e5eae2b11213333790d6317f192b Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Wed, 28 Oct 2026 13:23:55 +0300 Subject: [PATCH 3/3] docs(changelog): describe what out-of-process iframes did before #685 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c05e73ac..2d6c33ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ undid `--blink-settings` overrides. `mobile:` defaults to `nil` and leaves touch as is, `false` turns it off [#664] - A click or navigation timed out when the last loading iframe moved to another process, e.g. submitting a form that opens an inline PDF in Chrome 145+, where the PDF viewer runs out of process [#672] -- A click or navigation didn't wait for an out-of-process iframe, e.g. a cross-site iframe with site isolation on, - and timed out when it was the last one loading. Ferrum now follows its loading in the iframe's own target [#685] +- A click or navigation didn't wait for an out-of-process iframe to load, e.g. a cross-site iframe with site isolation + on. Ferrum now follows its loading in the iframe's own target [#685] ### Removed