Generated with AI.
This guide describes a pattern for testing components that use React Suspense. Real-world Suspense boundaries resolve based on data fetches, lazy imports, or module loads. To test them deterministically we need to pause the suspended state, assert what the UI looks like while suspended, then resolve on demand and assert what it looks like after.
Do not let the suspended promise resolve on its own. Instead:
- Build a story (or test harness page) whose lazy resource is backed by a promise you control.
- Expose a
resolve()function onwindowso the Playwright test can decide when the promise settles. - In the test, render the story, assert the suspended UI, call
window.<helper>.resolve(), then assert the resolved UI.
This lets you test the two states of a Suspense boundary (pending and resolved) and the transition between them without relying on real network timing.
Co-locate the helper with the story so the test does not have to import anything from app code. The helper stashes a resolve callback in a module-scoped variable when the loader runs, then exposes it on window for Playwright.
import type { ComponentType } from 'react';
type TPopupSuspenseHelper = {
resolve: () => null;
};
type TWindowWithPopupHelper = Window &
typeof globalThis & {
popupSuspenseHelper: TPopupSuspenseHelper;
};
// Module-scoped so the loader (which runs lazily when Suspense first reads
// the resource) can assign it, and so the window helper can read the latest value.
let popupSuspenseResolver: (() => null) | null = null;
// Exposed on window so the Playwright test can drive resolution via page.evaluate.
(window as TWindowWithPopupHelper).popupSuspenseHelper = {
resolve: () => {
popupSuspenseResolver?.();
return null;
},
};
function SuspenseTestContent() {
return (
<Popup.Body>
<div data-testid="lazy-content">Lazy loaded popup content</div>
</Popup.Body>
);
}
const suspenseTestContent = createResource({
loader: () =>
// The promise is held open and only settles when the test calls resolve(),
// keeping the Suspense boundary in its fallback state until then.
new Promise<{ default: ComponentType }>((resolve) => {
popupSuspenseResolver = () => {
resolve({ default: SuspenseTestContent });
return null;
};
}),
name: 'story:popup-suspense-test',
});
export const SuspenseTest: Story = {
render: () => (
<Popup header={<PopupHeader>Suspense test</PopupHeader>} content={suspenseTestContent}>
<button type="button" data-testid="trigger">
Open Popup
</button>
</Popup>
),
};Notes:
- The promise never resolves until something calls
popupSuspenseResolver(). Until then the Suspense boundary stays in its fallback state. - The module-scoped
letis intentional: the loader runs lazily (when Suspense first reads the resource), so the variable is assigned at the right moment. - Type the window cast (
as TWindowWithPopupHelper) rather than reaching forany. The cast is local to the file and does not leak into ambient global typings. - Use one helper per story. Do not share a helper across stories with different resolved content; you will get cross-test bleed if the loader caches the resolved value.
Each test follows the same shape:
import { expect, test } from '@playwright/test';
type TWindowWithPopupHelper = Window &
typeof globalThis & {
popupSuspenseHelper: { resolve: () => null };
};
test.describe('popup suspense', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/iframe.html?globals=&id=design-system-popup--suspense-test&viewMode=story');
// Wait for Popup effect to run (sets aria-haspopup and binds click);
// avoids opening the popover before the handler is attached.
await expect(page.getByTestId('trigger')).toHaveAttribute('aria-haspopup', 'dialog');
});
test('should load lazy content successfully', async ({ page }) => {
const trigger = page.getByTestId('trigger');
await trigger.click();
const popup = page.getByRole('dialog');
await expect(popup).toBeVisible();
// Drive the Suspense boundary to its resolved state from the test, rather
// than waiting on real timing.
await page.evaluate(() => (window as TWindowWithPopupHelper).popupSuspenseHelper.resolve());
const lazyContent = page.getByTestId('lazy-content');
await expect(lazyContent).toBeVisible();
});
});The shape:
- Navigate to the story URL (
?id=...&viewMode=story). - Wait for hydration with an attribute or role assertion that proves the component effect has run. Clicking before effects attach silently no-ops in some components.
- Trigger the suspend (open the popup, click the route link, etc.).
- Assert the fallback (loading state, header still visible, spinner present, lazy content absent).
- Resolve the promise via
page.evaluate(() => window.helperName.resolve()). - Assert the resolved state (lazy content visible, fallback gone).
Once you can pause the boundary, write a test for each behaviour that matters:
- Resolved happy path: the lazy content actually renders after resolve.
- Fallback content: what is visible during suspense (heading, spinner, skeleton).
- Behaviours that should keep working after resolve: escape to close, click-outside dismiss, focus return.
- Preload-on-intent: hovering or focusing the trigger should start the load so resolving before the click means no fallback flash.
- Eager preload: mounting the component starts the load; resolve, then click, then the content is immediate.
The preload tests follow the same pattern with one variation: resolve before the user interaction, then assert the resolved content appears without first asserting the fallback. This proves the load was kicked off by hover/focus/mount rather than by the click.
Playwright auto-waiting assertions retry, which can mask a missing fallback (the assertion might pass because the resolved content appeared). To assert something is absent during suspense, pair a positive assertion with the negative one:
await expect(page.getByRole('heading', { name: 'Suspense test' })).toBeVisible();
await expect(page.getByTestId('lazy-content')).not.toBeVisible();If the boundary resolves on its own (because the controllable promise was not wired correctly), the second assertion still passes the moment the fallback appears but fails as soon as the real content renders. That is a useful canary for a broken story.
- Module caching across tests. A
createResource(orReact.lazy) call at module scope caches the resolved value forever. IfbeforeEachreuses the same story URL, the second test sees an already-resolved resource and skips the fallback state entirely. The story side mitigates this by storing the resolver in a module-scopedletso each loader call re-assigns it, but storybook hot-reload / story isolation matters: each test navigates to a fresh iframe load, so the module evaluates from scratch. - Wait for handlers. Component effects bind listeners. Asserting
aria-haspopup(or any post-effect attribute) before clicking prevents flaky "click did nothing" failures. - Do not use
waitForLoadState('networkidle'). Suspense boundaries have nothing to do with network state in these tests; the relevant signal is the promise the test controls. - One resolve per loader call. If you call
resolve()twice, the second is a no-op (promises are settled once). Do not write tests that try to "re-resolve" with different content. - TypeScript on
window. Declare a localTWindowWithXHelpertype per test file and cast at the call site. Do not bolt these onto a global ambient declaration; keep them scoped to the tests that need them.
- Lazy-loaded modal/popup bodies (
React.lazy,createResource). - Route-level Suspense (Next.js loading.tsx, server component streaming).
- Any data-driven Suspense boundary where you want to assert the fallback UI in isolation.
If the only thing you care about is "does it eventually render", a plain await expect(content).toBeVisible() is enough. Use the controllable-promise pattern when you need to assert the suspended state or the transition between states.