Asserting Streaming Suspense Boundaries
Suspense boundaries decide what a user sees while data loads: which parts of a page appear immediately, which show a skeleton, and in what order the real content replaces each fallback. With streaming server rendering, those decisions also shape the HTML that arrives first and the order chunks are flushed. Tests of this behaviour tend to go one of two ways — they wait for everything to finish and assert the final page, which checks nothing about the loading experience; or they race real timers against real fetches and fail randomly. This guide shows how to take control of when each suspended resource resolves, so tests can assert the fallback, resolve one boundary at a time, verify the reveal order, check error boundaries, and confirm in a real browser that the shell streams before slow data. It is part of React state and hydration testing.
Root Cause Analysis
The underlying problem is that the resolution order of suspended data is usually decided by the network or a fake delay, not by the test. When two boundaries load in parallel, their completion order varies from run to run, and a test asserting “the summary appears before the reviews” passes only when the network happens to agree. Replacing delays with promises the test resolves explicitly makes the order a test input rather than an accident.
A second issue is that Suspense behaviour depends on how the promise is created. A component that creates a new promise on every render suspends forever, because each render starts a new request; a promise cached outside the component resolves once and the boundary reveals. Tests that construct promises inline inherit this bug and hang until timeout, which looks like a test-environment problem rather than the component bug it is.
A third is the gap between client rendering and streaming. A client test in jsdom sees the fallback swap to content, but not whether the server sent the shell first. A boundary misplaced above a slow component forces the server to wait before flushing anything, and only a test that observes the HTML arriving over time catches that regression.
Reproducible Setup
A product page with two boundaries: the summary and the reviews, each reading a resource through React’s use. Data loaders are injected so tests can replace them.
// src/product/ProductPage.tsx
type Loaders = { summary: (id: string) => Promise<Summary>; reviews: (id: string) => Promise<Review[]> };
export function ProductPage({ id, loaders }: { id: string; loaders: Loaders }) {
// Promises are created once per page render tree, not inside the suspending children.
const [summaryP] = useState(() => loaders.summary(id));
const [reviewsP] = useState(() => loaders.reviews(id));
return (
<main>
<h1>Product</h1>
<Suspense fallback={<p role="status">Loading summary…</p>}>
<SummaryPanel promise={summaryP} />
</Suspense>
<ErrorBoundary fallback={<p role="alert">Reviews are unavailable.</p>}>
<Suspense fallback={<p role="status">Loading reviews…</p>}>
<ReviewList promise={reviewsP} />
</Suspense>
</ErrorBoundary>
</main>
);
}
A tiny deferred helper gives the test control over each promise.
// test/deferred.ts
export function deferred<T>() {
let resolve!: (v: T) => void, reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
Implementation
Step 1 — Assert the shell and both fallbacks render first. Nothing has resolved, so both boundaries show their fallback while the heading is already present.
test('renders the shell with both fallbacks before any data', async () => {
const s = deferred<Summary>(), r = deferred<Review[]>();
render(<ProductPage id="p1" loaders={{ summary: () => s.promise, reviews: () => r.promise }} />);
expect(screen.getByRole('heading', { name: 'Product' })).toBeInTheDocument();
expect(screen.getByText('Loading summary…')).toBeInTheDocument();
expect(screen.getByText('Loading reviews…')).toBeInTheDocument();
});
Step 2 — Resolve one boundary and check the other still waits. This is the property that makes boundaries worth having: independent reveal.
test('reveals the summary without waiting for reviews', async () => {
const s = deferred<Summary>(), r = deferred<Review[]>();
render(<ProductPage id="p1" loaders={{ summary: () => s.promise, reviews: () => r.promise }} />);
await act(async () => s.resolve({ name: 'Mug', price: 12 }));
expect(await screen.findByText('Mug — £12')).toBeInTheDocument();
expect(screen.getByText('Loading reviews…')).toBeInTheDocument();
await act(async () => r.resolve([{ id: 'r1', text: 'Lovely' }]));
expect(await screen.findByText('Lovely')).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
Step 3 — Resolve in the opposite order. The page must behave sensibly when the slower resource wins the race; this is the order real networks sometimes produce.
test('shows reviews first when they arrive first', async () => {
const s = deferred<Summary>(), r = deferred<Review[]>();
render(<ProductPage id="p1" loaders={{ summary: () => s.promise, reviews: () => r.promise }} />);
await act(async () => r.resolve([{ id: 'r1', text: 'Lovely' }]));
expect(await screen.findByText('Lovely')).toBeInTheDocument();
expect(screen.getByText('Loading summary…')).toBeInTheDocument();
});
Step 4 — Reject a resource and assert the error boundary contains it. A failing reviews request must not take the summary down with it.
test('a reviews failure is contained to its own boundary', async () => {
const s = deferred<Summary>(), r = deferred<Review[]>();
vi.spyOn(console, 'error').mockImplementation(() => {}); // React logs caught errors
render(<ProductPage id="p1" loaders={{ summary: () => s.promise, reviews: () => r.promise }} />);
await act(async () => { s.resolve({ name: 'Mug', price: 12 }); r.reject(new Error('503')); });
expect(await screen.findByRole('alert')).toHaveTextContent('Reviews are unavailable.');
expect(screen.getByText('Mug — £12')).toBeInTheDocument();
});
Step 5 — Verify the stream in a real browser. In Playwright, delay the reviews API at the network layer and assert that the shell and summary are visible while reviews still show their fallback. If a boundary were misplaced, the whole page would wait and the first assertion would time out.
test('streams the shell before slow reviews', async ({ page }) => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
await page.route('**/api/reviews/*', async (route) => { await gate; await route.continue(); });
await page.goto('/products/p1');
await expect(page.getByRole('heading', { name: 'Product' })).toBeVisible();
await expect(page.getByText('Loading reviews…')).toBeVisible();
release();
await expect(page.getByText('Loading reviews…')).toBeHidden();
});
For server-side fetches the route interception above does not apply; point the server at a mock backend with a controllable delay instead, as described in testing React Server Components with Playwright.
A note on transitions: when navigation or a filter change is wrapped in startTransition, React keeps showing the previous content instead of falling back to the skeleton. That is usually the desired experience, and it deserves its own test — resolve the first data, trigger the transition with a new deferred promise, and assert that the old content stays visible, perhaps with a pending indicator, rather than being replaced by the fallback. Without that test, a refactor that drops the transition wrapper silently reintroduces a flash of loading state on every change, which users notice long before any assertion does.
Verification
Move the reviews Suspense boundary so it wraps both panels, then rerun Step 2. The test must fail: the summary no longer appears independently because it now shares a boundary with the unresolved reviews. That failure is the regression these tests exist to catch.
npx vitest run src/product/ProductPage.test.tsx --reporter=verbose
# ✓ renders the shell with both fallbacks before any data
# ✓ reveals the summary without waiting for reviews
# ✓ shows reviews first when they arrive first
# ✓ a reviews failure is contained to its own boundary
Then create the promise inside ReviewList instead of the parent and rerun. The reviews test now hangs until timeout — the infinite-suspend bug the setup is designed to avoid, surfaced by the test rather than by users.
Troubleshooting
Symptom: the test hangs and times out with the fallback still shown. Diagnosis: a new promise is created on each render, so the component suspends forever. Fix: create promises once — in the parent with useState, in a cache, or in a data library — and pass them down.
Symptom: “A component suspended inside an act scope” warning. Diagnosis: a promise resolved outside act, so React’s update was not flushed. Fix: resolve inside await act(async () => …) as in the examples, or rely on findBy queries which wait for the update.
Symptom: the fallback is never observed. Diagnosis: the loader returns an already-resolved value synchronously, or the resource was cached by a previous test. Fix: use deferred promises for every test that asserts a fallback, and reset caches between tests.
Symptom: the error boundary test prints a large stack trace. Diagnosis: React logs caught render errors to console.error. Fix: silence it for that test with a spy, and restore mocks after each test so real errors elsewhere are still visible.
FAQ
Do I need fake timers to test Suspense?
Usually not. Deferred promises control resolution directly, which is clearer than advancing a clock past a simulated delay. Use fake timers only when the component itself has time-based behaviour, such as a minimum fallback duration.
How do I test Suspense with React Query or SWR?
Enable their suspense modes and control the query function with deferred promises in the same way, using a fresh cache per test. See testing React Query hooks with a fresh cache.
Can jsdom test streaming server rendering?
It can render the result of renderToPipeableStream into a string, but it does not model the browser receiving and hydrating chunks over time. Use a real browser for the streaming claim and keep component tests for boundary logic.
Should every boundary have a test?
Every boundary that encodes a product decision — what loads independently, what may fail on its own — deserves one. A boundary added only to satisfy a framework requirement is covered by the page-level test.
Related
- Back to React State & Hydration Testing
- Debugging hydration mismatches in Next.js tests — the other half of server-rendered correctness.
- Testing loading and error states deterministically — the same control applied without Suspense.
- Testing context providers in isolation — injecting loaders through context instead of props.