How to Wait for Single Page Navigation and Re-hydration with Playwright and React Router

While testing this blog with the awesome Playwright I faced the problem of tests failing in CI (github actions) environment. After some trial-and-error and a debugging I figured that all tests failing were initiating (or dependent on) partial application re-rendering or react rehydration.

Issued I had were caused by too eager search for the element on the navigation target page. Which wasn’t giving enough time for the target page to render and hydrate.

Failing tests were written about a year ago before playwright’s locators were introduced. They used ElementHandle.waitForSelector instead.

Subsequent migration to locators resolved all navigation related issues I had. This is newer and far superior API to get hold of elements on the page. Please use it when you can.

That’s why I redacted this article and left only custom UI page render approach for cases when you want to be notified of the actual navigation and successful target page render.

One example when custom navigation event might be useful is to wait for form elements re-hydration. Even though input elements could be present on the page right after page load — event listeners might not be hooked up yet. Interacting with those elements generally won’t produce the expected result.

React Router Location Change Event

Most single-page-applications use client-side router for seamless navigation between application routes. Such router navigations don’t cause full page reload but update the view and browser’s history.

Client side routers also provide methods to work with application route programmatically. In particular, they are able to announce (or otherwise communicate) application route change event via service method, react hook, or custom event.

Let’s leverage the route change event to capture the successful single-page application navigation without the need to wait for the particular page element to appear or disappear.

For this React based website I’m using useLocation router hook wrapped in useEffect react hook.

The combo gives me a custom useLocationChangeEvent hook emitting specific CustomEvent on window object whenever application route changes:

import React, { useEffect } from 'react';
import { useLocation } from '@reach/router';

function useLocationChangeEvent(): void {
  const location = useLocation();

  useEffect(() => {
    if (window) {
      // custom event type provides location details as a payload
      const event = new CustomEvent('REACT_ROUTER_PAGE_CHANGE_EVENT', { detail: { location } });

      window.dispatchEvent(event);
    }
  }, [location]);
}

// hook needs to be imported and used by the page-level react component
typescript

Now we need to capture REACT_ROUTER_PAGE_CHANGE_EVENT event in the Playwright test. Once captured we consider the application route changed and new page being ready for testing.

To achieve it we will use page.evaluate which allows us to wait for the window event in the browser context and resolve a promise in our test script environment once it had been captured.

In essence, we are making a bridge between react router useLocation hook and promise we can await for in our test.

Here is the bridge implementation:

function waitForSpaNavigation(): Promise<string> {
  return new Promise((resolve, reject) => {
    let resolved = false;

    const eventListener = (event: Event): void => {
      // very lax check for TSC (typescript compiler)
      if (event instanceof CustomEvent) {
        resolved = true;

        // promise with be resolved with the URL pathname
        resolve((event.detail as unknown as any).location.pathname);
      }
    };

    window.addEventListener(
      'REACT_ROUTER_PAGE_CHANGE_EVENT',
      eventListener,
      { once: true }, // this is (at most) one time use event handler
    );

    // cleanup in case of timeout or non-event
    setTimeout(() => {
      window.removeEventListener(
        'REACT_ROUTER_PAGE_CHANGE_EVENT',
        eventListener,
      );

      if (!resolved) {
        reject(Error('Expected SPA navigation timeout'));
      }
    }, 10000); // timeout value better match playwright's timeout
  });
}
typescript

waitForSpaNavigation returns a Promise which will be resolved with navigation target page pathname or rejected if navigation didn’t happen within the timeframe provided.

Under the hood it is setting (at most) one time use REACT_ROUTER_PAGE_CHANGE_EVENT event listener on window. Which is emitted by useLocationChangeEvent hook we defined above.

It is important to execute waitForSpaNavigation page function before clicking the link or triggering navigation programmatically because otherwise REACT_ROUTER_PAGE_CHANGE_EVENT might fire before we get a chance to set up our listener.

Here is how we use our custom navigation event approach in the test:

await page.goto('https://lab.amalitsky.com');

const carsPageLink = page.locator('[data-testid*=HeaderTabs-Tab_cars]');

const [urlPath] = await Promise.all([
  page.evaluate(waitForSpaNavigation),
  carsPageLink.click(),
]);

// now you can verify the succesfull navigation against the URL pathname
expect(urlPath).toMatchSnapshot();
typescript

Notice the await Promise.all wrapper for click and navigation event subscription — we don’t want to await for the click before we set up the REACT_ROUTER_PAGE_CHANGE_EVENT event listener. Otherwise, we open ourselves to the potential race condition when navigation event fires before we get a chance to catch it.

When using page.goto along with the custom navigation event listener don’t forget to pass { waitUntil: 'commit' } option:

// await is ok here since 'waitUntil: commit' resolves too early for the page to load
// and react router to instantiate and trigger our custom event
await page.goto('https://example.com', { waitUntil: 'commit' });

// now we wait for our custom "UI State Ready" event
await page.evaluate(waitForSpaNavigation);
typescript

This last snippet is particularly useful for SSR pages with client side re-hydration.

Conclusion

We figured that reliable multi-page Playwright tests in React/Vue/Angular application can be achieved with the use of locators and web-first assertions or custom implementation of waitForSpaNavigation page function.

Thanks for reading this far!

As always, please let me know what you think or go ahead and ask any questions via DM. Cheers