This page extends the containment strategy in Component Isolation Techniques to custom elements, whose failures happen where no framework boundary can reach them.

The exact problem #

A design-system team ships a charting widget as a custom element. The React application renders <ds-chart data-series="…">. In production, one dataset contains a null point, the element’s connectedCallback throws while measuring, and the result is a blank rectangle where a chart should be — no fallback, no crash report, and a React boundary two levels up that never fires.

The reason is structural. React rendered a tag; the browser upgraded it. From that point the element’s constructor, connectedCallback, and attribute reactions run inside the custom-element reaction queue. Exceptions there go straight to window.onerror. The framework has no idea anything happened.

Zero-to-working containment #

Two layers are needed: the element defends itself internally, and the host wrapper handles “the element never became usable”.

// ds-chart.ts — the element defends itself
class DsChart extends HTMLElement {
  #root = this.attachShadow({ mode: 'open' });
  #resize?: ResizeObserver;

  connectedCallback(): void {
    try {
      this.#render();
    } catch (err) {
      this.#fail(err);
    }
  }

  attributeChangedCallback(): void {
    try {
      this.#render();
    } catch (err) {
      this.#fail(err);
    }
  }

  disconnectedCallback(): void {
    // Cleanup must run even after a failure, or a broken element leaks
    // observers for the lifetime of the page.
    this.#resize?.disconnect();
    this.#resize = undefined;
  }

  #render(): void {
    const series = JSON.parse(this.getAttribute('data-series') ?? '[]');
    if (!Array.isArray(series)) throw new TypeError('data-series must be an array');
    this.#root.innerHTML = `<style>${CHART_CSS}</style><figure part="chart">${draw(series)}</figure>`;
    this.#resize ??= new ResizeObserver(() => this.#render());
    this.#resize.observe(this);
  }

  #fail(err: unknown): void {
    // The failure UI lives in the shadow root: host CSS cannot distort it.
    this.#root.innerHTML =
      `<style>${FAIL_CSS}</style>` +
      `<p part="error" role="status">Chart unavailable</p>`;
    // composed:true lets the event cross the shadow boundary to the host app.
    this.dispatchEvent(new CustomEvent('ds-error', {
      bubbles: true,
      composed: true,
      detail: { component: 'ds-chart', message: err instanceof Error ? err.message : String(err) },
    }));
  }

  static observedAttributes = ['data-series'];
}

customElements.define('ds-chart', DsChart);
// CustomElementSlot.tsx — the host handles "never became usable"
export function CustomElementSlot({ tag, timeoutMs = 4000, children, fallback }: Props) {
  const [state, setState] = useState<'pending' | 'ready' | 'failed'>('pending');
  const hostRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let settled = false;
    const timer = setTimeout(() => { if (!settled) setState('failed'); }, timeoutMs);

    void customElements.whenDefined(tag).then(() => {
      settled = true;
      clearTimeout(timer);
      setState('ready');
    });

    return () => clearTimeout(timer);
  }, [tag, timeoutMs]);

  useEffect(() => {
    const node = hostRef.current;
    if (!node) return;
    const onError = (e: Event) => {
      const detail = (e as CustomEvent<{ component: string; message: string }>).detail;
      reportCrash({ scope: `web-component:${detail.component}`, message: detail.message });
      setState('failed');
    };
    node.addEventListener('ds-error', onError);
    return () => node.removeEventListener('ds-error', onError);
  }, []);

  return (
    <div ref={hostRef} className="ce-slot" data-state={state}>
      {state === 'failed' ? fallback : children}
    </div>
  );
}
Two containment layers around a custom element An outer box labelled React host wrapper contains an inner box labelled custom element, which contains a further box labelled shadow root. Annotations show that the shadow root contains DOM and CSS, the custom element catches its own lifecycle throws and dispatches a composed error event, and the host wrapper handles definition timeouts and swaps in a framework fallback. A separate arrow leaves the custom element towards a global error handler box. React host wrapper whenDefined + timeout · listens for ds-error · renders fallback <ds-chart> try/catch in connected + attribute callbacks shadow root contains DOM + CSS — failure UI renders here ds-error host swaps in fallback and reports the crash window.onerror uncaught throws still land here

