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>
);
}
Step-by-step #
- 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. - Wrap every lifecycle callback.
connectedCallbackandattributeChangedCallbackare the two that run arbitrary user data through your code; both need their owntry/catch. - 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.
- Dispatch a
composedevent. Withoutcomposed: truethe event stops at the shadow boundary and the host never learns anything. This event is the only supported bridge back into framework land. - Always clean up in
disconnectedCallback. A failed element that keeps aResizeObserveralive turns one bug into a slow leak — the discipline described in State Reset & Cleanup Protocols. - Time out the definition. If the defining script never loads,
whenDefinednever 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;
}
Verification #
- Feed the element malformed data. Set
data-series="{"and confirm the shadow root shows “Chart unavailable”, the host receivesds-error, and nothing else on the page changes. - 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.
- 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.
- Check listener cleanup. Mount and unmount the element fifty times in the console, then look at DevTools → Memory for retained
ResizeObserverinstances; the count must not grow. - Confirm the crash was reported. One
web-component:ds-chartscoped 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.
Related #
- Component Isolation Techniques — the containment model behind this pattern
- Isolating Third-Party Widget Crashes with a Sandbox Boundary — stronger isolation when you do not own the code
- Implementing Fallback Rendering Without Layout Shift — keeping the placeholder honest about size