Skip to content
LogoLogo

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/Pending on 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

RegistryFaçade
Work per plugin-list changeone resolvePlugins call: group, patch, sort—
Work per host render, props unchangednone — entries content-compared, contributions memoisednone — the snapshot is cached
Work per host render, props changedone render per contributionone render per fill
Work per fill mount or unmount—re-sort that factory's store, re-render its hosts
What your application must hold stablenothing — holding the Resolution is an optimisationnothing

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