fix(humanize): re-scroll after the settle wait so clicks land

An element scrolled into view can be pushed off screen again while we wait
for a reflowing page to settle. Nothing scrolled a second time, so the click
was dispatched outside the viewport and hit nothing, with no error raised.

Also stop the pointer-events check from turning a confirmed miss back into
'undetermined' when a late probe times out, which let that click through.

Measured on a page reflowing 10-25s: silent miss at ~32s before, clean click
now. Static pages and pages reflowing past the timeout are unchanged.
This commit is contained in:
CloakHQ committed 2026-08-05 16:29:07 +02:00
1 parent 3ceca98caf
commit e4d4c680c9
8 files changed
+172 -22

No files matched your search

+2
View File
@@ -8,6 +8,8 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
## [Unreleased]
- **[wrapper]** **`humanize=True` no longer misses clicks on pages that are still loading.** When a page kept reflowing after the element was scrolled into view, the wrapper waited for the position to settle but never scrolled again — by then the element could have been pushed off screen, so the click was dispatched at coordinates outside the viewport and landed on nothing. The action reported success and the click had simply not happened. The element is now re-scrolled into view after the settle wait, and the pointer-events check no longer downgrades an already-confirmed miss to "undetermined" when a later probe times out, so a click that cannot land raises instead of passing silently. Measured on a page reflowing for 10–25 seconds: previously a silent miss after ~32s, now a successful click. Pages that reflow longer than the call's timeout still raise, as before. Python, JavaScript, and .NET.
---
## [0.5.4] — 2026-08-04
+45 -6
View File
@@ -1068,7 +1068,14 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
is_input = _is_input_element(page, selector)
if not force and did_scroll:
ensure_stable(page, selector, timeout=_remaining_ms())
box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
# Waiting for the reflow to settle can push the element back out of
# view, and scrolling once before the wait is not enough: the click
# coords would land outside the viewport and hit nothing (#329).
box, cx, cy, _ = scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
)
cursor.x = cx
cursor.y = cy
target = click_target(box, is_input, call_cfg)
if not force:
check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
@@ -1099,7 +1106,14 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
is_input = _is_input_element(page, selector)
if not force and did_scroll:
ensure_stable(page, selector, timeout=_remaining_ms())
box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
# Waiting for the reflow to settle can push the element back out of
# view, and scrolling once before the wait is not enough: the click
# coords would land outside the viewport and hit nothing (#329).
box, cx, cy, _ = scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
)
cursor.x = cx
cursor.y = cy
target = click_target(box, is_input, call_cfg)
if not force:
check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
@@ -1132,7 +1146,14 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
cursor.y = cy
if not force and did_scroll:
ensure_stable(page, selector, timeout=_remaining_ms())
box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
# Waiting for the reflow to settle can push the element back out of
# view, and scrolling once before the wait is not enough: the click
# coords would land outside the viewport and hit nothing (#329).
box, cx, cy, _ = scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
)
cursor.x = cx
cursor.y = cy
target = click_target(box, False, call_cfg)
if not force:
check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
@@ -2109,7 +2130,13 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
is_input = await _async_is_input_element(page, selector)
if not force and did_scroll:
await async_ensure_stable(page, selector, timeout=_remaining_ms())
box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
# See the sync path: settling can move the element off-screen again,
# so re-scroll before computing click coords (#329).
box, cx, cy, _ = await async_scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
)
cursor.x = cx
cursor.y = cy
target = click_target(box, is_input, call_cfg)
if not force:
await async_check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
@@ -2140,7 +2167,13 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
is_input = await _async_is_input_element(page, selector)
if not force and did_scroll:
await async_ensure_stable(page, selector, timeout=_remaining_ms())
box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
# See the sync path: settling can move the element off-screen again,
# so re-scroll before computing click coords (#329).
box, cx, cy, _ = await async_scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
)
cursor.x = cx
cursor.y = cy
target = click_target(box, is_input, call_cfg)
if not force:
await async_check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
@@ -2173,7 +2206,13 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
cursor.y = cy
if not force and did_scroll:
await async_ensure_stable(page, selector, timeout=_remaining_ms())
box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
# See the sync path: settling can move the element off-screen again,
# so re-scroll before computing click coords (#329).
box, cx, cy, _ = await async_scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
)
cursor.x = cx
cursor.y = cy
target = click_target(box, False, call_cfg)
if not force:
await async_check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
+13 -3
View File
@@ -239,6 +239,7 @@ def check_pointer_events(
"""
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
last_miss: Optional[str] = None
while True:
try:
@@ -250,11 +251,20 @@ def check_pointer_events(
result = None
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
# (None) — failing closed would block legitimate clicks. But once a miss
# has actually been *determined*, a later indeterminate attempt must not
# launder it into a pass: near the deadline the bounding_box timeout is
# clamped to ~1ms and always throws, which used to turn a proven miss
# into "unknown" and let the click through silently (#329).
if result is None:
if last_miss is not None and time.monotonic() >= deadline:
raise ElementNotReceivingEventsError(selector, last_miss)
return
if result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
covering = result.get("covering", "unknown")
last_miss = covering
if time.monotonic() >= deadline:
raise ElementNotReceivingEventsError(selector, covering)
+13 -3
View File
@@ -140,6 +140,7 @@ async def async_check_pointer_events(
) -> None:
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
last_miss: Optional[str] = None
while True:
try:
@@ -151,11 +152,20 @@ async def async_check_pointer_events(
result = None
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
# (None) — failing closed would block legitimate clicks. But once a miss
# has actually been *determined*, a later indeterminate attempt must not
# launder it into a pass: near the deadline the bounding_box timeout is
# clamped to ~1ms and always throws, which used to turn a proven miss
# into "unknown" and let the click through silently (#329).
if result is None:
if last_miss is not None and time.monotonic() >= deadline:
raise ElementNotReceivingEventsError(selector, last_miss)
return
if result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
covering = result.get("covering", "unknown")
last_miss = covering
if time.monotonic() >= deadline:
raise ElementNotReceivingEventsError(selector, covering)
+27 -2
View File
@@ -285,6 +285,7 @@ public static class Actionability
{
double deadline = NowMs() + timeoutMs;
int attempt = 0;
string? lastMiss = null;
while (true)
{
@@ -312,10 +313,23 @@ public static class Actionability
// Proceed if the check confirms a hit, or if it could not be determined
// (null) - failing closed would block legitimate clicks.
if (result == null || result.Hit)
// An indeterminate result (null) fails open - failing closed would
// block legitimate clicks. But once a miss has been *determined*, a
// later indeterminate attempt must not launder it into a pass: near
// the deadline the BoundingBox timeout is clamped to ~1ms and always
// throws, which used to turn a proven miss into "unknown" and let the
// click through silently (#329).
if (result == null)
{
if (lastMiss != null && NowMs() >= deadline)
throw new ElementNotReceivingEventsError(selector, lastMiss);
return;
}
if (result.Hit)
return;
string covering = result.Covering ?? "unknown";
lastMiss = covering;
if (NowMs() >= deadline)
throw new ElementNotReceivingEventsError(selector, covering);
@@ -414,6 +428,7 @@ public static class Actionability
{
double deadline = NowMs() + timeoutMs;
int attempt = 0;
string? lastMiss = null;
while (true)
{
@@ -434,10 +449,20 @@ public static class Actionability
result = null;
}
if (result == null || result.Hit)
// See the locator variant: an indeterminate result fails open, but a
// miss that was already determined must not be laundered into a pass
// by a late attempt that merely errored (#329).
if (result == null)
{
if (lastMiss != null && NowMs() >= deadline)
throw new ElementNotReceivingEventsError("<ElementHandle>", lastMiss);
return;
}
if (result.Hit)
return;
string covering = result.Covering ?? "unknown";
lastMiss = covering;
if (NowMs() >= deadline)
throw new ElementNotReceivingEventsError("<ElementHandle>", covering);
+36 -4
View File
@@ -246,7 +246,15 @@ public sealed class HumanPage
if (!force && scroll.DidScroll)
{
await Actionability.EnsureStableAsync(_page, selector, RemainingMs(deadline)).ConfigureAwait(false);
box = await GetBoxAsync(selector, RemainingMs(deadline)).ConfigureAwait(false) ?? box;
// Waiting for the reflow to settle can push the element back out of
// view, and scrolling once before the wait is not enough: the click
// coords would land outside the viewport and hit nothing (#329).
var rescroll = await HumanScroll.HumanScrollIntoViewAsync(
_scrollPage, _rawMouse, () => GetBoxAsync(selector, RemainingMs(deadline)),
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
_cursor.X = rescroll.CursorX;
_cursor.Y = rescroll.CursorY;
box = rescroll.Box;
}
var target = HumanMouse.ClickTarget(box, isInput, callCfg);
if (!force)
@@ -286,7 +294,15 @@ public sealed class HumanPage
if (!force && scroll.DidScroll)
{
await Actionability.EnsureStableAsync(_page, selector, RemainingMs(deadline)).ConfigureAwait(false);
box = await GetBoxAsync(selector, RemainingMs(deadline)).ConfigureAwait(false) ?? box;
// Waiting for the reflow to settle can push the element back out of
// view, and scrolling once before the wait is not enough: the click
// coords would land outside the viewport and hit nothing (#329).
var rescroll = await HumanScroll.HumanScrollIntoViewAsync(
_scrollPage, _rawMouse, () => GetBoxAsync(selector, RemainingMs(deadline)),
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
_cursor.X = rescroll.CursorX;
_cursor.Y = rescroll.CursorY;
box = rescroll.Box;
}
var target = HumanMouse.ClickTarget(box, isInput, callCfg);
if (!force)
@@ -331,7 +347,15 @@ public sealed class HumanPage
if (!force && scroll.DidScroll)
{
await Actionability.EnsureStableAsync(_page, selector, RemainingMs(deadline)).ConfigureAwait(false);
box = await GetBoxAsync(selector, RemainingMs(deadline)).ConfigureAwait(false) ?? box;
// Waiting for the reflow to settle can push the element back out of
// view, and scrolling once before the wait is not enough: the click
// coords would land outside the viewport and hit nothing (#329).
var rescroll = await HumanScroll.HumanScrollIntoViewAsync(
_scrollPage, _rawMouse, () => GetBoxAsync(selector, RemainingMs(deadline)),
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
_cursor.X = rescroll.CursorX;
_cursor.Y = rescroll.CursorY;
box = rescroll.Box;
}
var target = HumanMouse.ClickTarget(box, false, callCfg);
if (!force)
@@ -552,7 +576,15 @@ public sealed class HumanPage
if (!force && scroll.DidScroll)
{
await Actionability.EnsureStableAsync(_page, selector, RemainingMs(deadline)).ConfigureAwait(false);
box = await GetBoxAsync(selector, RemainingMs(deadline)).ConfigureAwait(false) ?? box;
// Waiting for the reflow to settle can push the element back out of
// view, and scrolling once before the wait is not enough: the click
// coords would land outside the viewport and hit nothing (#329).
var rescroll = await HumanScroll.HumanScrollIntoViewAsync(
_scrollPage, _rawMouse, () => GetBoxAsync(selector, RemainingMs(deadline)),
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
_cursor.X = rescroll.CursorX;
_cursor.Y = rescroll.CursorY;
box = rescroll.Box;
}
var target = HumanMouse.ClickTarget(box, false, callCfg);
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, target.X, target.Y, callCfg).ConfigureAwait(false);
+15 -1
View File
@@ -231,6 +231,7 @@ export async function checkPointerEvents(
): Promise<void> {
const deadline = Date.now() + timeout;
let attempt = 0;
let lastMiss: string | null = null;
while (true) {
let result: any = null;
@@ -242,8 +243,21 @@ export async function checkPointerEvents(
result = null;
}
if (!result || result.hit) return;
// An indeterminate result fails open — failing closed would block legitimate
// clicks. But once a miss has been *determined*, a later indeterminate
// attempt must not launder it into a pass: near the deadline the boundingBox
// timeout is clamped to ~1ms and always throws, which used to turn a proven
// miss into "unknown" and let the click through silently (#329).
if (!result) {
if (lastMiss !== null && Date.now() >= deadline) {
throw new ElementNotReceivingEventsError(selector, lastMiss);
}
return;
}
if (result.hit) return;
const covering = (result as any)?.covering ?? 'unknown';
lastMiss = covering;
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering);
await backoffSleep(attempt);
+21 -3
View File
@@ -334,7 +334,13 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
let finalBox = box;
if (!force && didScroll) {
await ensureStable(page, selector, remainingMs());
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
// Waiting for the reflow to settle can push the element back out of view,
// and scrolling once before the wait is not enough: the click coords would
// land outside the viewport and hit nothing (#329).
const rescrolled = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
finalBox = rescrolled.box;
cursor.x = rescrolled.cursorX;
cursor.y = rescrolled.cursorY;
}
const target = clickTarget(finalBox, isInput, callCfg);
if (!force) {
@@ -366,7 +372,13 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
let finalBox = box;
if (!force && didScroll) {
await ensureStable(page, selector, remainingMs());
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
// Waiting for the reflow to settle can push the element back out of view,
// and scrolling once before the wait is not enough: the click coords would
// land outside the viewport and hit nothing (#329).
const rescrolled = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
finalBox = rescrolled.box;
cursor.x = rescrolled.cursorX;
cursor.y = rescrolled.cursorY;
}
const target = clickTarget(finalBox, isInput, callCfg);
if (!force) {
@@ -400,7 +412,13 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
let finalBox = box;
if (!force && didScroll) {
await ensureStable(page, selector, remainingMs());
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
// Waiting for the reflow to settle can push the element back out of view,
// and scrolling once before the wait is not enough: the click coords would
// land outside the viewport and hit nothing (#329).
const rescrolled = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
finalBox = rescrolled.box;
cursor.x = rescrolled.cursorX;
cursor.y = rescrolled.cursorY;
}
const target = clickTarget(finalBox, false, callCfg);
if (!force) {