DEV Community

Hoelee
Hoelee

Posted on Originally published at blog.hoelee.com

Why Your CDP Clicks Silently Fail: Smooth Scroll, Minimised Windows, and JS-Dispatched Events

Why Your CDP Clicks Silently Fail: Smooth Scroll, Minimised Windows, and JS-Dispatched Events

Originally published on my blog — read it there with the full context, code and updates.

If you drive a web app with the Chrome DevTools Protocol, you have met this failure: your script reports clicked @591,361, the element is there, the click landed — and nothing happens. No exception, no console error, no change on the page.

I spent an evening on exactly this while automating a marketplace's seller admin panel, and the three causes I found are worth writing down, because they are all invisible to the code that reports the click.

Cause 1: a stale rect, because the page scrolls smoothly

The obvious click helper looks like this:

r = self.ev("""(()=>{const e=%s; e.scrollIntoView({block:'center'});
  const b=e.getBoundingClientRect();
  return JSON.stringify({x:b.left+b.width/2, y:b.top+b.height/2});})()""")
self.click_xy(json.loads(r)["x"], json.loads(r)["y"])
Enter fullscreen mode Exit fullscreen mode

Scroll into view, read the element's rectangle, click its centre. Correct — unless the page has scroll-behavior: smooth (very common in modern UI kits). Then scrollIntoView() starts an animation, and getBoundingClientRect() in the same tick returns the position the element is about to leave. Your click goes to where the element was, lands on whatever is there, and the log tells you it clicked the coordinates you asked for.

The fix is two lines — turn smooth scrolling off, then re-read the rect:

self.ev("""(()=>{document.documentElement.style.scrollBehavior='auto';
  if(document.body) document.body.style.scrollBehavior='auto'; return 'ok';})()""")
moved = self.ev("""(()=>{const e=%s; if(!e) return 'no';
  e.scrollIntoView({block:'center', behavior:'instant'}); return 'ok';})()""" % sel)
time.sleep(0.35)                      # let layout settle
# then read the rect (and retry a couple of times if the element is not there yet)
Enter fullscreen mode Exit fullscreen mode

Two details matter. behavior:'instant' on scrollIntoView overrides the CSS; and the small wait before reading is what makes the rect trustworthy. Without the retry loop the helper still fails intermittently on elements that render a frame late — intermittent failures are the worst kind, because they make you doubt your own diagnosis.

Cause 2: the window is minimised, and coordinate clicks are dropped

After the fix, my click helper worked in a one-off probe and then never again inside the real script. Same selector, same coordinates, same page. The difference turned out to be visibility:

document.visibilityState → "hidden"
document.hasFocus()      → true
Enter fullscreen mode Exit fullscreen mode

CDP-synthesised mouse events at coordinates are unreliable when the target tab is not the active tab of a visible window — the browser may accept them, throttle them, or route them nowhere. Note that hasFocus() returns true even when the document is hidden, so it is not a useful check; document.hidden/visibilityState is.

Two fixes, and I use both:

def activate(self):                 # bring the tab to the front of its window
    self.cdp("Page.bringToFront")
    time.sleep(0.2)

def click_js(self, sel):            # click by dispatching events from inside the page
    return self.ev("""(()=>{const e=%s; if(!e) return 'no';
      ['mousedown','mouseup','click'].forEach(k=>
        e.dispatchEvent(new MouseEvent(k,{bubbles:true,cancelable:true,view:window})));
      return 'js-clicked';})()""" % sel)
Enter fullscreen mode Exit fullscreen mode

Page.bringToFront fixes the common case. But the JS-dispatched click is the one that never fails: it does not depend on the window being visible, and React's root-level event listener receives it exactly as it receives a real click, because the events bubble to the same root. A date-picker input that opened maybe one time in five with coordinate clicks opens every time this way.

The trade-off to know: dispatching events skips hit-testing, so it will also “click” an element a user could not reach (covered, scrolled off, zero-opacity). That is a feature here — and a trap if you use it to paper over a layout bug — so I keep coordinate clicking for anything visual and reach for click_js when the widget is a custom control.

Cause 3: the page received the click and rejected it

This is the one that wasted the most time, because the symptom is identical to causes 1 and 2: click fires, nothing changes.

Distinguishing them takes one injected listener:

window.__ev = [];
['mousedown','mouseup','click'].forEach(k =>
  document.addEventListener(k, e => window.__ev.push(k + ':' + e.target.tagName), true));
Enter fullscreen mode Exit fullscreen mode
  • Events recorded → the input path works. The problem is in the app (cause 3), or in how you read the result (see below).
  • Nothing recorded → causes 1 or 2.

With the listener armed I clicked Confirm and the buffer filled up — so the click was fine, and the app was saying no. The reason was in text I had not thought to look at:

“Please create a new shop welcome voucher after the existing one is expired.”

The form refuses a second voucher of that type. No field-level red text, no toast in the first 500 ms, the URL unchanged. The rejection was real and the click was innocent.

Two more tools for this class of bug:

document.elementFromPoint(x, y)        // what is actually on top at that coordinate?
document.querySelectorAll('button').length   // how many match your selector?
Enter fullscreen mode Exit fullscreen mode

The second one caught a subtle one: the portal leaves a hidden Confirm button in the DOM from the collapsed date-panel, so .pop() returned the invisible duplicate and the click went nowhere visible. Filter by visibility and size:

"[...document.querySelectorAll('button')].filter(b=>/^Confirm$/.test(b.innerText.trim())"
" && b.offsetParent!==null && b.getBoundingClientRect().width>0).pop()"
Enter fullscreen mode Exit fullscreen mode

And the fourth failure I only found by re-reading

There is a class of “failure” that is not one: the write succeeded and your read is stale. I concluded a create had been silently refused because the list page did not show it immediately, clicked again, and later discovered the voucher had existed the whole time — the list just lagged about ten minutes. The lesson generalises: after a write, the only trustworthy verdict is a read-back after a real waiting period, and “no visible change” means unknown, not failed.

The checklist

  1. Turn off smooth scrolling; scroll with behavior:'instant'; wait, then read the rect.
  2. Call Page.bringToFront before clicking; treat document.hidden as the signal, not hasFocus().
  3. For custom widgets, dispatch mousedown/mouseup/click from JS rather than clicking coordinates.
  4. Arm an event listener to prove whether input arrived before you blame the app.
  5. Count your selectors — hidden duplicates are common in component libraries.
  6. After a write, wait and re-read; never re-click a submit you have not verified.

None of this is exotic, but every item is invisible in a log line that says clicked @591,361. If you are building automation against an admin panel you do not control, the click is the least reliable part of the pipeline — and the one most likely to be blamed last.

I build and debug this kind of browser automation for a living — self-hosted services, Docker and Traefik stacks, and the odd scraped-but-bot-walled marketplace. If you have a workflow that keeps breaking because a UI has other ideas, tell me about it: WhatsApp · me@hoelee.com · hoelee.com.


I write about self-hosted infrastructure, DevOps and the failures behind them at blog.hoelee.com.

Top comments (2)

Collapse
 
autenai profile image
Auten •

Cause 3 is the one I see most, and two small changes made it much easier to catch for us.

Instead of the fixed 0.35 s wait, wait for the scroll to finish. Chrome fires scrollend (114+), or you can read the rect on two consecutive requestAnimationFrame ticks and only click once they match. The wait is then exactly as long as the page needs, and the intermittent late-render case goes away without a retry loop.

For "the app said no", take the visible text before the click and again about a second after, and diff them. The voucher message would have shown up as a new line in the diff, no matter whether the app put it in a toast, a banner or plain text under the form. Selectors for error elements miss half of these, the text diff doesn't.

Your fourth case is the one I'd put first in the checklist. "No visible change" meaning unknown, not failed, is what stops the double submit.

Collapse
 
xushoulin profile image
xushoulin •

Curious whether you'd rather keep the transform layer free of that logic and
fix it on the server instead.