From 7e8e83d0caa92a0015795c525f80684f1803ffd7 Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Fri, 16 Oct 2026 22:14:42 +0300 Subject: [PATCH 1/3] fix(node): drop cached node id when the document is updated Chrome invalidates node ids on DOM.documentUpdated and hands the same ids out again for the new document, so a node found before a navigation or reload acted on a different element. Page now tracks a document version and Node re-requests its id from its remote object after it changes, which raises NodeNotFoundError for a stale node. Fixes #86 --- CHANGELOG.md | 3 +++ lib/ferrum/node.rb | 6 +++++- lib/ferrum/page.rb | 9 +++++++++ sig/ferrum/node.rbs | 1 + sig/ferrum/page.rbs | 2 ++ spec/node_spec.rb | 13 +++++++++++++ 6 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f545c544..1f2503b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ ### Fixed - Headers can be added on a remote browser whose `/json/version` has no `User-Agent`, such as browserless v2, instead of failing with `Invalid parameters` [rubycdp/cuprite#300] +- A `Ferrum::Node` found before a navigation or reload kept its cached CDP node id, which Chrome reuses for the new + document, so `focus`, `click`, `scroll_into_view` and friends silently hit another element. Node ids are now dropped + on `DOM.documentUpdated` and such nodes raise `Ferrum::NodeNotFoundError` [#86] ### Removed - `Browser::Process#default_user_agent`, `#browser_version`, `#protocol_version`, `#v8_version` and `#webkit_version`, diff --git a/lib/ferrum/node.rb b/lib/ferrum/node.rb index 00803e2a..b684d141 100644 --- a/lib/ferrum/node.rb +++ b/lib/ferrum/node.rb @@ -23,13 +23,17 @@ def initialize(frame, target_id, description, object_id: nil, node_id: nil) @tag_name = description["nodeName"].downcase @object_id = object_id @node_id = node_id + @document_version = @page.document_version end # Frontend node id is resolved lazily, on first actual need (focus, click, scroll_into_view, etc.) # We can try to subscribe to `DOM.childNodeRemoved` and `DOM.childNodeInserted` in the future # to keep track of nodes. def node_id - @node_id ||= begin + return @node_id if @node_id && @document_version == page.document_version + + @document_version = page.document_version + @node_id = begin id = page.command("DOM.requestNode", objectId: @object_id)["nodeId"] raise NodeNotFoundError, "node is not trackable" if id.zero? diff --git a/lib/ferrum/page.rb b/lib/ferrum/page.rb index f813114a..3ed20a3e 100644 --- a/lib/ferrum/page.rb +++ b/lib/ferrum/page.rb @@ -87,6 +87,12 @@ class Page # @return [Downloads] attr_reader :downloads + # Bumped every time Chrome reports `DOM.documentUpdated`, which + # invalidates every node id handed out before it. + # + # @return [Integer] + attr_reader :document_version + def initialize(client, context_id:, target_id:, proxy: nil) @client = client @context_id = context_id @@ -96,6 +102,7 @@ def initialize(client, context_id:, target_id:, proxy: nil) @frames = Concurrent::Map.new @main_frame = Frame.new(nil, self) @event = Utils::Event.new.tap(&:set) + @document_version = 0 self.proxy = proxy @mouse = Mouse.new(self) @@ -526,6 +533,8 @@ def subscribe network.subscribe downloads.subscribe + on("DOM.documentUpdated") { @document_version += 1 } + if @options.logger on("Runtime.consoleAPICalled") do |params| log_console_api(params) diff --git a/sig/ferrum/node.rbs b/sig/ferrum/node.rbs index 1e0fbb09..b47d5a28 100644 --- a/sig/ferrum/node.rbs +++ b/sig/ferrum/node.rbs @@ -6,6 +6,7 @@ module Ferrum @node_id: untyped @object_id: untyped + @document_version: ::Integer attr_reader page: untyped diff --git a/sig/ferrum/page.rbs b/sig/ferrum/page.rbs index eb7b4bad..51553300 100644 --- a/sig/ferrum/page.rbs +++ b/sig/ferrum/page.rbs @@ -23,6 +23,7 @@ module Ferrum attr_reader headers: Headers attr_reader cookies: Cookies attr_reader downloads: Downloads + attr_reader document_version: ::Integer attr_reader client: (Client | SessionClient) @client: (Client | SessionClient) @@ -40,6 +41,7 @@ module Ferrum @tracing: Page::Tracing @downloads: Downloads @referrer: String? + @document_version: ::Integer def initialize: ((Client | SessionClient) client, context_id: String?, target_id: String, ?proxy: { host: String, port: ::Integer, user: String?, password: String? }?) -> void diff --git a/spec/node_spec.rb b/spec/node_spec.rb index 3c86263d..ab38dd6c 100644 --- a/spec/node_spec.rb +++ b/spec/node_spec.rb @@ -47,6 +47,19 @@ expect { node.text }.to raise_error(Ferrum::NodeNotFoundError) end + it "raises an error instead of focusing a node from the new document after reload" do + browser.go_to("/form") + node = browser.at_css("#customer_email") + node.focus + + browser.refresh + browser.at_css("#customer_email").focus + browser.at_css("#customer_email").blur + + expect { node.focus }.to raise_error(Ferrum::NodeNotFoundError) + expect(browser.evaluate("document.activeElement.id")).not_to eq("customer_email") + end + it "raises an error if the element is not visible" do browser.go_to("/index") From 2b65aa9c1be64c5650ffd50dcfd941bb3e9b5e15 Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Fri, 16 Oct 2026 22:28:12 +0300 Subject: [PATCH 2/3] fix(node): pass a response hash to NodeNotFoundError so "node is not trackable" is its message --- lib/ferrum/node.rb | 4 ++-- spec/node_spec.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ferrum/node.rb b/lib/ferrum/node.rb index b684d141..24cd9246 100644 --- a/lib/ferrum/node.rb +++ b/lib/ferrum/node.rb @@ -35,11 +35,11 @@ def node_id @document_version = page.document_version @node_id = begin id = page.command("DOM.requestNode", objectId: @object_id)["nodeId"] - raise NodeNotFoundError, "node is not trackable" if id.zero? + raise NodeNotFoundError, { "message" => "node is not trackable" } if id.zero? id rescue NoExecutionContextError - raise NodeNotFoundError, "node is not trackable" + raise NodeNotFoundError, { "message" => "node is not trackable" } end end diff --git a/spec/node_spec.rb b/spec/node_spec.rb index ab38dd6c..20e8aa5d 100644 --- a/spec/node_spec.rb +++ b/spec/node_spec.rb @@ -56,7 +56,7 @@ browser.at_css("#customer_email").focus browser.at_css("#customer_email").blur - expect { node.focus }.to raise_error(Ferrum::NodeNotFoundError) + expect { node.focus }.to raise_error(Ferrum::NodeNotFoundError, "node is not trackable") expect(browser.evaluate("document.activeElement.id")).not_to eq("customer_email") end From 43644f3e32117ae73507e2282648d0cac9552df1 Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Fri, 16 Oct 2026 22:36:28 +0300 Subject: [PATCH 3/3] fix(node): keep the stale node id invalid after a failed lookup --- lib/ferrum/node.rb | 4 +++- spec/node_spec.rb | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/ferrum/node.rb b/lib/ferrum/node.rb index 24cd9246..65967184 100644 --- a/lib/ferrum/node.rb +++ b/lib/ferrum/node.rb @@ -32,7 +32,7 @@ def initialize(frame, target_id, description, object_id: nil, node_id: nil) def node_id return @node_id if @node_id && @document_version == page.document_version - @document_version = page.document_version + document_version = page.document_version @node_id = begin id = page.command("DOM.requestNode", objectId: @object_id)["nodeId"] raise NodeNotFoundError, { "message" => "node is not trackable" } if id.zero? @@ -41,6 +41,8 @@ def node_id rescue NoExecutionContextError raise NodeNotFoundError, { "message" => "node is not trackable" } end + @document_version = document_version + @node_id end # Whether this is an element node, as opposed to e.g. a text node. diff --git a/spec/node_spec.rb b/spec/node_spec.rb index 20e8aa5d..59a631b2 100644 --- a/spec/node_spec.rb +++ b/spec/node_spec.rb @@ -56,6 +56,7 @@ browser.at_css("#customer_email").focus browser.at_css("#customer_email").blur + expect { node.focus }.to raise_error(Ferrum::NodeNotFoundError, "node is not trackable") expect { node.focus }.to raise_error(Ferrum::NodeNotFoundError, "node is not trackable") expect(browser.evaluate("document.activeElement.id")).not_to eq("customer_email") end