Recipes
Visibility
There is no when predicate. A contribution decides for itself, with a plain
if:
// There is no `when` predicate. A contribution decides for itself, and it is an
// ordinary component, so the condition can read anything a component can.
export function ({ }: { : string }) {
const = ("archive")
if ( !== "closed" || !) {
return null
}
return < ="button">Archive</>
}The contribution is an ordinary component, so the condition can use hooks, context, a store or a feature flag. A predicate in the manifest could not — it would run outside React, before any of that exists.
The cost is that the host cannot know how many contributions produced output. See empty states below.
Turning a feature off
The two channels differ here, and both are one expression:
- Registry — resolve from a filtered list, or keep the list whole and
disableby id. Whatever decides it must produce the same answer on the server and on the client; see the SSR contract. - Façade — stop rendering the component that fills. Installing a feature is
mounting it, so uninstalling is
&&.
// Declarative: resolve from the filtered list, or keep the list whole and
// `disable` by id — a typo in an id comes back as a diagnostic, not a silent
// no-op. Runtime: uninstalling a feature is not rendering it.
export function ({
,
,
}: {
: readonly string[]
: boolean
}) {
const = (
() => (, { : { : } }),
[],
)
return (
< ={}>
< />
{ && < />}
</>
)
}There is no unregister call in either case, and no array for the library to
keep in sync with the tree.
Splitting a plugin module
A façade plugin is a module, so lazy moves all of it into one chunk. The fills
move with it. A plugin that is off is never mounted, so the browser never
requests its chunk.
// A façade plugin is a module, so `lazy` moves all of it into its own chunk —
// the fills with it. A plugin that is off is never mounted, so the browser never
// requests its chunk.
const = (() => import("./telephony-plugin"))
export function ({ }: { : boolean }) {
return (
<>
{ && (
// The plugin renders nothing at its own position, so `null` is the
// correct fallback here.
< ={null}>
< />
</>
)}
<>
{/* No fill is registered while the chunk loads, so the host shows this
placeholder instead of a gap. */}
<.>
<>Nothing has claimed the status bar</>
</.>
</>
</>
)
}While the chunk loads, the plugin has registered no fill. The factory's host has no entries, so it renders its own children. You get the placeholder you already wrote, not a gap.
The runtime channel never reaches server markup, so SSR needs nothing more here. See what is missing from the HTML.
Splitting a declared contribution
On the registry only the component moves. The manifest stays in the initial bundle, and it has to. The application reads it to resolve the graph, and to assemble state before it renders. A contribution whose component loads in a later chunk is a deferred contribution.
Two ways to fill the wait: set Pending on the provider — one skeleton for
every deferred contribution, with identity props to vary it — or give the
contribution its own Suspense boundary for a skeleton of its own.
// Only the component is deferred. The manifest stays in the initial bundle,
// because the application has to read it to filter and to build its state.
// The provider's `Pending` fills the wait; an inner `Suspense` gives this one
// contribution a skeleton of its own.
const = (() => import("./panel"))
export const = ({
: "notes",
: [
.("panel", {
: 10,
: () => (
< ={< />}>
< {...} />
</>
),
}),
],
})Move the components. Keep in the manifest module everything the application reads before it renders:
- a reducer, or a store factory
- an exclusive claim, such as a saved view or a route
- a
setupfunction - a
preloadfunction
A store whose shape depends on which plugins are on cannot be preloaded by a server that does not know the answer yet. See Plugin state.
The shorter form leaves a gap
// This compiles, and it is shorter. One deferred contribution is still one
// entry, so the host skips its placeholder — and with no `Pending` set on the
// provider, the boundary renders `null` until the chunk arrives.
export const = ({
: "notes-with-a-gap",
: [
.("panel", { : 10, : }),
],
})One deferred contribution is still one entry in the slot. A host renders its
children only while it has no contributions at all, so the placeholder does
not appear. With no Pending set, the boundary renders null and the slot
stays empty until the chunk arrives.
Use this form only when Pending is set, or when the contribution is small
enough that nobody sees the gap.
What each render path produces
| Render path | A chunk that is not loaded gives you |
|---|---|
renderToString | Nothing you can use. It does not support Suspense, so React marks the boundary for a client render. The fallback becomes placeholder markup, and a contribution with no fallback emits nothing. |
renderToPipeableStream | The shell carries the fallback. The body arrives in a later chunk. |
| A client render | Pending, or the contribution's own Suspense fallback. Nothing, when neither is set. |
A render that does not stream cannot put a deferred contribution in the HTML at all. See a deferred contribution.
One contribution, one host per row
A slot can have any number of hosts mounted at once. Each renders the same contributions and hands them its own props, so one piece of code can opt out of one row and not the next.
// One contribution, one host per row. A declared contribution receives the
// host's props as its own, so the same code can opt out of one row and not the
// next.
export function ({
,
}: {
: { : string; : boolean }[]
}) {
return (
<>
{.(() => (
< ={.}>
{.}
<
={}
={{ : ., : . }}
/>
</>
))}
</>
)
}
function ({ }: { : string; : boolean }) {
// Visibility is a plain `if`, per host.
return ? <>Selected</> : null
}
export const = ({
: "badges",
: [
.("badge", { : 10, : }),
],
})On the façade the same pattern works through useProps(), which reflects the
host doing the rendering rather than the place the element was written. See
Slots, hosts & fills.
Empty states
A host renders its children while nothing is contributed, which is not the
same as nothing producing output. If three contributions exist and all three
return null, the host has contributions, so the placeholder does not render.
This is the acknowledged cost of having exactly one visibility mechanism. It also has a one-line answer, because host wrappers that render nothing emit no DOM — so the container really is empty:
// A host renders its children while nothing is *contributed*, which is not the
// same as nothing producing output. When every contribution returns `null` the
// container really is empty, so CSS covers the visual case.
export const = `
.deal-actions:empty::before {
content: "No actions available";
color: var(--muted);
}
`For full ownership of the empty state — a count, a call to action — use
renderEntries, which is called with zero entries too.
Exclusive claims
"Exactly one owner" — a saved view, a route, a settings page — is a routing problem, not a slot problem. Keep the claim in the manifest as an application field and resolve it into one table before render.
// "Exactly one owner" is a routing problem, not a slot problem. Keep the claim
// as an application field and resolve it into one table before render, so the
// refusal is reported instead of silently last-wins.
type = & {
?: <string, >
}
export function (: readonly []) {
const = new <string, { : string; : }>()
const : { : string; : string; : string }[] = []
for (const of ) {
for (const [, ] of .(. ?? {})) {
const = .()
if () {
.({ : ., , : . })
continue
}
.(, { : ., })
}
}
return { , }
}That check is stronger than anything the library could offer, because it knows the actual keys, it decides who wins, and it can report the plugin it refused instead of silently letting the last one win.
Inventory
There is no inventory helper because the manifest is data. An inventory is a
.map over an array your application owns — and Resolution.slots is the same
data after disable and override, if the resolved view is the one you want.
// There is no inventory helper because the manifest *is* data.
export function (: readonly []) {
return .(() => ({
: .,
: (. ?? []),
}))
}
function (: readonly []) {
const = new <string, number>()
for (const of ) {
.(., (.(.) ?? 0) + 1)
}
return .()
}Useful for a plugin settings screen, a support diagnostic, or a test that asserts a release did not quietly drop a contribution.
Testing a contribution
A contribution is an ordinary component, so the cheapest test renders it directly with the props the host would give it. No provider, no slot, no library involved.
To test the wiring instead — that a plugin reaches the right slot, in the right place — resolve, render the host inside a provider, and assert on the output:
// Testing the wiring: resolve, hand the Resolution to a provider, and assert
// on the host's output.
const = ({
: "search",
: [
.("button", {
: 10,
: () => <>Search</>,
}),
],
})
const = ({
: "filters",
: [
.("button", {
: 20,
: () => <>Filters</>,
}),
],
})
("plugins render in order", () => {
const { } = (
< ={([, ])}>
<>
< ={} />
</>
</>,
)
// Ranked by `order`, not by position in the array.
(
[....("li")].(() => .),
).(["Search", "Filters"])
})Two more assertions worth having in a real suite, because both are silent when they break:
// The catalog validator is one line: the resolver reports every manifest
// defect — duplicate ids, invalid ids, unknown disable and override targets —
// as data instead of throwing.
("the catalog is clean", () => {
(([, ]).).([])
})- The diagnostics. One line, and it catches duplicate ids, invalid ids and overrides that no longer target anything.
- The placeholder. Render the host over
resolvePlugins([])and assert the children appear — that is the only signal that a slot name still matches.
A façade fill needs one extra step: it registers from an effect, so assert after the effect has run rather than synchronously after the first render.