From 1a8f42da3a419016a5d23d082be040692ad09a30 Mon Sep 17 00:00:00 2001 From: otsch Date: Fri, 19 Jun 2026 22:24:16 +0200 Subject: [PATCH] Fix scroll timeout by waiting for the scroll position to settle The scroll timeout addressed in #678 happens when the page layout changes between the moment `Mouse::scroll()` computes the target scroll position and the moment it verifies that position. The verify loop waits for the page to reach an exact, pre-computed target. Once the layout shifts mid-scroll, that target can become unreachable and the loop spins until it fails with OperationTimedOut. While digging deeper into this I (assisted by AI I have to admit ;)) found several distinct ways to reproduce the issue, each added as a test case: - scrollShrink.html - the scrollable area shrinks right after the scroll starts. - scrollLock.html - an overlay/modal locks scrolling (body becomes position:fixed), so the maximum scroll distance drops to 0. - infiniteScroll.html - a lazy-loaded image above the current position finishes loading mid-scroll and shifts the layout (the typical infinite-scroll case). The fix proposed in #678 (retry once when the recomputed distances changed) only covers the cases where the maximum scroll distance changes: it recovers the shrink and the lock case, but not the layout-shift case. There the page grows, the recomputed distance stays the same, and it still times out - so that fix unfortunately does not solve every variant of the issue. This change takes a different, more robust approach. Instead of waiting for an exact, pre-computed target, it observes the actual scroll position and treats the scroll as done once the position stops changing ("settles"). It reacts to what really happens on the page, so it handles a shrinking scrollable area, a scroll lock and a layout shift alike - without a retry, without a fixed target, and noticeably faster (no fixed timeout penalty on every occurrence). Alternative approach to #678. --- src/Input/Mouse.php | 151 ++++++++++++++---- tests/MouseApiTest.php | 74 +++++++++ .../resources/static-web/infiniteScroll.html | 40 +++++ tests/resources/static-web/scrollLock.html | 65 ++++++++ tests/resources/static-web/scrollShrink.html | 30 ++++ 5 files changed, 329 insertions(+), 31 deletions(-) create mode 100644 tests/resources/static-web/infiniteScroll.html create mode 100644 tests/resources/static-web/scrollLock.html create mode 100644 tests/resources/static-web/scrollShrink.html diff --git a/src/Input/Mouse.php b/src/Input/Mouse.php index 5aae49ff..915e6996 100644 --- a/src/Input/Mouse.php +++ b/src/Input/Mouse.php @@ -141,6 +141,7 @@ public function click(?array $options = null) * * @throws \HeadlessChromium\Exception\CommunicationException * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut * * @return $this */ @@ -156,6 +157,7 @@ public function scrollUp(int $distance) * * @throws \HeadlessChromium\Exception\CommunicationException * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut * * @return $this */ @@ -180,38 +182,79 @@ private function scroll(int $distanceY, int $distanceX = 0): self { $this->page->assertNotClosed(); + // make sure the mouse is on the screen + $this->move($this->x, $this->y); + + $distances = $this->getScrollDistances($distanceY, $distanceX); // Calculated distances to scroll. + $startPos = $this->getCurrentScrollPosition(); // Remember positions before scrolling started. + + if ($distances['x'] === 0 && $distances['y'] === 0) { + return $this; + } + + $this->sendScrollMessage($distances); + + // Wait until the scroll position settles (i.e. it stops changing). + Utils::tryWithTimeout( + 10_000_000, + $this->waitForScrollToSettle($distanceY, $distanceX, $startPos['x'], $startPos['y']) + ); + + // set new position after move + $endPos = $this->getCurrentScrollPosition(); + $this->x += $endPos['x'] - $startPos['x']; + $this->y += $endPos['y'] - $startPos['y']; + + return $this; + } + + /** + * Get the distances to actually scroll on each axis, clamped to the page's + * current scroll boundaries. + * + * @throws \HeadlessChromium\Exception\OperationTimedOut + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\CommunicationException\ResponseHasError + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * + * @return array{ x: int, y: int } + */ + private function getScrollDistances(int $distanceY, int $distanceX = 0): array + { $scrollableArea = $this->page->getLayoutMetrics()->getCssContentSize(); $visibleArea = $this->page->getLayoutMetrics()->getCssVisualViewport(); $maximumX = $scrollableArea['width'] - $visibleArea['clientWidth']; $maximumY = $scrollableArea['height'] - $visibleArea['clientHeight']; - $distanceX = $this->getMaximumDistance($distanceX, $visibleArea['pageX'], $maximumX); - $distanceY = $this->getMaximumDistance($distanceY, $visibleArea['pageY'], $maximumY); + $distanceX = $this->getMaximumDistance($distanceX, (int) $visibleArea['pageX'], (int) $maximumX); + $distanceY = $this->getMaximumDistance($distanceY, (int) $visibleArea['pageY'], (int) $maximumY); - $targetX = $visibleArea['pageX'] + $distanceX; - $targetY = $visibleArea['pageY'] + $distanceY; + return ['x' => $distanceX, 'y' => $distanceY]; + } - // make sure the mouse is on the screen - $this->move($this->x, $this->y); + private function getCurrentScrollPosition(): array + { + $viewport = $this->page->getLayoutMetrics()->getCssVisualViewport(); - // scroll + return ['x' => (int) $viewport['pageX'], 'y' => (int) $viewport['pageY']]; + } + + /** + * @param array{ x: int, y: int } $distances + * + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\NoResponseAvailable + */ + private function sendScrollMessage(array $distances): void + { $this->page->getSession()->sendMessageSync(new Message('Input.dispatchMouseEvent', [ 'type' => 'mouseWheel', 'x' => $this->x, 'y' => $this->y, - 'deltaX' => $distanceX, - 'deltaY' => $distanceY, + 'deltaX' => $distances['x'], + 'deltaY' => $distances['y'], ])); - - // wait until the scroll is done - Utils::tryWithTimeout(30000 * 1000, $this->waitForScroll($targetX, $targetY)); - - // set new position after move - $this->x += $distanceX; - $this->y += $distanceY; - - return $this; } /** @@ -351,32 +394,78 @@ private function getMaximumDistance(int $distance, int $current, int $maximum): } /** - * Wait for the browser to process the scroll command. + * Wait for the scroll position to settle (stop changing). * - * Return the number of microseconds to wait before trying again or true in case of success. + * Rather than waiting for an exact target position, this polls the live + * scroll position and returns once it has been stable for a few consecutive + * reads. A settled position is only accepted once we have either observed + * actual movement or reached a scroll boundary - this guards against + * accepting the start position before the scroll has even begun + * (the start-of-scroll race). * - * @see \HeadlessChromium\Utils::tryWithTimeout + * Yields the number of microseconds to wait before trying again or + * returns in case of success. * - * @param int $targetX - * @param int $targetY + * @see Utils::tryWithTimeout * + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\CommunicationException\ResponseHasError + * @throws \HeadlessChromium\Exception\NoResponseAvailable * @throws \HeadlessChromium\Exception\OperationTimedOut - * - * @return bool|\Generator */ - private function waitForScroll(int $targetX, int $targetY) + private function waitForScrollToSettle(int $distanceY, int $distanceX, int $startX, int $startY): \Generator { - while (true) { - $visibleArea = $this->page->getLayoutMetrics()->getCssVisualViewport(); + $requiredStableReads = 5; + $stableReads = 0; + $lastX = $lastY = null; + $moved = false; - if ($visibleArea['pageX'] === $targetX && $visibleArea['pageY'] === $targetY) { - return true; + while (true) { + $pos = $this->getCurrentScrollPosition(); + $moved = $moved || ($pos['x'] !== $startX || $pos['y'] !== $startY); + + if ($pos['x'] === $lastX && $pos['y'] === $lastY) { + ++$stableReads; + + if ( + $stableReads >= $requiredStableReads && + ($moved || $this->isAtScrollBoundary($distanceY, $distanceX, $pos['x'], $pos['y'])) + ) { + return; + } + } else { + $stableReads = 0; + $lastX = $pos['x']; + $lastY = $pos['y']; } - yield 1000; + yield 16_000; // Time between two frames at 60Hz. } } + /** + * Whether the page can no longer be scrolled further in the requested + * direction (so a non-moving position is a legitimate end state). + * + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\CommunicationException\ResponseHasError + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut + */ + private function isAtScrollBoundary(int $distanceY, int $distanceX, int $x, int $y): bool + { + $scrollableArea = $this->page->getLayoutMetrics()->getCssContentSize(); + $visibleArea = $this->page->getLayoutMetrics()->getCssVisualViewport(); + + $maximumX = (int) ($scrollableArea['width'] - $visibleArea['clientWidth']); + $maximumY = (int) ($scrollableArea['height'] - $visibleArea['clientHeight']); + + $atBoundaryX = $distanceX > 0 ? $x >= $maximumX : ($distanceX < 0 ? $x <= 0 : true); + $atBoundaryY = $distanceY > 0 ? $y >= $maximumY : ($distanceY < 0 ? $y <= 0 : true); + + return $atBoundaryX && $atBoundaryY; + } + /** * Get the current mouse position. * diff --git a/tests/MouseApiTest.php b/tests/MouseApiTest.php index 5413a7b0..dabcf402 100644 --- a/tests/MouseApiTest.php +++ b/tests/MouseApiTest.php @@ -101,6 +101,80 @@ public function testScroll(): void self::assertLessThan(10000, $page->mouse()->getPosition()['y']); } + /** + * Scrolling works when scrollable area shrinks immediatly after scroll event. + * + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut + */ + public function testScrollDoesNotTimeOutWhenScrollableAreaShrinks(): void + { + $page = $this->openSitePage('scrollShrink.html'); + + $page->mouse()->scrollDown(4000); // Before patch this threw an OperationTimedOut Exception. + + $windowScrollY = $page->evaluate('window.scrollY')->getReturnValue(); + $maximumY = $page + ->evaluate('document.documentElement.scrollHeight - window.innerHeight') + ->getReturnValue(); + + // We asked to scroll further than the shrunken page allows, so we end up + // exactly at the new (smaller) maximum. The actual regression guarantee + // is that scrollDown() returns at all instead of timing out. + self::assertSame($maximumY, $windowScrollY); + } + + /** + * Scrolling works when an overlay pops up and locks scrolling immediatly after scroll event. + * + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut + */ + public function testScrollDoesNotTimeOutWhenModalLocksScrolling(): void + { + $page = $this->openSitePage('scrollLock.html'); + + $page->mouse()->scrollDown(4000); // Before patch this threw an OperationTimedOut Exception. + + $windowScrollY = $page->evaluate('window.scrollY')->getReturnValue(); + $maximumY = $page + ->evaluate('document.documentElement.scrollHeight - window.innerHeight') + ->getReturnValue(); + + // Scrolling got locked, so the page can no longer be scrolled at all and + // we stay at the top. The guarantee the fix restores is that scrollDown() + // returns instead of timing out. + self::assertSame(0, $maximumY); + self::assertSame(0, $windowScrollY); + } + + /** + * Scrolling works when the layout shifts (e.g. due to lazy loaded image) during scrolling. + * + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut + */ + public function testScrollDoesNotTimeOutWhenLayoutShiftsDuringScroll(): void + { + $page = $this->openSitePage('infiniteScroll.html'); + + $page->mouse()->scrollDown(4000); // Before patch this threw an OperationTimedOut Exception. + + $windowScrollY = $page->evaluate('window.scrollY')->getReturnValue(); + $maximumY = $page + ->evaluate('document.documentElement.scrollHeight - window.innerHeight') + ->getReturnValue(); + + // The page grew (it did not shrink), so we scrolled and ended up at a + // valid position within the new bounds rather than timing out. The exact + // position is timing-dependent. + self::assertGreaterThan(0, $windowScrollY); + self::assertLessThanOrEqual($maximumY, $windowScrollY); + } + /** * @dataProvider providerFindElementWithSingleElement * diff --git a/tests/resources/static-web/infiniteScroll.html b/tests/resources/static-web/infiniteScroll.html new file mode 100644 index 00000000..2a8716f4 --- /dev/null +++ b/tests/resources/static-web/infiniteScroll.html @@ -0,0 +1,40 @@ + + + + + infinite-scroll issue (lazy image above) + + + + + +
feed content
+ + + diff --git a/tests/resources/static-web/scrollLock.html b/tests/resources/static-web/scrollLock.html new file mode 100644 index 00000000..85cdefc1 --- /dev/null +++ b/tests/resources/static-web/scrollLock.html @@ -0,0 +1,65 @@ + + + + + scroll-lock issue + + + +
scroll me
+
+
+

A modal popped up

+

…and locked scrolling (the page is now position:fixed)

+
+
+ + + diff --git a/tests/resources/static-web/scrollShrink.html b/tests/resources/static-web/scrollShrink.html new file mode 100644 index 00000000..bd9e0ef2 --- /dev/null +++ b/tests/resources/static-web/scrollShrink.html @@ -0,0 +1,30 @@ + + + + + scroll timeout issue + + + +
scroll me
+ + +