Skip to content

Input.dispatchMouseEvent fires click before dynamic-import microtasks drain (w/ element.click() via execute_script workaround) #584

@agrberg

Description

@agrberg

Describe the bug

Node#click can fire the browser's click event before JavaScript microtasks from dynamic import() calls have drained. This means event listeners registered asynchronously like Stimulus action handlers loaded via eagerLoadControllersFrom may not yet be connected when the click fires. This causes the handler to silently not run. The test passes on a fast local machine but fails consistently on slower CI.

Replacing the Capybara click with page.execute_script("arguments[0].click()", element) reliably fixes this, because Runtime.evaluate only executes after all pending microtasks drain.

To Reproduce

Given a Stimulus controller loaded via dynamic import:

// app/javascript/controllers/example_controller.js
export default class extends Controller {
  copy() { window.__recorded = true }
}
<div data-controller="example">
  <button id="btn" data-action="click->example#copy">Click me</button>
</div>
# Fails on slower CI (Ubuntu), passes locally (macOS):
find("#btn").click
expect(page.evaluate_script("window.__recorded")).to be true

# Always passes:
page.execute_script("arguments[0].click()", find("#btn"))
expect(page.evaluate_script("window.__recorded")).to be true

The failure is timing-sensitive. An action wired to focus-> on a sibling element passes in both environments as focus fires synchronously inside Chrome's mousedown processing before the CDP ack returns, so it always fires into a connected listener. A click-> action does not have this guarantee.

Expected behavior

element.click should fire into the same listener state as page.execute_script("arguments[0].click()", element). If Ferrum sends Input.dispatchMouseEvent and returns, the caller should be able to assume JavaScript event handlers that were scheduled before the call have had a chance to run.

Desktop (please complete the following information):

  • OS: Fails on Linux (Ubuntu 24.04 CI), passes on macOS 15 (local)
  • Browser: Chrome 124+ headless
  • Version: Ferrum 0.15, Cuprite 0.17

Additional context

Ferrum's Node#click dispatches three CDP Input.dispatchMouseEvent commands: mouseMovedmousePressedmouseReleased. Chrome generates the JavaScript click event from the combined mousedown/mouseup and fires it in the renderer's event queue. Chrome returns the CDP ack for each of these commands before guaranteeing that all pending JavaScript microtasks have run.

When using Stimulus with eagerLoadControllersFrom, each controller is loaded via a dynamic import() and the resulting application.register() call happens in a Promise .then() callback, i.e. a microtask. On a fast machine these microtasks drain before the test interaction arrives. On a slower CI runner, the click event fires while those microtasks are still pending, meaning Stimulus hasn't called application.register() yet and no listener is registered.

Runtime.evaluate (used by execute_script) runs on Chrome's main thread only when it's free (i.e. after all pending microtasks have drained) so element.click() invoked that way fires into a fully-connected listener tree.

A possible fix would be for Ferrum to follow the Input.dispatchMouseEvent sequence with a no-op Runtime.evaluate call, which would cause Chrome to drain its microtask queue before returning. Alternatively, documenting this behavior gap so users know to prefer execute_script for interactions with async-loaded JS would also be valuable.

Workaround

# spec/support/cuprite.rb
module CupriteHelpers
  # Input.dispatchMouseEvent can fire before dynamic-import microtasks drain.
  # Runtime.evaluate (execute_script) runs only after microtasks settle.
  def js_click(element)
    page.execute_script("arguments[0].click()", element)
  end
end

RSpec.configure do |config|
  config.include CupriteHelpers, type: :system
end

Related: teamcapybara/capybara#1211, teamcapybara/capybara#777, hotwired/stimulus-rails#35

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions