This page narrows a scenario from Component Isolation Techniques: containing the blast radius of a single embedded widget you do not control, rather than isolating first-party components.

The exact failure this page solves #

A support-chat widget, an ads slot, or a third-party reviews carousel throws during render — a null dereference in the vendor’s own bundle, an unexpected API response shape, a race between its own async init and React’s commit phase. Without isolation, that throw unwinds up the component tree until it hits the nearest boundary, which is often the app root. The checkout form, the navigation, the rest of the page — everything unmounts because of a widget that was never critical to the page’s core function. The fix is a SandboxBoundary component: a small, reusable boundary purpose-built for embedding untrusted or flaky third-party UI, which renders a compact fallback in place of just the widget and reports the failure without touching anything else on the page.

Zero-to-working implementation #

The component below is complete and framework-accurate for React 18/19. It catches synchronous render/lifecycle errors, reserves layout space to avoid shift, reports to telemetry, and supports a bounded retry.

// SandboxBoundary.tsx
import React from 'react';

interface SandboxBoundaryProps {
  children: React.ReactNode;
  name: string;          // widget identifier used in telemetry and the fallback message
  maxRetries?: number;   // caps re-mount attempts to avoid a retry storm
  minHeight?: number;    // reserved pixel height so the fallback causes no layout shift
}

interface SandboxBoundaryState {
  hasError: boolean;
  retryCount: number;
}

export class SandboxBoundary extends React.Component<SandboxBoundaryProps, SandboxBoundaryState> {
  static defaultProps = { maxRetries: 1, minHeight: 120 };
  state: SandboxBoundaryState = { hasError: false, retryCount: 0 };

  static getDerivedStateFromError(): Partial<SandboxBoundaryState> {
    return { hasError: true }; // flips render to the fallback branch below
  }

  componentDidCatch(error: Error, info: React.ErrorInfo): void {
    reportWidgetCrash(this.props.name, error, info.componentStack, this.state.retryCount);
  }

  private retry = (): void => {
    this.setState(prev => ({ hasError: false, retryCount: prev.retryCount + 1 }));
  };

  render(): React.ReactNode {
    const { hasError, retryCount } = this.state;
    const { name, maxRetries = 1, minHeight = 120, children } = this.props;

    if (hasError) {
      const canRetry = retryCount < maxRetries;
      return (
        <div
          role="alert"
          style={{ minHeight, display: 'flex', alignItems: 'center', gap: '0.5rem',
                    border: '1px dashed currentColor', opacity: 0.85,
                    fontSize: '0.85rem', padding: '0.75rem 1rem' }}
        >
          <span>{name} is unavailable right now.</span>
          {canRetry && <button type="button" onClick={this.retry}>Retry</button>}
        </div>
      );
    }

    // Reserve the widget's footprint even before it throws, so a later
    // crash swaps content in place rather than collapsing the container.
    return <div style={{ minHeight }}>{children}</div>;
  }
}

function reportWidgetCrash(
  name: string,
  error: Error,
  componentStack: string | null,
  attempt: number,
): void {
  const payload = JSON.stringify({
    name, message: error.message, stack: error.stack, componentStack, attempt, at: Date.now(),
  });
  fetch('/telemetry/widget-crash', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: payload,
    keepalive: true, // ensures the beacon survives if the page navigates away
  }).catch(() => { /* telemetry is best-effort; never throw from the reporter */ });
}

Usage is a single wrap at the call site:

<SandboxBoundary name="support-chat-widget" maxRetries={2} minHeight={96}>
  <SupportChatWidget />
</SandboxBoundary>

