Slot reference
Contextual menus, component payloads and programmatic attachment.
Read the small toolbar lesson first. The independent examples below assume a reports plugin and the named host integrations.
Define an extension point
// src/plugins/reports/slots/row/menu.ts
import { defineSlot } from '@bitakit/core';
import { z } from 'zod';
export default defineSlot({
accepts: 'actions',
payload: z.object({ reportId: z.string() }),
});The name is reports.row.menu. A flat slots/row.menu.ts, an index collection or manual
definePlugin({ id: 'reports', slots: { row: { menu } } }) produces the same contract.
Policies
policy | Renders |
|---|---|
list (default) | Every contribution, sorted by order |
keyed | The contribution whose key the outlet asks for with entryKey; keys must be unique |
single | One candidate: a replaceable part such as layout.auth or screen.not-found |
A single slot accepts candidates from several plugins and renders one: the app's choose
entry in bitakit.config.ts ('<plugin>', '<plugin>:<key>' or '<key>'), else the app's
own contribution, else the only candidate. Several candidates without a decision render the
outlet's fallback, report the candidates once in the console and make doctor ask for a
decision. The owner renders the part with its default:
<PluginSlot name="billing.plan-card" fallback={<DefaultPlanCard />} />children given to PluginSlot reach the contribution, which is how layouts wrap pages. A
contribution that throws is replaced by the fallback. Before the runtime has started, a single
slot already renders its selected file contribution, so the default does not flash.
Bind each occurrence
// src/plugins/audit/actions/inspect.ts
import { defineAction } from '@bitakit/core';
export default defineAction<string>({
title: ({ input }) => (input ? `Inspect ${input}` : 'Inspect'),
slots: [
{
slot: 'reports.row.menu',
input: ({ payload }) => payload.reportId,
},
],
execute: ({ input }) => console.log(input),
});// Inside the owner's existing DropdownMenuContent.
const menu = useSlot('reports.row.menu', { payload: { reportId } });
if (menu.error) throw menu.error;
return menu.items.map((item) => (
<Action key={item.key} id={item.action} kind="menuItem">
<DropdownMenuItem />
</Action>
));item.action retains the mapped input. Labels, guards and pending state remain live. Each
outlet validates its own payload; pending or invalid payloads expose no items. Keep nested
payload references stable. Navigation Actions use real anchors and kind="link".
The organization UI already owns organizations.member.menu, with
{ organizationId, memberId }. Its row renderer uses the existing themed dropdown and bindings.
Add a custom widget
// src/plugins/reports/ui/slots/sidebar.footer.tsx
import type { SlotProps } from '@bitakit/ui';
export const order = 20;
export default function Footer({ payload }: SlotProps<'sidebar.footer'>) {
return <span>{payload.collapsed ? 'R' : 'Reports available'}</span>;
}// Owner rendering a component slot.
<PluginSlot name="sidebar.footer" payload={{ collapsed }} />A component slot accepts custom UI. An Action slot keeps the owner's standard menu/navigation
rendering. useSlot also supports typed data slots and returns { items, payload, pending, phase, error }.
phase is the declared owner's lifecycle state (absent for legacy outlets). A failed/blocked
owner is unavailable rather than indefinitely pending; error reports outlet validation/binding failures.
Unmounting an outlet does not remove contributions; stopping their plugin does.
A widget fails to render
No extra setup is needed. PluginSlot isolates each component contribution, shows an attributed
fallback and keeps its siblings mounted. The retry button uses the host's existing Button.
<PluginSlot name="sidebar.footer" payload={{ collapsed }} />If this outlet needs different recovery copy, add only an errorFallback:
<PluginSlot
name="sidebar.footer"
payload={{ collapsed }}
errorFallback={({ error, retry }) => (
<div role="alert">
<p>Widget from {error.plugin} is unavailable.</p>
<Button onClick={retry}>Try again</Button>
</div>
)}
/>Button above is your existing design-system Button. error is a SlotRenderError with
plugin, slot, contribution and the original cause. The default fallback omits raw error
messages/stacks. Existing BitaKitRuntimeProvider actionOptions.onError receives the attributed
error for local reporting; it is not added to Core inventory or settings.
| Situation | Behavior |
|---|---|
| Click retry | Retry this occurrence once; a repeated failure shows the fallback again. |
| Replace the contributing plugin definition or component | Reset the failed occurrence. |
| Rerender with an equivalent stabilized payload or update another plugin | Keep the existing error; no automatic retry loop. |
| Hide/remove the contribution, revalidate changed context or unmount the outlet | Remove its boundary; Core attachment ownership is unchanged. |
Invalid payload / failing when / Action input mapper | Existing useSlot().error behavior; not a widget render failure. |
| Pending Suspense / event handler / later async work / server render | Use the corresponding loading, execution or framework error mechanism. |
Advanced custom renderers using useSlot can put SlotErrorBoundary around one contribution.
Do this inside any access boundary, using the entry's stable key and owning plugin:
<SlotErrorBoundary
key={entry.key}
slot="sidebar.footer"
plugin={entry.plugin}
contribution={entry.key}
>
<Widget payload={payload} />
</SlotErrorBoundary>App Preferences does this automatically for generated/custom bodies and extension widgets. A body's error names the settings page's owning plugin. Recovery remains inside the page's existing access boundary; hiding a fallback never grants access. A boundary protects its child render tree, not the plugin's services, event handlers or its own failing fallback.
Optional host integration
// setup.ts — reuse an already registered Action in a host that may provide Shell.
import type { Setup } from '@bitakit/core';
const setup: Setup = ({ scope }) => {
if (scope.slots.has('shell.navigation')) {
scope.slots.attach(
{ slot: 'shell.navigation', actionId: 'reports.open' },
{
availability: 'wait',
},
);
}
};
export default setup;Use one placement path per target. The scope owns cleanup. Readiness events notify observers; they are not registration commands. Settings page/body projection remains a separate integration.
Find out why a slot is empty
Start in the component that already renders the slot:
const view = useSlot('shell.navigation');
console.info(view.diagnostics.reason);When needed, inspect view.diagnostics.excluded: each entry names its plugin, contribution
and reason (key, condition, action-hidden, action-unavailable, or not-selected for
the other candidates of a single slot). A single slot without an occupant reports ambiguous
or choice-unavailable as its reason and lists candidates. No payload is copied into
diagnostics. selected counts eligible entries, not successful or visible renders.
The existing error boundary reports rendering errors separately.
For a slow plugin, inspect runtime.lifecycle.get().operation and the plugin/service
since timestamps. Devtools displays elapsed time. A timeout is a diagnostic, never
permission to skip cleanup. Headless/CLI inspection cannot know which React outlets are mounted.