Skip to content

mouseClick() ignores clipping by scrollable ancestors: clicks land on the wrong element, or outside the viewport, without raising #79

Description

@nathanfallet

Affected versions

dev.kdriver:core 0.5.11 (latest on Maven Central) and current main (be37a42).
DefaultElement.kt is byte-identical between the two, so the analysis and the
reproduction apply unchanged to both. Verified empirically on both (a
publishToMavenLocal build of main gives identical results).

Summary

Element.mouseClick() derives its click point from getBoundingClientRect(), which by
construction ignores clipping by ancestors with overflow: auto/scroll. An element
scrolled out of the visible band of such an ancestor still reports a non-zero rect at its
layout position, so the computed point is somewhere the element is not painted.

This produces two distinct silent failures, depending on whether the rect centre still
falls inside the window viewport:

needsScroll what happens outcome
Mode 1 false click dispatched at a point painted by another element that element receives a fully trusted click
Mode 2 true scroll branch runs but scrolls the window, which cannot move an inner pane; visibility is never re-checked click dispatched outside the viewport, hit-test resolves to <html> / null

In both cases mouseClick() returns normally and nothing is thrown, so a caller has no way
to know the intended element was never activated. Mode 1 is the more damaging of the two:
the event carries isTrusted: true, so the page reacts exactly as if the user had
genuinely clicked that other element.

This is easy to hit on any chat-like layout — a message pane pinned to the bottom, where a
button inside a bubble gets pushed out of the visible band as later messages arrive.

Root cause

DefaultElement.mouseClick() (core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt:328)
computes the point and the scroll requirement in a single JS call:

const rect = this.getBoundingClientRect();                                  // :342
if (rect.width === 0 || rect.height === 0) return null;                     // :343

const viewportHeight = window.innerHeight;                                  // :346
const viewportWidth  = window.innerWidth;                                   // :347
const elementCenterY = rect.top  + rect.height / 2;                         // :348
const elementCenterX = rect.left + rect.width  / 2;                         // :349

const needsScrollY = elementCenterY < 0 || elementCenterY > viewportHeight;  // :352
const needsScrollX = elementCenterX < 0 || elementCenterX > viewportWidth;   // :353

Three gaps:

  1. Visibility is tested against the window, never against clipping ancestors.
    getBoundingClientRect() does not account for ancestor overflow, so an element fully
    clipped out of its pane keeps a rect whose centre can sit anywhere in
    [0, innerHeight]. needsScroll is then false and the branch at :380-383 never
    runs — this is Mode 1.

  2. When the scroll branch does run, it cannot fix a clipped element. It calls
    tab.scrollTo(...) (DefaultTab.kt:227), a window-level scroll; an element clipped by
    an inner pane needs that pane scrolled. And getStableCoordinates() at :387 only
    waits for the position to stabilise — it never re-evaluates whether the element became
    visible. So the original, out-of-viewport point is dispatched anyway. This is Mode 2.

  3. There is no occlusion check at all. The only guard is the zero-size test at :343,
    and a clipped element keeps a non-zero rect. Nothing verifies that the computed point
    resolves to the element: document.elementFromPoint, checkVisibility,
    IntersectionObserver and offsetParent do not appear anywhere in commonMain or
    jvmMain.

The existing stability machinery is not implicated: the two-stable-frames wait and the
post-trajectory re-check (5px threshold, with re-dispatch) work as intended. They protect
against layout races, not against clipping.

Reproduction

Deterministic, 5/5 runs. Window forced to 1200x800 so window.innerHeight == 713. Pane
at top:300 height:200 (visible band y 300..500), target as the pane's first child.

<!doctype html>
<style>
  html, body { margin: 0; padding: 0 } * { box-sizing: border-box }
  /* a real interactive element occupying y 40..80 — nothing to do with the target */
  #decoy  { position:absolute; left:100px; top:40px;  width:400px; height:40px; margin:0 }
  #pane   { position:absolute; left:100px; top:300px; width:400px; height:200px;
            overflow:auto; border:0; padding:0 }
  #target { display:block; width:400px; height:40px; margin:0; border:0; padding:0 }
  #spacer { height:1200px }
</style>

<input id="decoy" value="DECOY (must not be clicked)">
<div id="pane">
  <button id="target" type="button">TARGET</button>
  <div id="spacer"></div>
</div>