Step-by-step explanation #

  1. getDerivedStateFromError flips render state. This static lifecycle method runs during the render phase, before componentDidCatch, and its only job is to set hasError: true so the next render produces the fallback branch instead of re-throwing.

  2. componentDidCatch reports, it does not render. This method runs during the commit phase and receives both the Error and React’s componentStack, which pinpoints which JSX ancestor chain was mounting the widget — invaluable when the same vendor script is embedded in multiple places on the page.

  3. The fallback reserves minHeight before and after the crash. The non-error branch already wraps children in a div sized to minHeight, so swapping to the fallback div of the same height produces zero layout shift — the rest of the page never reflows.

  4. reportWidgetCrash uses a keepalive fetch. Ordinary fetches can be cancelled mid-flight if the user navigates immediately after the crash; keepalive: true tells the browser to complete the request in the background, similar to navigator.sendBeacon.

  5. retry re-mounts the subtree, capped by maxRetries. Setting hasError: false causes React to attempt mounting children again from scratch — a fresh instance of the widget, not a resumed one. The canRetry check disables the button once the cap is hit, so a widget that fails on every mount stops offering false hope.

  6. The boundary only guards the render call stack. Nothing in this component subscribes to window’s error events — that gap is deliberate and covered in the edge cases below, because conflating the two catch paths is the most common mistake when isolating third-party code.

Edge cases #

Scenario Symptom Mitigation
Widget throws inside a setTimeout, event handler, or its own fetch().then() Error reaches window as uncaught; boundary’s fallback never appears Add a window.addEventListener('error', ...) and unhandledrejection listener scoped to the widget’s script, and route matches into the same reportWidgetCrash call
Vendor script mutates global objects or leaks a memory-hungry loop Boundary catches the render throw, but the damage already happened outside React Load fully untrusted vendor code inside an <iframe sandbox="allow-scripts">, communicating over postMessage instead of direct DOM/JS access
Widget fails on every mount and retry is called repeatedly by the user or an automated poller Repeated componentDidCatch calls flood telemetry and hammer the vendor’s endpoint Enforce maxRetries, and add an exponential backoff timer before re-enabling the button rather than allowing back-to-back clicks
Fallback renders at a different size than the loaded widget Page reflows the moment the widget crashes or recovers Set minHeight to the widget’s typical rendered height (measure with DevTools) so both the loading, success, and fallback states occupy the same box

Verification steps #

  1. Force the widget to throw. Temporarily replace the wrapped component with one that throws unconditionally: function BrokenWidget(): never { throw new Error('forced crash'); }, mount it inside <SandboxBoundary name="test">, and confirm only the boundary’s fallback appears — the rest of the page must remain scrollable and interactive.

  2. Check the Network tab. Confirm a POST /telemetry/widget-crash request fires with name, message, and componentStack populated, and that it completes even if you immediately trigger a client-side navigation afterward (thanks to keepalive).

  3. Click Retry and confirm re-mount. React DevTools’ Components tab should show the widget subtree unmount and remount with a fresh instance; click through maxRetries times and confirm the button disappears afterward.

  4. Confirm the async gap. Add a setTimeout(() => { throw new Error('async'); }, 0) inside the test widget and verify the boundary’s fallback does not appear — the error should surface only in the console or your window.onerror listener, proving the boundary’s catch scope is exactly the render call stack.

  5. Measure layout shift. Use Chrome DevTools’ Performance panel or a Cumulative Layout Shift overlay while triggering the crash; the reserved minHeight should keep CLS contribution from this component at zero.

Frequently asked questions #

Will a SandboxBoundary catch an error from a widget’s setTimeout callback?

No. React error boundaries only catch errors thrown during rendering, in lifecycle methods, or in constructors of the components below them. Anything thrown inside a setTimeout, a promise callback, or a DOM event handler runs outside React’s render call stack and becomes an uncaught exception on window instead of reaching componentDidCatch.

Should I always run third-party widgets inside an iframe instead of a boundary?

Use an iframe with a sandbox attribute when the vendor script is untrusted or known to touch global state, since it gives you real process and DOM isolation that a boundary cannot provide. Reach for a SandboxBoundary alone when the widget is a reasonably trusted vendor component rendered directly in your React tree and the goal is mainly to contain synchronous render crashes without the overhead and messaging complexity of a separate browsing context.

How do I stop a flaky widget from retrying itself into a retry storm?

Cap retries with the maxRetries prop, disable the retry action once the cap is reached, and consider adding a short backoff delay between attempts rather than allowing rapid repeated clicks. Logging every retry attempt to telemetry, as this implementation does with the attempt field, turns a widget that fails on every mount into a visible monitoring signal instead of a silent background loop.