Skip to content

feat(runtime): add a JavaScript evaluation API with named arguments, keeping the old one with warnings - #673

Merged
route merged 1 commit into
mainfrom
feat/js-api
Oct 25, 2026
Merged

route merged 1 commit into
mainfrom
feat/js-api

Conversation

@route

@route route commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Redesigns the JavaScript evaluation API around named arguments. It lands in one go without breaking anyone's scripts: old-style calls keep their old behavior and warn, and FERRUM_JS_API=new runs the new behavior ahead of time.

Release Old-style scripts
0.20 (this PR) run as before and warn; FERRUM_JS_API=new runs them the new way
0.21 new way only: Runtime::Legacy, evaluate_async, evaluate_func and evaluate_on removed, no switch back

The old code lives unchanged, apart from warnings, in Frame::Runtime::Legacy, and the new pipeline is separate. So 0.21 deletes legacy.rb and utils/deprecate.rb. The switch is an environment variable rather than a browser option, so it's clearly temporary and removing it breaks no code.

The new API

page.evaluate("a + b", a: 1, b: 2)                              # named arguments become parameters
page.evaluate("const x = a * 2\nx + 1", a: 1)                   # every statement runs, the last one's value is returned
page.evaluate("function(a, b) { return a - b }", 3, 1)          # a declaration is used as written, with positional arguments
page.evaluate("await fetch(url).then(r => r.status)", url: "/") # await works, timeout: bounds it
list = page.evaluate_handle("document.querySelectorAll('li')") # => Ferrum::RemoteObject
node.execute("this.value = text", text: "hi")                   # Node#execute, Node#evaluate_handle
  • Positional arguments keep working in every release, arguments[0] and up, but can't be mixed with named ones in the new way.
  • Returned promises are awaited, zone.js and Bluebird ones included. timeout: (seconds, the page timeout by default) is enforced in Ruby by waiting on the CDP response, so pages that replace Promise or mock timers can't break it. It raises Ferrum::ScriptTimeoutError, which inherits from Ferrum::TimeoutError. A navigation while a promise is awaited doesn't run the script again.
  • A SyntaxError or a thrown error points at the script's own lines, and objects shared by several references are converted once. A node inside an Array or Hash argument raises ArgumentError, pass each one as its own argument.
  • Node#property and Node#attribute pass the name as an argument, so a name with a quote no longer breaks the script, and property returns a Ferrum::Node for a property holding one.
  • Scripts are read by a small scanner, Frame::Script, that skips strings, template literals, regular expressions and comments.

Adapting your code

  1. Upgrade to 0.20 and run your suite. Each warning names the line in your code (the first caller outside installed gems, so Capybara users see their execute_script call), the script, and a rewrite. Each warning prints once per call site.
  2. Fix what it reports. Rewritten calls already take the new path in 0.20, so you can fix them one at a time.
  3. Run your suite with FERRUM_JS_API=new. Warnings only cover the scripts and results your suite runs into, and 0.21 has no switch back, so this is the real check. FERRUM_DEPRECATION_WARNINGS=raise turns warnings into errors.
  4. Upgrade to 0.21.

Most code needs nothing: plain expressions and positional arguments, page.evaluate("document.title") or page.evaluate("arguments[0] + 1", 1), behave the same in every release.

Script < 0.21 >= 0.21 Notes
evaluate("arguments[0].foo", foo: 1) 1 nil keywords are named parameters now, write evaluate("foo", foo: 1)
evaluate("[arguments[0], arguments[1]]", 1, foo: 2) [1, { "foo" => 2 }] ArgumentError positional and keyword arguments can't be mixed, pass { foo: 2 } in braces
evaluate("a(); b") a()'s value, b never runs b's value every statement runs, the last one's value is returned
evaluate("\n window.ran = true\n") nil, nothing runs true the line break no longer ends the return, check it's safe to run
evaluate("Promise.resolve(1)") {} 1 promises are awaited, void keeps one from being awaited
node.evaluate("this.parentNode") {} Ferrum::Node
node.evaluate("new Date(0)") {} "1970-01-01T00:00:00.000Z"
evaluate_async("arguments[0](1)", 5) 1 NoMethodError evaluate("await …", timeout: 5)
evaluate_func("function(a) { return a }", 1) 1 NoMethodError evaluate("function(a) { return a }", 1), with on: use node.evaluate
evaluate_on(node: node, expression: "this.id") "main" NoMethodError node.evaluate("this.id")
evaluate_on(node: link, expression: "this.click()", wait: 0.1) waits for the new page NoMethodError page.wait_for_navigation!(0.1) { link.evaluate("this.click()") } (#679)

evaluate_async callbacks become await:

# before
page.evaluate_async(%(navigator.serviceWorker.register("/sw.js").then(arguments[0])), 5)
# after
page.evaluate(%(await navigator.serviceWorker.register("/sw.js")), timeout: 5)

Besides being simpler, a rejected promise now raises its real error right away, where evaluate_async timed out because nothing called the callback.

Libraries built on Ferrum should pass named arguments or a function declaration, which run the new way in both 0.20 and 0.21.

How old-style calls are detected

A call takes the new path, even in legacy mode, if it uses something only the new API has: named arguments that the script doesn't read through arguments, a function declaration, await, or a new method. So Ferrum's own calls behave the same in both modes. Anything else runs the code from main unchanged, and is checked for what the new way does differently:

Detected How
Keywords read through arguments, or mixed with positional arguments the arguments given
Several statements, split by ; or by line breaks scanner
A line break before the script scanner
A returned promise result subtype promise
Nodes and dates from Node#evaluate result subtype, or Runtime.getProperties for arrays

The script runs once, and detection never runs code on its result, so getters and proxy traps don't fire. Promises that Chrome doesn't report as promise, zone.js, Bluebird or any object with a then method, aren't warned about, though the new way awaits them.

Verified

Legacy mode runs main's code, deep serialization (#677) and thrown-value handling (#675) included, and main's runtime specs still pass against it. spec/frame/runtime/legacy_spec.rb pins those results and warnings, and spec/frame/runtime_spec.rb covers FERRUM_JS_API=new.

Replaces #617, #659, #660 and #661.

@decaffeinatedio

Copy link
Copy Markdown

This syntax is much more intuitive!

One suggestion: I think that semicolons get dropped, so e.g. page.execute("const end = Date.now() + 10; while (Date.now() < end);") is valid but would begin to throw a syntax error in the new API.

@route

route commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

This syntax is much more intuitive!

One suggestion: I think that semicolons get dropped, so e.g. page.execute("const end = Date.now() + 10; while (Date.now() < end);") is valid but would begin to throw a syntax error in the new API.

nice find! thank you!

…keeping the old one with warnings

`#evaluate`, `#evaluate_async`, `#execute`, `#evaluate_func` and `#evaluate_on` had four argument conventions and three
return semantics between them, and scripts reached their arguments through `arguments[0]`. This adds one convention for
all of them, without changing what any existing call returns by default.

New: keyword arguments become the script's parameters, `page.evaluate("a + b", a: 1, b: 2)`, and a script returns the
value of its last statement. A script may be a function or arrow declaration, used as written with positional
arguments, and may `await`. Returned promises are awaited, including zone.js and Bluebird ones, bounded by `timeout:`
and raising `ScriptTimeoutError`, now a `TimeoutError`, and a navigation while awaiting doesn't run the script again.
Errors point at the script's own lines, objects shared by several references are converted once, and a node inside an
Array or Hash argument raises. Added `#evaluate_handle`, returning a `Ferrum::RemoteObject`, and
`Node#execute`/`Node#evaluate_handle`. Scripts are read by a small scanner, `Frame::Script`, that skips strings,
template literals, regular expressions and comments.

Compatible: by default, a call that doesn't use any of that runs exactly as before, through `Runtime::Legacy`, which
also keeps `#evaluate_async`, `#evaluate_func` and `#evaluate_on`. Positional arguments keep working. What the new way
does differently is detected and warned about with a rewrite, pointing at the first caller outside installed gems:
keywords read through `arguments`, mixed positional and keyword arguments, scripts of several statements, promises
that will be awaited, and nodes `Node#evaluate` will resolve.
`FERRUM_DEPRECATION_WARNINGS=raise` turns the warnings into errors, `0` silences them.

`FERRUM_JS_API=new` runs every call the new way. 0.21 removes `Runtime::Legacy` and the deprecated methods.
@route
route merged commit fb7d858 into main Oct 25, 2026
6 checks passed
@route
route deleted the feat/js-api branch October 25, 2026 20:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants