How to Wait for Single Page Navigation and Re-hydration with Playwright and React Router
Published on . Last updated on .
#frontend #playwright #react #tech #testing
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