<script>
  const t = document.getElementById('target'), d = document.getElementById('decoy');
  t.addEventListener('click', () => document.title = 'HIT=TARGET');
  d.addEventListener('click', () => document.title = 'HIT=DECOY');

  // Mode 1: rect.top = 300 - 260 = 40, centre y = 60, inside [0, 713] -> needsScroll false
  window.__clip    = () => document.getElementById('pane').scrollTop = 260;
  // Mode 2: rect.top = 300 - 400 = -100, centre y = -80 -> needsScroll true
  window.__clipFar = () => document.getElementById('pane').scrollTop = 400;
  window.__show    = () => document.getElementById('pane').scrollTop = 0;

  window.__probe = () => {
    const r = t.getBoundingClientRect();
    const cx = r.left + r.width/2, cy = r.top + r.height/2;
    const at = document.elementFromPoint(cx, cy);
    return JSON.stringify({ innerHeight: innerHeight, rect: r, centerX: cx, centerY: cy,
      needsScrollY: (cy < 0 || cy > innerHeight),
      paintedAtOwnCentre: at === t, elementThere: at ? (at.id || at.tagName) : null });
  };
  document.title = 'HIT=none';
</script>
import dev.kdriver.core.browser.createBrowser
import dev.kdriver.core.tab.ReadyState
import dev.kdriver.core.tab.evaluate

val browser = createBrowser(this) {
    browserArgs = listOf("--window-size=1200,800", "--hide-scrollbars")
}
val tab = browser.get(reproUrl)
tab.waitForReadyState(ReadyState.COMPLETE)

// Mode 1 — clipped, rect centre still inside the window viewport
tab.evaluate<Int>("window.__clip()")
tab.select("#target").mouseClick()
tab.evaluate<String>("document.title")   // => "HIT=DECOY"

// Negative control — same clipped element, JS click path
tab.evaluate<Int>("window.__clip()")
tab.select("#target").click()
tab.evaluate<String>("document.title")   // => "HIT=TARGET"

// Positive control — element visible inside the pane
tab.evaluate<Int>("window.__show()")
tab.select("#target").mouseClick()
tab.evaluate<String>("document.title")   // => "HIT=TARGET"

// Mode 2 — clipped far enough that the rect centre goes negative
tab.evaluate<Int>("window.__clipFar()")
tab.select("#target").mouseClick()
tab.evaluate<String>("document.title")   // => "HIT=none"

println(tab.evaluate<String>("window.__probe()"))

Measured results

case state dispatched at element hit result
A clipped, centre in viewport (303, 61) decoy ❌ Mode 1
B same, via click() target
C visible in pane (303, 317) target
D clipped, centre negative (296, −81) <html> ❌ Mode 2

In case A the probe reports rect = {top: 40, left: 100, width: 400, height: 40},
centerY = 60, needsScrollY = false, paintedAtOwnCentre = false, and
document.elementFromPoint(300, 60) returns decoy.

kdriver's own debug log states it is clicking the button:

DEBUG Element - Mouse click at location 303.21, 61.50 (center: 300.0, 60.0, ...)
  where <button id="target" type="button">TARGET</button> is located

while the page observes:

{"type":"mousedown","clientX":303,"clientY":61,
 "hitTarget":"decoy","elementFromPointThere":"decoy","isTrusted":true}

In case D the scroll branch is entered and logs Scrolling by (0.0, -436.5), but the page
has nothing to scroll, the pane stays at scrollTop = 400, and the click is still
dispatched at y = -81. elementFromPoint returns null.

Suggested fix

After computing (x, y), verify that document.elementFromPoint(x, y) is the element or
one of its descendants. If it is not:

  1. call scrollIntoView({ block: 'center' }) on the element — unlike a window scroll, this
    walks the chain of scrollable ancestors — then recompute the point and re-check;
  2. if the check still fails, raise instead of dispatching. This is the part that matters
    most for automation: a caller can react to an exception, it cannot react to a click that
    quietly missed.

The current needsScroll condition is structurally unable to detect ancestor clipping, so
replacing it with a hit-test is the substantive change; the scroll is the remedy, not the
detector.

It would also help to make the computed coordinates visible at default log level, or to
include the resolved elementFromPoint target when the check trips — this class of failure
is currently hard to diagnose because the coordinates are only logged at DEBUG (:403).


Minor packaging note found while building the reproduction: 0.5.11 is published with
Kotlin 2.3.0 metadata, so consumers must be on the 2.3.0 Kotlin plugin — an older
plugin fails with an internal compiler error rather than a readable version-mismatch
message.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions