Performance
A host re-renders whenever anything above it does, and it hands every contribution the props it was given. Left alone, that makes one feature's state change everyone else's render: a toolbar with a hundred contributions would render all hundred because a search box beside it took a keystroke.
So the host compares, twice. It holds its own props steady for as long as their
values are, and it compares each resolved entry by content — id, rank,
component identity — because resolvePlugins mints fresh entry objects on
every call and an application is allowed to call it inline on every render.
A host whose props and entries did not really change re-renders nothing.
That is held as budgets in the library's own render-count tests
(lib/perf.test.tsx):
- an inline
resolvePlugins()per render re-renders hosts and boundary shells, and no contribution renders, remounts or commits anything; - inline
onError/Failed/Pendingon the provider re-render nothing below the thin boundary shells.
Neither rule below is required for correctness — a broken one costs render time, never a wrong result.
Hold the Resolution anyway
The Resolution is identity-compared by the provider, and entries are content-compared by each entry shell — so resolving inline is legal and cheap. Holding it is one line, and it spares the hosts and shells too.
export function () {
const [, ] = ("")
return (
// A fresh Resolution on every keystroke. Legal, and cheaper than it looks:
// entries are compared by content, so hosts and boundary shells re-render
// and no contribution renders, remounts or commits anything.
< ={(.())}>
< ={} ={() => (..)} />
< ={} ={{ : 1 }} />
</>
)
}The one-line version:
export function () {
const [, ] = ("")
// One line spares the hosts and shells too. Module scope works as well.
const = (
() => (.()),
[],
)
return (
<
={}
// Exempt: handlers live in a context of their own, read only where a
// contribution is isolated, so an inline arrow never reaches a host.
={({ }) => ()}
>
< ={} ={() => (..)} />
{/* `zoom: 1` is a value, so re-checking it is free. */}
< ={} ={{ : 1 }} />
</>
)
}onError, Failed and Pending are exempt. They live in a context of their
own, read only where a contribution is isolated, so writing an inline handler —
which is how everyone writes them — never reaches a host.
Pass host props by value where you can
A number costs nothing to re-check. An object rebuilt each render counts as a
change, exactly as it would to memo.
export function ({ , }: ) {
// `zoom` is a number, so re-checking it is free. `theme` is a new object on
// every render, so every contribution counts it as a change — exactly as
// `memo` would.
return < ={} ={{ , : { } }} />
}
export function ({ , }: ) {
const = (() => ({ }), [])
return < ={} ={{ , }} />
}This makes an unchanged host free; it never makes a changed one wrong.
A contribution is still plain data
// Still plain data. `contribute()` hands back the component you passed,
// untouched; the memoised view belongs to the host and is cached on your
// component. The React key is the full id "search/search-box", so inserting
// or removing a neighbouring contribution never remounts this one.
export const = ({
: "search",
: [
.("search-box", { : 10, : }),
],
})contribute() hands back the component you passed, untouched. The memoised
view belongs to the host and is cached on your component, so a rebuilt
Resolution never remounts it.
The React key is the full id pluginId/contributionId — stable data, not a
position. Inserting or removing a neighbouring contribution never remounts the
others, so the v3 rule about fixed-shape contributes arrays is gone.
Façade fills
A fill's element is cloned once with a stable key and held in the factory's store, so a fill whose content changes reconciles against the old element rather than remounting.
Façade hosts read the store with useSyncExternalStore and get a cached
snapshot, rebuilt only when a fill mounts or unmounts. A factory with no fills
returns one shared empty array, so an idle façade host never re-renders because
of the runtime channel.
Fills are not memoised the way declared contributions are: the host renders the element it was handed, and that element's identity is the one the fill committed. Whatever wraps it inside your own tree decides the rest.
What each channel costs
| Registry | Façade | |
|---|---|---|
| Work per plugin-list change | one resolvePlugins call: group, patch, sort | — |
| Work per host render, props unchanged | none — entries content-compared, contributions memoised | none — the snapshot is cached |
| Work per host render, props changed | one render per contribution | one render per fill |
| Work per fill mount or unmount | — | re-sort that factory's store, re-render its hosts |
| What your application must hold stable | nothing — holding the Resolution is an optimisation | nothing |
Measuring it yourself
Render counts, not milliseconds, are what to assert on. The library's own suite does exactly that: it counts renders per contribution across a host update and fails if the count grows.
npm run test:run # render-count budgets
npm run bench # comparative mount and update cost, 10 and 100 contributions