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();
  });
});
Arming windows relative to hydration A left-to-right page-load timeline with four markers: new document, bundle evaluates, hydration runs, and load complete. A bracket labelled addInitScript covers the region before the bundle evaluates. A bracket labelled page.evaluate starts only after load complete, entirely after hydration. new document bundle evaluates hydration runs load complete addInitScript — arms here page.evaluate — too late for hydration faults a fault armed after hydration can only affect a later re-render

Step-by-step #

  1. 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.
  2. Arm with addInitScript, never evaluate. Init scripts run on every new document — including after a client-side navigation that creates one — so the fault is present before React starts.
  3. Route the beacon endpoint. page.route both 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.
  4. 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.
  5. 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.
  6. 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.

Four assertions and the regression each one detects Four paired boxes. Fallback visible detects a boundary that stopped catching. Shell intact detects a boundary mounted too high. Beacon captured detects a detached reporter. Recovery in place detects a reset that only works via a full reload. fallback visible boundary stopped catching this failure at all shell intact boundary mounted above the layout during a refactor beacon captured reporter detached; dashboards go quiet, tickets do not recovery in place retry silently reloads the page instead of resetting What a fresh browser context resets between specs Three context boxes side by side, each containing the same four items: storage, service worker registration, init script and console listeners. A caption notes that reusing one context between specs is the most common source of intermittent failures in injected-fault tests. context · spec 1 storage cleared no worker registered its own init script context · spec 2 storage cleared no worker registered arms a different fault context · spec 3 storage cleared no worker registered arms no fault at all share one context and spec 3 inherits spec 2's cached bundle — the classic intermittent failure

Verification #

  1. 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.
  2. 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.
  3. 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.
  4. Watch it run headed once. --headed --slow-mo=250 on 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.
  5. Run the suite twenty times in CI. Any intermittent failure is almost always the networkidle wait 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.