Skip to content

fix: do not retry navigations in the expectation loop - #280

Open
cyppe wants to merge 1 commit into
pestphp:5.xfrom
cyppe:fix/do-not-retry-navigations
Open

cyppe wants to merge 1 commit into
pestphp:5.xfrom
cyppe:fix/do-not-retry-navigations

Conversation

@cyppe

@cyppe cyppe commented Oct 4, 2026

Copy link
Copy Markdown

navigate(), refresh(), back() and forward() go through Execution::waitForExpectation(). When the configured timeout is above 1000 ms, that loop sets the client timeout to 1000 ms for each attempt and retries every ExpectationFailedException; after the loop it makes one final call with the restored timeout. Client::execute() turns every Playwright error into that exception, including the timeout of an attempt. So when a page takes more than a second to load, the navigation is sent again while the first load may still be in progress. This PR runs these four methods once, as #269 already does for click(), press() and the other actions.

Example

Playwright::setTimeout(5_000);

Route::get('/', fn (): string => 'home');
Route::get('/slow', function (): string {
    usleep(1_500_000); // longer than one attempt, well within the 5 s timeout

    return 'slow page';
});

$page = visit('/');
$page->navigate('/slow');

On 5.x, the first goto('/slow') times out after one second and the loop calls it again, so /slow is served twice for one navigate() call. In an application's CI this showed up as:

Navigation to "http://127.0.0.1:43981/<product-page>" is interrupted by another navigation to "http://127.0.0.1:43981/<product-page>"

Playwright raises this when a different document commits before the one its goto is waiting for. The retry loop can issue both navigations from one call. A retried back() is worse: in the test below it does not land within the 5 s timeout (Timeout 5000ms exceeded). With this change, /slow is served once.

Why one attempt

Retrying repeats a state-changing command. A repeated goto/reload starts another load, and a repeated goBack/goForward can traverse another history entry. Playwright already waits for the requested load state: goto() and reload() send waitUntil: 'load', and goBack/goForward default to load. That wait happens within the timeout it is given, so a single attempt with the full timeout is the right shape, as for clicks. One behaviour changes on purpose: a navigation that fails is no longer re-sent automatically.

Tests

tests/Browser/Webpage/SlowNavigationTest.php raises the timeout to 5 s inside the file and restores it afterwards. Each slow response sleeps 1.5 s; the initial loads are fast.

  • navigate() serves the slow page once and shows it.
  • refresh() reloads once.
  • back() and forward() each request the page once and land on the expected history entry. The responses send Cache-Control: no-store to avoid HTTP-cache reuse; Playwright's default Chromium launch disables the back/forward cache.

Local results:

  • On unchanged 5.x source, the three tests failed in 3 of 3 runs:
    • navigate: Failed asserting that 2 is identical to 1;
    • refresh: 3 is identical to 2;
    • back: Timeout 5000ms exceeded.
  • With the fix they passed in 5 of 5 runs, and 3 of 3 with Xdebug in coverage mode.
  • Removing any one of the four names from the list makes exactly one test fail.
  • Full composer test (exit 0): 349 passed, 27 skipped (the usual platform skips); Rector, Pint, profanity, type coverage (100 %) and PHPStan are clean.
  • PHP 8.4, Playwright 1.63.0, Chromium. I did not run PHP 8.5; the CI matrix will.

These are local observations; the 1.5 s sleep leaves headroom within the 5 s timeout, but runner scheduling can never be fully ruled out.

Not covered

withinFrame() still retries its whole callback with a one-second client timeout, so a navigation inside a frame callback can still be repeated with it. That is callback replay, a separate change.

Related PRs

🤖 Generated with Claude Code

navigate(), refresh(), back() and forward() ran inside waitForExpectation(),
whose one-second attempts re-sent a navigation that took longer than a
second, while the first load could still be in progress: a slow page was
requested twice, or the call failed with "interrupted by another
navigation". Run them once, like click() and the other actions (pestphp#269);
Playwright waits for the load state within the full timeout.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant