Skip to content
LogoLogo

Failure isolation

Every declared contribution renders inside ContributionBoundary: contribution identity, an error boundary, and a Suspense boundary. That isolation is the host's job, not the contribution author's: a plugin comes from somewhere else, and the application is the one that has to stay up.

On the client

The boundary catches, Failed renders in place of the contribution, and onError reports it.

// `Failed` is a component, not a render prop: its identity is stable, and a
// component reference crosses an RSC boundary where a closure cannot.
function ({
  ,
  ,
  ,
}:  & { : () => void }) {
  return (
    < ="alert">
      <>
        {} could not render: {()}
      </>
      {/* There is no automatic reset. Recovery is this button. */}
      < ="button" ={}>
        Try again
      </>
    </>
  )
}
 
export function () {
  return (
    <
      ={()}
      ={}
      ={}
    >
      < ={} />
    </>
  )
}
 
function ({ , , ,  }: ) {
  .(`[${}/${}] failed in "${}"`, )
}

onError receives the plugin id, the contribution id, the slot name and the error — enough to attribute a failure to a feature without the feature cooperating.

propwhen it runswhat it gets
onErroronce, when the boundary catches{ pluginId, contributionId, slot, error }
Failedon every render while the boundary holds an errorthe same, plus reset

Failed is a component, not a render prop: a component reference has stable identity and crosses an RSC boundary, where a closure cannot. The handlers live in a context of their own, read only where a contribution is isolated, so an inline onError arrow never re-renders a host. See Performance.

What a boundary does not catch

The boundary is React's, so its limits are React's. It catches errors thrown during render, in a lifecycle method, or in a constructor below it. It does not catch:

  • an error thrown in an event handler — onClick, onSubmit, and the rest;
  • a rejected promise that nothing awaits inside render;
  • an error thrown inside setTimeout or an effect's async continuation;
  • anything thrown by the host or the layout itself, which is above the boundary.

A contribution that does async work handles its own failures, or throws during the next render so the boundary can see it.

Pending

The same Suspense boundary has a fallback: Pending, a component receiving { pluginId, contributionId, slot }, renders while a deferred contribution loads. Unset it and the fallback is null — byte-for-byte the v3 shell. Set it knowing the trade: the skeleton ships in the streamed shell and React swaps it out — HTML size and layout shift for earlier paint.

On the server

getDerivedStateFromError never runs during a server render, so the class boundary cannot catch there. The Suspense boundary still does its job:

  1. React marks just that boundary for a client render — <!--$!--> in the HTML.
  2. The rest of the markup is kept and sent.
  3. The client re-renders that one contribution, where the class boundary finally catches it and Failed takes over.

One broken contribution costs its own line, not the page. This holds for both renderToString and renderToPipeableStream.

There is no automatic reset

Recovery is the explicit reset() handed to Failed, and nothing else.

Resetting on element identity would loop: a host that re-renders in response to onError produces a fresh element every time, so the boundary would clear, throw, clear, throw. The boundary sits directly around the contribution in the same commit, so React's own behaviour is enough — a button is the honest alternative to a loop.

Hand-rolled hosts keep the same semantics

ContributionBoundary is exported so a host you write yourself — a renderEntries layout, or an RSC server host mapping a Resolution with entriesOf — isolates each entry exactly as the default host does. It reads onError, Failed and Pending from the nearest SlotProvider.

Façade fills get only Suspense

A façade host wraps each fill in Suspense with a null fallback. This is a safety boundary: without it, a suspended host can hide the subtree that registered the fill, remove that registration, reveal it and repeat forever.

There is still no error boundary and no contribution identity. A fill is your own code in your own tree, not a third party's contribution, so failure isolation remains yours. Add an inner Suspense when the pending state needs visible UI.

// A façade fill gets only a null-fallback Suspense boundary from its host. It
// is your own code in your own tree, so bring your own error boundary when it
// can throw.
const  = ()
 
export function () {
  return (
    <>
      <>
        < />
      </>
    </>
  )
}

If a fill can throw, bring your own error boundary. The SPA example does exactly that — its crash-test feature ships one, because the runtime channel leaves errors to you.

Per-contribution identity

While a contribution renders, useContribution is the one thing only the library knows: which contribution it is — slot, plugin id, contribution id.

// The one thing only the library knows while a contribution renders: which
// contribution it is. Per-plugin stores, loggers and settings namespaces all
// hang off `useContribution().pluginId`.
function () {
  const {  } = ()
  const  = ()
 
  return <>{.}</>
}
 
export const  = ({
  : "reporting",
  : [
    .("card", { : 10, :  }),
  ],
})

It throws outside a contribution, which is what makes it usable as the key to a per-plugin store, logger or settings namespace.

Next