This page is the browser-level half of the strategy in Testing & Fault Injection for Error Boundaries, where the fault registry itself is defined.
The exact problem #
Unit tests prove a boundary catches a throw in a synthetic tree. They cannot prove that the boundary is still wired into the shipped route, that server-rendered markup falls back correctly, or that the crash beacon leaves the page. Those only fail in a real browser running a real bundle — usually first in production.
The specific failure this page prevents: a route renders fine in tests, but in production the boundary sits inside the component that crashes during hydration, so the fallback never mounts and the user gets a blank shell. To catch it you have to make the crash happen during hydration, which means arming the fault before a single line of the application bundle executes.
Zero-to-working spec #
The bundle reads a well-known global at startup and arms the matching fault; the test sets that global with addInitScript, which runs before page scripts on every new document.
// src/entry.client.tsx (excerpt) — the only production-side requirement
if (__FAULTS_ENABLED__) {
const requested = (window as { __ARM_FAULT__?: string }).__ARM_FAULT__;
if (requested) arm(requested as FaultPoint, { repeat: true });
}
hydrateRoot(document.getElementById('root')!, <App />);
// e2e/boundary-injection.spec.ts
import { expect, test, type Page } from '@playwright/test';
/** Record every crash beacon the page tries to send. */
async function captureBeacons(page: Page) {
const sent: Array<Record<string, unknown>> = [];
await page.route('**/_crash', async (route) => {
const body = route.request().postData();
if (body) sent.push(JSON.parse(body));
await route.fulfill({ status: 204, body: '' });
});
return sent;
}
test.describe('dashboard panel boundary', () => {
test('a hydration-time crash degrades one panel only', async ({ page }) => {
const beacons = await captureBeacons(page);
// Runs on EVERY new document, before any application script.
await page.addInitScript(() => {
(window as { __ARM_FAULT__?: string }).__ARM_FAULT__ = 'dashboard:panel-render';
});
await page.goto('/dashboard', { waitUntil: 'networkidle' });
// 1. the panel degraded, with an accessible announcement
const alert = page.getByRole('alert');
await expect(alert).toContainText(/revenue panel is unavailable/i);
// 2. the shell around it is untouched — this is the containment proof
await expect(page.getByRole('navigation')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByTestId('orders-panel')).toBeVisible();
// 3. the crash actually left the page
await expect.poll(() => beacons.length).toBe(1);
expect(beacons[0]).toMatchObject({
scope: 'dashboard-panel',
route: '/dashboard',
recovery: 'fallback-shown',
});
});
test('retry recovers in place once the fault clears', async ({ page }) => {
await page.addInitScript(() => {
(window as { __ARM_FAULT__?: string }).__ARM_FAULT__ = 'dashboard:panel-render';
});
await page.goto('/dashboard');
await expect(page.getByRole('alert')).toBeVisible();
// Disarm in-page, then use the app's own recovery affordance.
await page.evaluate(() => {
(window as { __DISARM__?: () => void }).__DISARM__?.();
});
await page.getByRole('button', { name: /try again/i }).click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.getByTestId('revenue-panel')).toBeVisible();
// A full navigation would also make this pass — assert it did NOT happen.
expect(page.url()).toContain('/dashboard');
await expect(page.getByTestId('orders-panel')).toBeVisible();
});
});
Step-by-step #
- Give the bundle one arming hook. Reading a single global at startup keeps the production surface tiny and the test surface obvious. The
__FAULTS_ENABLED__guard means the branch disappears entirely from the production build. - Arm with
addInitScript, neverevaluate. Init scripts run on every new document — including after a client-side navigation that creates one — so the fault is present before React starts. - Route the beacon endpoint.
page.routeboth captures the payload and prevents test traffic reaching a real reporting backend. Fulfilling with 204 keeps the app’s own error handling on its happy path. - Poll rather than sleep.
expect.poll(() => beacons.length)waits for the beacon without a fixed timeout, which is the difference between a stable test and an intermittently red pipeline. - Assert the shell, not just the fallback. Navigation, page heading and a sibling panel are the containment evidence. In a real bundle these are also what would break if the boundary were mounted too high.
- Prove recovery happened in place. Checking the URL is unchanged and a sibling panel is still mounted distinguishes a genuine boundary reset from a full page reload that would also make the fallback disappear.
Edge cases that make these tests flaky #
| Cause | Symptom | Fix |
|---|---|---|
Fault armed with evaluate |
Test passes locally, fails in CI where hydration is slower | Arm with addInitScript only |
repeat: false on a fault that must persist |
Retry silently succeeds on the first attempt | Arm with repeat: true, disarm explicitly before asserting recovery |
networkidle on a page with polling |
Navigation times out | Wait for a concrete locator instead of a network state |
Beacon sent via sendBeacon |
page.route does not intercept it in some versions |
Have the test build fall back to fetch when a test flag is present |
| Service worker serving a cached bundle | The arming hook is missing from an old bundle | Clear storage per test context, or version the SW cache per run |
| Console error assertions | Fails on unrelated warnings from third-party scripts | Filter console messages by source before asserting |
The service-worker row is the one that produces the most confusing failures: a cached older bundle has no arming hook, so the fault never fires and the test reports “no fallback appeared” as if the boundary were broken. Clearing storage in a beforeEach — or running each spec in a fresh context — removes the category. If your app registers a worker, the lifecycle traps in Recovering from a Stuck Service Worker with skipWaiting and clients.claim apply to your test environment too.
Verification #
- Run the spec with the fault name misspelled. It must fail on the fallback assertion — proving the fault is genuinely driving the crash rather than something else on the page producing an alert.
- Temporarily remove the panel’s boundary. The shell assertions must fail. If the test still passes, your fallback is coming from the component’s own error state, not the boundary.
- Block the beacon route with a 500. The test should still pass on the first three assertions and fail only on the beacon check, confirming the assertions are independent.
- Watch it run headed once.
--headed --slow-mo=250on the recovery test shows whether the retry re-mounts in place or flashes a full reload — a difference the URL assertion catches but the eye confirms. - Run the suite twenty times in CI. Any intermittent failure is almost always the
networkidlewait or a cached service-worker bundle; both are listed above.
Frequently asked questions #
Why does page.evaluate arm my fault too late?
evaluate runs after load, by which time the bundle has hydrated and rendered. Anything armed afterwards can only affect a later re-render, so hydration-time crashes are never exercised. addInitScript runs before any page script on every new document.
How do I assert that a crash report was actually sent?
Route the reporting endpoint and record request bodies, or have the reporter push payloads onto a global array in non-production builds. Routing is stronger because it proves the request left the page.
Should injected-fault tests run against a production build?
Run them against a build identical to production apart from the arming flag. Development builds differ in minification, error overlays and StrictMode double-rendering, all of which change what you are asserting.
Related #
- Testing & Fault Injection for Error Boundaries — the fault registry and CI gates behind this spec
- Unit Testing an Error Boundary with React Testing Library — the fast layer that covers each boundary contract
- Error Telemetry & Crash Reporting — the beacon payload this test asserts against