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.
| prop | when it runs | what it gets |
|---|---|---|
onError | once, when the boundary catches | { pluginId, contributionId, slot, error } |
Failed | on every render while the boundary holds an error | the 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
setTimeoutor 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:
- React marks just that boundary for a client render —
<!--$!-->in the HTML. - The rest of the markup is kept and sent.
- The client re-renders that one contribution, where the class boundary
finally catches it and
Failedtakes 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
- Server rendering — the SSR contract and streaming
- Two channels — the rest of what the two channels differ on
- Errors — every message the library itself throws