Step-by-step #

  1. Attach the shadow root in the constructor or field initialiser. Doing it later races with the first connectedCallback, and a failure before the root exists has nowhere to render its message.
  2. Wrap every lifecycle callback. connectedCallback and attributeChangedCallback are the two that run arbitrary user data through your code; both need their own try/catch.
  3. Render failure UI inside the shadow root. The host page cannot restyle it, and the element’s own CSS stays contained — the visual isolation that makes a broken widget look intentional rather than corrupt.
  4. Dispatch a composed event. Without composed: true the event stops at the shadow boundary and the host never learns anything. This event is the only supported bridge back into framework land.
  5. Always clean up in disconnectedCallback. A failed element that keeps a ResizeObserver alive turns one bug into a slow leak — the discipline described in State Reset & Cleanup Protocols.
  6. Time out the definition. If the defining script never loads, whenDefined never settles. Four seconds is a reasonable ceiling before showing a framework fallback.

Edge cases #

Case What happens Handling
Definition script fails to load Element stays undefined forever, renders as inline zero-height whenDefined timeout plus :not(:defined) sizing
Constructor throws Browser marks the element “failed”; it never upgrades Keep constructors trivial: no DOM, no parsing, no fetch
Attribute set before upgrade Property is shadowed by an own-property, ignored after upgrade Delete and reassign in connectedCallback (the standard upgrade dance)
SSR markup with no client definition Hydration renders empty boxes Size with CSS and treat as a hydration fallback
Element used inside a React list React does not diff shadow content Key the host wrapper so a re-render remounts the element cleanly
Adopted stylesheets on an old browser adoptedStyleSheets unsupported Fall back to an inline <style> in the shadow root

Sizing deserves a rule of its own, because an undefined custom element is display: inline with no dimensions:

/* Reserve the space before the definition arrives, then hand over. */
ds-chart:not(:defined) {
  display: block;
  min-height: 220px;
  border-radius: 8px;
  background: var(--surface-muted);
}
ds-chart:not(:defined)::after {
  content: 'Loading chart…';
  display: grid;
  place-items: center;
  height: 100%;
  font-size: 0.875rem;
}
Layout stability with and without a sized placeholder Two page mock-ups. On the left the undefined element occupies no height so the paragraph below sits high, and an arrow shows it jumping down once the element defines. On the right a sized placeholder holds the space so the paragraph never moves. no reserved size sized placeholder undefined element: 0 px tall content jumps 220 px when it defines min-height reserved nothing below ever moves The upgrade window where failures are invisible A timeline with four markers: element parsed, definition script loaded, element upgraded, and connectedCallback run. The span between parsing and upgrade is shaded and labelled undefined, zero height without a sizing rule. A note marks that a failure during upgrade produces no exception in application code. :not(:defined) — inline, zero height without a CSS rule parsed definition loads upgraded connectedCallback a throw here never reaches a framework boundary it lands on window.onerror — hence the internal try/catch if the definition never loads, the timeline simply stops at the first marker

Verification #

  1. Feed the element malformed data. Set data-series="{" and confirm the shadow root shows “Chart unavailable”, the host receives ds-error, and nothing else on the page changes.
  2. Block the defining script. Confirm the placeholder holds its height for the timeout window and then the framework fallback appears, with no cumulative layout shift.
  3. Measure CLS. Run a Lighthouse trace on a page containing the element; the placeholder rule should keep the layout-shift contribution at zero, matching the approach in Implementing Fallback Rendering Without Layout Shift.
  4. Check listener cleanup. Mount and unmount the element fifty times in the console, then look at DevTools → Memory for retained ResizeObserver instances; the count must not grow.
  5. Confirm the crash was reported. One web-component:ds-chart scoped report per failure, no duplicates from the resize loop re-throwing.

Frequently asked questions #

Why does my React error boundary not catch a custom element failure?

Because lifecycle callbacks run in the browser’s custom-element reactions, not inside React’s render. React only rendered a tag name, so the throw goes to window.onerror instead of a boundary.

Does a shadow root stop a crash from spreading?

It contains styles and DOM, not JavaScript. Markup and CSS cannot bleed into the page, but an exception still reaches the global handler and any observer the element registered keeps running unless it cleans up.

What should render while a custom element is still undefined?

A sized placeholder using :not(:defined). Undefined elements are inline and zero-height, so content below jumps when the definition arrives.