# create-slot A React plugin registry: features declare UI contributions, and the application renders them at named points without importing the features. # create-slot **Features declare what they render. Layouts decide where it appears.** A feature contributes UI to a named slot. A layout mounts a host for that slot and gets every contribution, in order, with its own props. Neither side imports the other, and nothing is drilled through the tree to connect them. ```bash npm install create-slot ``` ## See it work Toggle a feature. It is mounted somewhere else entirely — the sidebar never imports it, and it never imports the sidebar. The host renders its own children until something is contributed, and every contributed item reads the host's live props. The live sidebar demo runs only in a browser. Toggling a feature mounts a component that contributes a menu item to a host it never imports, and every item reads the host's own props. Source: [docs-site/src/components/slot-basics-demo.client.tsx](https://github.com/r13v/create-slot/blob/main/docs-site/src/components/slot-basics-demo.client.tsx) ```tsx twoslash // [!include ~/snippets/quick-start.tsx:slots] // [!include ~/snippets/quick-start.tsx:host] // [!include ~/snippets/quick-start.tsx:fill] // [!include ~/snippets/quick-start.tsx:props] ``` ## Two channels A contribution can be registered in two ways, and the difference is what a contribution *is*. | | [Declarative](/registry) | [Runtime](/slots#the-runtime-channel) | | --- | --- | --- | | A contribution is | data plus a component, under an id | an element, from where it is mounted | | Registered | before render, by `resolvePlugins` | from an effect, while mounted | | In server HTML | yes | no | | Addressable | `disable`, `override`, diagnostics — by full id | no identity | | Shared state inside one feature | a store | ordinary `useState` | | Per-contribution isolation | identity + error boundary + `Suspense` | `Suspense` with a `null` fallback | | API | `defineSlot`, `definePlugin`, `resolvePlugins`, `SlotProvider`, `SlotHost` | `createSlot` | Each channel keeps its own hosts — the registry never merges fills — and a single application uses both: **the registry for anything that must be in the HTML, the façade for chrome that depends on live tree state.** [Two channels](/channels) is the whole comparison, with the decision written out. The declarative half of the same idea — a named slot, a plugin that declares a contribution under an id, one `resolvePlugins` call, and a provider the application mounts once: ```tsx twoslash // [!include ~/snippets/registry-guide.tsx:slots] // [!include ~/snippets/registry-guide.tsx:plugin] // [!include ~/snippets/registry-guide.tsx:provider] ``` The Resolution is plain data, and `resolvePlugins` is React-free — a server component can resolve the graph and [hand it across the RSC boundary whole](/server-rendering#react-server-components). ## What it does not do The library knows who contributes what, in what order, and what happens when a contribution breaks. Everything else is yours: * **No `when` predicate.** A contribution returns `null`. * **No exclusive slots and no routing.** "Exactly one owner" is a table your application [resolves before render](/recipes#exclusive-claims), where it can report the plugin it refused. * **No inventory helper.** The manifest *is* data, so an inventory is a `.map`. * **No state, no lifecycle, no command registry.** The manifest is open data you [extend with your own fields](/state), and they keep their types. Zero dependencies, two entry points — one of them React-free — and any React with `useSyncExternalStore`: 18 and up. ## Where to go next 1. **[Get started](/get-started)** — a first contribution in three steps. 2. **[Slots, hosts & fills](/slots)** — the four words this library uses, and what each one does. 3. **[Plugin registry](/registry)** — the declarative channel end to end, and what you need for [server rendering](/server-rendering). 4. **[API](/api)** — every export, with the types twoslash infers from the published package. # Get started Contribute your first piece of UI in three steps. ## Pick the channel first `create-slot` delivers a contribution to a host through two channels, and the choice is the first thing to make, not the last: | | [Declarative](/registry) | [Runtime](#1-define-the-slot) | | --- | --- | --- | | API | `defineSlot`, `definePlugin`, `resolvePlugins`, `SlotProvider`, `SlotHost` | `createSlot` | | In server-rendered HTML | **yes** | no | | Needs a provider | yes | no | | Steps on this page | [the last section](#the-same-feature-declared) | the next three | :::warning **A runtime contribution is never in server-rendered HTML.** Its server and first hydration snapshots are intentionally empty. Registration happens in an effect after hydration, so the markup carries each host's placeholder and the contributions replace it on the client. If the contribution is navigation, above-the-fold content, or anything a crawler reads, go to the [plugin registry](/registry) instead. That is what it exists for, and it is the primary channel. ::: This page teaches the runtime channel, because it is the shortest path and needs no provider. [Two channels](/channels) is the full comparison. ## Install ```bash npm install create-slot ``` The package has no dependencies and two entry points: `create-slot`, and the React-free `create-slot/core` for server modules. React is a peer dependency; anything with `useSyncExternalStore` works, so React 18 and up. ## 1. Define the slot A slot is a name plus a props type. The props are the host's — whatever the place that renders contributions knows and the contributions might want. ```tsx twoslash // [!include ~/snippets/quick-start.tsx:slots] ``` Define slots in their own module. It is the only thing both sides import, which is what keeps the feature and the layout from importing each other. ## 2. Mount a host The host is where contributions render. Its own children are the placeholder: they render only while nothing is contributed, so an empty state costs no extra API. ```tsx twoslash // [!include ~/snippets/quick-start.tsx:slots] // ---cut--- // [!include ~/snippets/quick-start.tsx:host] ``` You can mount as many hosts for one slot as you like — one per row of a table, for instance. Each renders the same contributions, with its own props. ## 3. Contribute from anywhere The slot component itself is the contributor. It renders nothing where it sits; it registers its child into the slot for as long as it is mounted. ```tsx twoslash // [!include ~/snippets/quick-start.tsx:slots] // [!include ~/snippets/quick-start.tsx:props] // ---cut--- // [!include ~/snippets/quick-start.tsx:fill] ``` `order` is a priority, not an array index. Leave gaps — `10`, `20`, `30` — so a feature can be inserted later without renumbering anything. See [Ordering](/ordering). ## Read the host's props The contributed element is written next to the feature but rendered where the host is, so it reads the host's props through a hook rather than receiving them: ```tsx twoslash // [!include ~/snippets/quick-start.tsx:slots] // ---cut--- // [!include ~/snippets/quick-start.tsx:props] ``` That is what makes one contribution adapt to many hosts: `useProps` reflects the host doing the rendering, not the place the element was written. ## Install the feature Installing a feature is mounting it. There is no registration call, no provider, and no array to keep in sync — switching a feature off is a `&&` that stops rendering it. ```tsx twoslash // [!include ~/snippets/quick-start.tsx:slots] // [!include ~/snippets/quick-start.tsx:host] // [!include ~/snippets/quick-start.tsx:fill] // [!include ~/snippets/quick-start.tsx:props] // ---cut--- // [!include ~/snippets/quick-start.tsx:app] ``` ## The same feature, declared The declarative channel is the same three ideas — a slot, a host, a contribution — with one difference: the contribution is **data plus a component**, so the resolver enumerates it before render and the server can put it in the HTML. Five changes to the code above: 1. The slot gains a **name**: `defineSlot("nav-menu")`. 2. The fill becomes a **contribution** with a required id: `NavMenu.contribute("nav-item", { order, component })`. 3. Contributions are grouped into a **plugin** with a unique `id`. 4. The application calls **`resolvePlugins`** once, turning the plugin list into a `Resolution`. 5. The application mounts **`SlotProvider`** with that Resolution, and the host becomes **`SlotHost`**. ```tsx twoslash // [!include ~/snippets/registry-guide.tsx:slots] // ---cut--- // [!include ~/snippets/registry-guide.tsx:plugin] ``` The contribution receives the host's props as its own, so there is no `useProps` in this version. Resolve once, and mount the provider above every host: ```tsx twoslash // [!include ~/snippets/registry-guide.tsx:slots] // [!include ~/snippets/registry-guide.tsx:plugin] // ---cut--- // [!include ~/snippets/registry-guide.tsx:provider] ``` The two channels keep their own hosts — the registry never merges fills — but one application uses both freely. See [Two channels](/channels). ## Next * [Slots, hosts & fills](/slots) — placeholders, multiple hosts, `useProps` * [Two channels](/channels) — the full comparison, with the decision written out * [Plugin registry](/registry) — the declarative channel end to end * [API](/api) — every export, with its inferred types * [Errors](/errors) — every message the library throws, and its fix # Slots, hosts & fills Four words, used consistently everywhere in this library: * **Slot** — a named extension point. A name plus a props type, never a component — and since 4.0, literally: `defineSlot` returns a plain descriptor. * **Host** — the component that renders every contribution to one slot (`SlotHost`). Its own children are the placeholder. A slot can have many hosts mounted at once. * **Contribution** — one piece of UI addressed to a slot, declared with a required id. * **Fill** — the [runtime channel](/channels)'s contributor: the element a `createSlot()` slot registers while mounted. A façade-only concept. ## Defining a slot ```tsx twoslash // [!include ~/snippets/slots-guide.tsx:define] ``` The name is the Resolution's key, so it must be non-empty and unique across your application. Keep slot definitions in their own module: it is the only thing both the layout and the features import, which is what keeps them from importing each other. The descriptor is pure data from the React-free `create-slot/core`, so server modules can import it too. The type parameter is the **host's** props — whatever the place that renders contributions knows and a contribution might want. `children` is not part of it; children belong to the host. ## Mounting a host ```tsx twoslash // [!include ~/snippets/slots-guide.tsx:define] // ---cut--- // [!include ~/snippets/slots-guide.tsx:host] ``` `props` is an explicit bag, not a spread — so the host's own props (`slot`, `children`, `renderEntries`) can never collide with a slot's, and `children` structurally cannot leak into a contribution. The host's children are the placeholder, and there is no separate API for one. They render while nothing is *contributed* — which is not the same as nothing producing output. A slot whose contributions all returned `null` has no children rendered and no DOM either, so the container really is empty; see [the empty-state recipe](/recipes#empty-states). A host without a `SlotProvider` above it throws — a missing provider is a mistake worth reporting, not an empty slot. ## Contributing A declared contribution is an ordinary component. It receives the host's props as its own, and it decides its own visibility with a plain `if` — there is no `when` predicate. ```tsx twoslash // [!include ~/snippets/slots-guide.tsx:define] // ---cut--- // [!include ~/snippets/slots-guide.tsx:contribution] ``` ## The runtime channel The runtime channel lives inside the [`createSlot()` façade](/api#createslot): the factory's slot component contributes its child element from wherever it is mounted, for as long as it is mounted. The element is *written* next to the feature but *rendered* where the host is, so it reads the host's props through `useProps` rather than receiving them. ```tsx twoslash // [!include ~/snippets/slots-guide.tsx:define] // ---cut--- // [!include ~/snippets/slots-guide.tsx:fill] ``` Two things worth knowing: * A façade's `useProps` is never null — a `Slot`'s children only ever render inside one of its factory's hosts. The adapter's `useSlotProps(slot)` is the nullable one, because nothing guarantees a host above its caller. * `order` and the element's React key are read once, on mount. Changing `order` later leaves the fill in place; changing its *content* reconciles instead of remounting. See [Ordering](/ordering#read-once). ## Many hosts, one contribution A slot can have any number of hosts mounted at once. Each renders the same contributions, and each hands them its own props — which is what makes one contribution adapt per row. ```tsx twoslash // [!include ~/snippets/slots-guide.tsx:define] // [!include ~/snippets/slots-guide.tsx:host] // ---cut--- // [!include ~/snippets/slots-guide.tsx:multiple-hosts] ``` ## Typing a slot The type parameter is the host's props, and the props bag is checked against it at the call site rather than at render: ```tsx twoslash // @errors: 2741 2353 import { defineSlot, SlotHost } from "create-slot" const NavMenu = defineSlot<{ current: string }>("nav-menu") const StatusBar = defineSlot("status-bar") // ---cut--- // No type parameter: a host with no props bag to pass. const ok = Ready // A typed slot makes the bag required… const missing = // …and rejects a prop the slot does not declare. const extra = ``` A contribution's own parameter type has to accept what the host provides. It may accept less — that is ordinary assignability — but never something different: ```tsx twoslash // @errors: 2322 import { defineSlot } from "create-slot" const DealActions = defineSlot<{ dealId: string; stage: string }>( "deal-actions", ) // ---cut--- // Fewer props than the host provides: fine. function ArchiveAction({ dealId }: { dealId: string }) { return } const archive = DealActions.contribute("archive", { component: ArchiveAction }) // A prop the host does not have: rejected. function BadAction({ href }: { href: string }) { return Open } const bad = DealActions.contribute("bad", { component: BadAction }) ``` Hover `contribute` to see what it produces — plain data under a required id, which is what makes the declarative channel server-renderable and addressable: ```tsx twoslash // [!include ~/snippets/slots-guide.tsx:define] // [!include ~/snippets/slots-guide.tsx:contribution] // ---cut--- // [!include ~/snippets/slots-guide.tsx:contribute-data] ``` ## Next * [Two channels](/channels) — which channel a given contribution belongs on * [Plugin registry](/registry) — the declarative channel end to end * [Ordering](/ordering) — `order` as a priority, ties, and the read-once rule * [Recipes](/recipes) — per-row hosts, empty states, exclusive claims * [Errors](/errors) — every message the library throws, and its fix # Two channels A contribution reaches a host through one of two channels, and the two differ in what a contribution *is*. Everything else — whether it appears in server HTML, whether one feature's parts can share `useState`, whether an error boundary wraps it — follows from that. Since 4.0 the two channels are two APIs with two hosts. The registry is the declarative channel, whole; the runtime channel lives entirely inside the [`createSlot()` façade](/api#createslot). The registry never merges fills. ## The short answer **The registry for anything that must be in the HTML. The façade for chrome that depends on live tree state.** If one question decides it, it is this: *does the contribution have to exist before hydration?* Navigation, above-the-fold content, anything a crawler reads, anything whose late arrival shifts the layout — the registry. A status entry that exists only while a dialog is open, a badge a row puts up about its own selection — the façade. When neither applies, prefer the registry. It is the primary channel, it costs nothing extra, and it is the one that keeps its options open. The rest of this page is why. ## Declarative: data plus a component `slot.contribute(id, spec)` produces a plain object: a slot name, a required id, an `order`, and a component. A plugin carries a list of them, and `resolvePlugins` turns the plugin list into a `Resolution` **before render**. ```tsx twoslash // [!include ~/snippets/channels-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/channels-guide.tsx:declarative] ``` Because the graph is resolved before render, a host enumerates its contributions synchronously — which is exactly what a server render needs, and what lets the whole Resolution [cross an RSC boundary](/server-rendering#react-server-components). This is the primary channel. ## Runtime: an element from where it is mounted A `createSlot()` factory's slot component renders nothing where it sits. It registers its child element into the factory's private store for as long as it is mounted, and every mounted host of that factory renders it. ```tsx twoslash // [!include ~/snippets/channels-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/channels-guide.tsx:runtime] ``` This channel cannot reach server markup. Its server and first hydration snapshots are both empty: the client cannot reproduce fills from subtrees it has not reached yet without a mismatch. Registration therefore happens in an effect, after hydration. ## The comparison | | Registry (declarative) | Façade (runtime) | | --- | --- | --- | | A contribution is | data plus a component, under an id | an element | | Registered | before render, by `resolvePlugins` | from an effect, while mounted | | In server HTML | **yes** | no | | Host | `SlotHost`, under `SlotProvider` | the factory's own `Host`, no provider | | Addressable | by full id: `disable`, `override`, diagnostics | no identity | | Isolation per contribution | identity + error boundary + `Suspense` | `Suspense` with a `null` fallback | | One feature's parts share `useState` | no — separate subtrees | yes — one subtree | | Reads host props via | its own props | `useProps()` | | Turning a feature off | resolve without it, or `disable` by id | stop rendering it | The façade's store belongs to the factory, not to a module-level registry: two factories can never exchange fills, and two React roots using one factory always do. ## What each choice costs Reach for the registry when the contribution is navigation, above-the-fold content, anything a crawler reads, or anything whose absence during hydration would shift the layout. You get server rendering, streaming, addressable ids and per-contribution failure isolation, and you pay for it by giving up the shared React subtree: a plugin's contributions no longer share ordinary `useState`, so shared state needs [a store](/state). In exchange you get per-contribution memoisation — see [Performance](/performance). Reach for the façade when the contribution genuinely is not known up front — a status bar entry that exists only while a dialog is open, a badge a row puts up about its own selection, chrome that depends on where in the tree it happens to be mounted. A feature stays one subtree, so its parts share ordinary state, and installing it is mounting it. You pay for it with SSR, identity and isolation — it is the application's own code in the application's own tree. ## Using both An application uses both channels side by side — one registry for the content, a façade slot here and there for the live chrome. What no longer exists is a *mixed host*: a registry host renders declared contributions only, and a façade host renders its factory's fills only. A late client fill can therefore never displace markup the server already shipped — that class of hydration bug is unrepresentable by construction. ```tsx twoslash // [!include ~/snippets/registry-guide.tsx:slots] // [!include ~/snippets/registry-guide.tsx:plugin] // [!include ~/snippets/registry-guide.tsx:provider] // ---cut--- // [!include ~/snippets/registry-guide.tsx:mixed] ``` The pages-router [example](/examples) ships exactly this shape: every feature surface server-rendered from the Resolution, and one façade status bar that fills after hydration. ## Next * [Plugin registry](/registry) — the declarative channel end to end * [Server rendering](/server-rendering) — the one requirement it comes with * [Ordering](/ordering) — how each channel ranks its contributions * [Plugin state](/state) — what replaces the shared subtree # Use with AI agents `create-slot` changed shape in 4.0: the [registry](/registry) resolves plugins into a `Resolution` before render, and an agent working from its training data will reach for `createSlot` and an effect — or for the removed `PluginProvider` — where `resolvePlugins` and `SlotProvider` belong. The skill on this page fixes that. It tells a compatible coding agent to read the current documentation before it implements, reviews, debugs or explains create-slot code. The skill follows the open [Agent Skills](https://agentskills.io/) format. It is instructions for your agent — it does not install the `create-slot` package or touch your application. ## Install the skill Run this from your project directory: ```sh npx skills add r13v/create-slot --skill create-slot ``` Follow the CLI prompts to pick your agent and the installation method. Keep the default project scope when only this project uses create-slot. Add `--global` to make the skill available in every project: ```sh npx skills add r13v/create-slot --skill create-slot --global ``` The command downloads the skill from the public [create-slot repository](https://github.com/r13v/create-slot/tree/main/skills/create-slot). Read `SKILL.md` before you install or update it — your agent follows whatever that file says. ### Install without the CLI Copy the `skills/create-slot` directory from the repository into a skill directory your agent reads. For agents that follow the shared project convention, that is: ```text .agents/skills/create-slot/SKILL.md ``` Check your agent's documentation if it uses a different path. ## Use the skill A compatible agent can load the skill on its own when your request concerns create-slot. Name it in the request when you want it activated explicitly. There is no product-specific slash command to remember. For an implementation task: ```text Use the create-slot skill. Add a "toolbar" slot to the invoice page and contribute an export button from the billing feature. It has to be in the server-rendered HTML. ``` For a review task: ```text Use the create-slot skill. Review this feature for a runtime fill that should be a declared contribution, and for order values that only work by accident. ``` When the skill activates it sends the agent to the [LLM documentation index](https://r13v.github.io/create-slot/llms.txt) and tells it to treat that as the source of truth — which is what keeps it from answering from a pre-4.0 memory of the API. ## Read the docs without a skill Both files are generated on every build and are plain text. Any agent, script or chat that can fetch a URL can use them directly: | File | Contents | | --- | --- | | [`llms.txt`](https://r13v.github.io/create-slot/llms.txt) | The index — every page with its one-line description. | | [`llms-full.txt`](https://r13v.github.io/create-slot/llms-full.txt) | Every page's full text in one file. | Start from `llms.txt` and fetch the pages you need. Reach for `llms-full.txt` when you would rather spend context than round trips, or when the agent cannot follow links. Every page is also served as plain Markdown under `/assets/md`, so one page costs one fetch — for example [`/assets/md/api.md`](https://r13v.github.io/create-slot/assets/md/api.md). ## Verify the installation List what is installed: ```sh npx skills list ``` Confirm `create-slot` appears for the agent you picked. Start a new session if the current one does not discover it, then send one of the requests above. If your agent shows its activity, check that it reads `SKILL.md` and the LLM documentation before it edits code. ## Update the skill ```sh npx skills update create-slot ``` Add `--global` if you installed it globally. # Plugin registry The registry is `create-slot`'s declarative channel — since 4.0, its only one. Plugins declare their contributions as data; `resolvePlugins` turns the plugin list into a `Resolution` — one pure, synchronous, deterministic function; hosts render what it resolved. A contribution is not an element that an effect moves at runtime. It is **data plus a component**, under a required id — so the whole graph is known before render, which is exactly what a server render needs. ## The whole surface ```ts // create-slot/core — React-free, importable from server components defineSlot(name): Slot // { name, contribute, override } definePlugin(definition) // { id, contributes? } plus your own fields resolvePlugins(plugins, options?): Resolution // { slots, diagnostics } entriesOf(resolution, slot): ResolvedEntry[] // create-slot — the React adapter, plus everything above re-exported placeholder useSlotProps(slot): Props | null useContribution(): { slot, pluginId, contributionId } ``` `create-slot/core` never imports React at runtime, so a server component, a Node script or a test can import it under any condition. The root entry re-exports the core, so a client module needs one import. ## 1. Name the slots ```tsx twoslash // [!include ~/snippets/registry-guide.tsx:slots] ``` A slot is a descriptor — a name plus a props type, never a component. Since the core is React-free, `slots.ts` is importable from server modules too. ## 2. Declare the contributions A plugin is an object with an `id` and a list of contributions. A contribution is an ordinary component that receives the host's props — declared under a **required id**. ```tsx twoslash // [!include ~/snippets/registry-guide.tsx:slots] // ---cut--- // [!include ~/snippets/registry-guide.tsx:plugin] ``` A contribution's id is local to its plugin, never contains `/`, and must be unique inside it. The full id `${pluginId}/${contributionId}` is the React key every host uses, the address `disable` and `override` target, and the name diagnostics use. Because the key is the id, inserting or removing a neighbouring contribution never remounts the others. ## 3. Resolve, and mount the provider `resolvePlugins` is where everything that used to be render-time work happens: grouping, `disable`, `override` patches, sorting, key minting. The application owns the Resolution — and the memo boundary with it. ```tsx twoslash // [!include ~/snippets/registry-guide.tsx:slots] // [!include ~/snippets/registry-guide.tsx:plugin] // ---cut--- // [!include ~/snippets/registry-guide.tsx:provider] ``` ## 4. Configure without forking `disable` drops a plugin or a single contribution by id. `override` patches one contribution's `order` or `component`. Typed overrides come from the slot, not from a string: a string id cannot carry `Props`, so `NavMenu.override` is where a replacement component is type-checked. ```tsx twoslash // [!include ~/snippets/registry-guide.tsx:slots] // [!include ~/snippets/registry-guide.tsx:plugin] // [!include ~/snippets/registry-guide.tsx:provider] // ---cut--- // [!include ~/snippets/registry-guide.tsx:configure] ``` Problems come back as **diagnostics** — never thrown, never silently dropped: ``` duplicate-plugin-id · duplicate-contribution-id · invalid-contribution-id unknown-disable-target · unknown-override-target · override-slot-mismatch ``` In development the provider prints them once per content change; a production build pays nothing for the printing, and the data is still on the Resolution for a test to assert on: ```ts expect(resolvePlugins(PLUGINS).diagnostics).toEqual([]) ``` That one line is the catalog validator. The Resolution is plain data — slot name → sorted entries plus the diagnostics — so an inspector, a policy check or a snapshot is a `.map` over it, not a library feature. ## 5. Decide which plugins are enabled The enabled set is application data — only the application knows whether the answer has to survive a hydration. ```tsx twoslash // [!include ~/snippets/registry-guide.tsx:slots] // [!include ~/snippets/registry-guide.tsx:plugin] // [!include ~/snippets/registry-guide.tsx:provider] // ---cut--- // [!include ~/snippets/registry-guide.tsx:enabled] ``` One rule comes with those inputs, and it is cheap to keep: **give the resolver the same inputs, in the same order, on the server and on the client.** See [the SSR contract](/server-rendering#the-contract-in-one-sentence). ## A suggested file layout Nothing enforces this, and the library imports none of it. It is just the shape that keeps features from importing each other: ``` src/ slots.ts # defineSlot calls — the only module both sides import plugins/ pricing/ index.ts # definePlugin: the manifest, a plain module nav-item.tsx # the contribution components ("use client" under RSC) reporting/ ... catalog.ts # the array, in the order the server and client agree on app.tsx # resolvePlugins, SlotProvider, and the hosts ``` A feature imports `slots.ts` and nothing else from the application. The application imports `catalog.ts`. Neither direction crosses between features. Under React Server Components this split is also [the two-module discipline](/server-rendering#tier-2--the-two-module-discipline). ## See it work Enable a plugin and both hosts pick up the new Resolution at once. One card reads its own identity. The crash-test plugin throws on purpose, so the isolation boundary around every contribution is visible rather than described. The live registry demo runs only in a browser. Enabling a plugin resolves a new graph and both hosts pick it up at once, one contribution reads its own identity through useContribution, and the crash-test plugin shows what Failed and onError do when a contribution throws. Source: [docs-site/src/components/registry-demo.client.tsx](https://github.com/r13v/create-slot/blob/main/docs-site/src/components/registry-demo.client.tsx) ## Per-contribution identity `useContribution` is in the API because it is the one thing only the library knows while a contribution renders: which contribution it is — slot, plugin id, contribution id. Every per-plugin facility an application wants — a store, a prefixed logger, a settings namespace, a telemetry tag — hangs off it. ```tsx twoslash // [!include ~/snippets/failure-isolation-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/failure-isolation-guide.tsx:plugin-id] ``` ## What you give up A plugin's contributions do not share a React subtree, so they do not share ordinary `useState`. Two contributions of one plugin are two separate places in the tree that happen to carry the same `pluginId`. That is the real cost of the declarative channel, and it has a straightforward answer: a store, keyed per plugin. See [Plugin state](/state) for redux and mobx, both assembled from the catalog before render. ## What it does not do, on purpose **No `when` predicate.** Visibility has exactly one mechanism: the contribution returns `null`. The cost is that a host cannot know how many contributions produced *output* — its children render when nothing is *contributed*, which is not the same thing. See [empty states](/recipes#empty-states). **No policies beyond `disable`.** Limits, allowlists and caps over the resolved graph are a `.filter` over `Resolution.slots` the application writes with its own vocabulary. **No exclusive slots and no routing.** "Exactly one owner" 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](/recipes#exclusive-claims), where your code knows the actual keys and can report the plugin it refused. **No inventory helper.** The inventory exists because the manifest *is* data: [it is a `.map`](/recipes#inventory). **No state, no lifecycle, no command registry.** The library knows who contributes what, in what order, and what happens when it breaks. Everything else is yours, and the manifest is open data you can [extend with your own fields](/state). ## Next * [Server rendering](/server-rendering) — the SSR contract, the two RSC tiers, streaming * [Failure isolation](/failure-isolation) — `onError`, `Failed`, `Pending`, `reset` * [Plugin state](/state) — stores assembled from the catalog * [Performance](/performance) — what a re-render costs, and what stays free * [Examples](/examples) — one CRM, three shells # Ordering Every contribution carries an `order`. The resolver sorts each slot's entries by it — `order`, then plugin position, then declaration position — and a host renders them in that resolved order. The façade ranks its fills on the same kind of number, inside its own store. ## A priority, not an index `order` is a priority. Nothing is indexed by it, nothing is reserved, and two contributions may share one. Leave gaps — `10`, `20`, `30` — so a plugin can be slotted between two existing ones later without renumbering anything. ```tsx twoslash // [!include ~/snippets/ordering-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/ordering-guide.tsx:priority] ``` `order` is a plain number, so the whole range is available: | Value | Meaning | | --- | --- | | omitted | `0` — the default for `contribute()` and for a façade fill | | negative | before everything that left `order` unset | | `10`, `20`, `30` | the useful convention: room to insert later | Nothing is reserved and nothing is indexed by it, so a value you never use costs nothing and a gap you never fill is not a hole in the output. ## Ties are stable, and both render Two contributions that share one `order` both render. Neither replaces the other. ```tsx twoslash // [!include ~/snippets/ordering-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/ordering-guide.tsx:tie] ``` The tie-break is deterministic, in this order: 1. **`order`** — lower first. 2. **Plugin position** — where the plugin sits in the list handed to `resolvePlugins`. 3. **Declaration position** — where the contribution sits in that plugin's `contributes`. Every part of that is derived from data the application controls, so the same inputs always produce the same visual order — on the server and on the client. Each resolved entry also carries its final position as `seq`. ### A worked example Given `resolvePlugins([search, filters, exportCsv])`, a host renders this: | # | Contribution | `order` | Why it is here | | --- | --- | --- | --- | | 1 | `search/search-box` | 10 | lowest `order` | | 2 | `filters/menu` | 20 | `filters` is earlier in the list than `exportCsv` | | 3 | `export-csv/export` | 20 | first in `exportCsv`'s `contributes` | | 4 | `export-csv/import` | 20 | second in `exportCsv`'s `contributes` | Move `filters` after `exportCsv` in the list and rows 2–4 reorder. Change nothing and the result is byte-identical on every render, on both sides of a hydration. ## Overrides can re-rank The integrator has the last word: an `override` patches one contribution's `order` by its full id, without forking the plugin that declared it. ```tsx twoslash // [!include ~/snippets/ordering-guide.tsx:prelude] // [!include ~/snippets/ordering-guide.tsx:tie] // ---cut--- // [!include ~/snippets/ordering-guide.tsx:override] ``` Pass it to `resolvePlugins` in `options.overrides`. A target that nothing carries comes back as an [`unknown-override-target` diagnostic](/errors#diagnostics), not a silent no-op. ## Read once A **façade fill** reads its `order` once, when it mounts. Changing the value afterwards leaves the fill exactly where it is. ```tsx twoslash // [!include ~/snippets/ordering-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/ordering-guide.tsx:read-once] ``` That is deliberate: the alternative is a contribution that jumps position while a user is looking at it. Pass the value that is already final, or remount the fill to apply a new one. Fills that share an `order` render in mount order. A **declared** contribution has no such caveat. Its `order` is data in the manifest — resolve again and the new rank applies. ## See both rules at once Change a declared order and the registry host reorders on the next Resolution. Change the fill's order and nothing moves until you remount it. The live ordering demo runs only in a browser. Declared contributions reorder as soon as a new Resolution ranks them; a façade fill keeps the order it was given on mount until it is remounted. Source: [docs-site/src/components/ordering-demo.client.tsx](https://github.com/r13v/create-slot/blob/main/docs-site/src/components/ordering-demo.client.tsx) ## History In 2.x, `order` was an array index: two fills that shared one silently replaced each other, and order bands, strides and duplicate-order detectors existed to work around it. 3.0 made `order` a priority on both channels; 4.0 moved the declared ranking into `resolvePlugins`, where overrides can re-rank it. See [Migrating to 4.0](/migrating). # Server rendering The registry exists for this. A contribution is data resolved before render, so it is in the HTML the server sends — no effects on the render path, nothing to wait for. ## The contract, in one sentence **Give the resolver the same inputs, in the same order, on the server and on the client.** That is all the library asks. Deep-equal resolutions produce identical markup; nothing depends on object identity across the seam. If a tenant, a user or a flag controls the enabled set, that set is data. Send it with the HTML and resolve from it on both sides — or resolve once on the server and send the Resolution itself (see [React Server Components](#react-server-components) below). If you compute the set again on the client from something the server did not see, hydration breaks. The library's own test suite proves both directions. ```tsx twoslash // [!include ~/snippets/server-rendering-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/server-rendering-guide.tsx:catalog] ``` Declaring the order once, as a list of ids, is the cheapest way to keep both sides honest. ## Rendering it, without a framework Nothing about the library is framework-specific. `renderToString` on the server and `hydrateRoot` on the client is the whole integration, and the only thing that has to cross between them is the list of ids: ```tsx twoslash // [!include ~/snippets/server-rendering-guide.tsx:prelude] // [!include ~/snippets/server-rendering-guide.tsx:catalog] // ---cut--- // [!include ~/snippets/server-rendering-guide.tsx:ssr] ``` The client reads the same ids back and resolves the same graph: ```tsx twoslash // [!include ~/snippets/server-rendering-guide.tsx:prelude] // [!include ~/snippets/server-rendering-guide.tsx:catalog] // ---cut--- // [!include ~/snippets/server-rendering-guide.tsx:hydrate] ``` Serialise ids, never plugins or resolutions: both hold components, which do not survive `JSON.stringify`. The catalog that maps an id back to a plugin is ordinary code, imported by both entries. ## React Server Components Two tiers, both without codegen. The core entry is what makes them possible: `create-slot/core` never imports React at runtime, so a **server component can import manifests and call `resolvePlugins`** — only the adapter (`SlotProvider`, `SlotHost`, the hooks) is `"use client"`. `examples/nextjs-app` ships the second tier. ### Tier 1 — the client boundary Manifests and `SlotProvider` live behind one `"use client"` module; a server component sends **ids**, and the client resolves. This is the v3 shape, and it remains correct and simple. ```tsx twoslash // [!include ~/snippets/server-rendering-guide.tsx:prelude] // [!include ~/snippets/server-rendering-guide.tsx:catalog] // ---cut--- // [!include ~/snippets/server-rendering-guide.tsx:boundary] ``` ```tsx twoslash // [!include ~/snippets/server-rendering-guide.tsx:prelude] // [!include ~/snippets/server-rendering-guide.tsx:catalog] // [!include ~/snippets/server-rendering-guide.tsx:boundary] // ---cut--- // [!include ~/snippets/server-rendering-guide.tsx:server] ``` ### Tier 2 — the two-module discipline Write each manifest as a **plain module** that imports its components from `"use client"` files. Then the manifest — and `resolvePlugins` over it — is importable from a server component, and the Resolution it returns is serializable: metadata plus client references. The server resolves once and passes the whole graph across the boundary as a prop. ```tsx twoslash // [!include ~/snippets/server-rendering-guide.tsx:prelude] // [!include ~/snippets/server-rendering-guide.tsx:catalog] // ---cut--- // [!include ~/snippets/server-rendering-guide.tsx:two-module] ``` ```tsx twoslash // [!include ~/snippets/server-rendering-guide.tsx:prelude] // [!include ~/snippets/server-rendering-guide.tsx:catalog] // ---cut--- // [!include ~/snippets/server-rendering-guide.tsx:tier2-server] ``` ### A server host in userland Under tier 2 a fully server-rendered host is a few lines: `entriesOf` plus the exported `ContributionBoundary`, which ships as `"use client"` so the failure semantics stay the host's. The components are client references; they hydrate under the shell's providers like any other contribution. ```tsx twoslash // [!include ~/snippets/server-rendering-guide.tsx:prelude] // [!include ~/snippets/server-rendering-guide.tsx:catalog] // [!include ~/snippets/server-rendering-guide.tsx:two-module] // ---cut--- // [!include ~/snippets/server-rendering-guide.tsx:server-host] ``` ### The hard walls Three things do not cross an RSC boundary, and the design leans into each: * **`useSlotProps` is context**, and therefore client-only. A server host passes serializable props directly, as `ServerPanels` does above. * **Functions never cross** — reducers, `setup`, loaders. The module that carries what a server must own regardless of the graph (the **server seam**, `crm-core/server` in the examples) survives both tiers unchanged. * **A component defined inside the manifest module itself** is not a client reference and will not cross. The two-module discipline is exactly the rule that prevents this. ## Streaming `renderToPipeableStream` needs nothing extra from the library, and the per-contribution `Suspense` boundary is what makes it worthwhile: a slow contribution holds up its own line and nothing else. The shell — every other contribution, plus that one's fallback — goes out immediately, and the resolved contribution arrives in a later chunk with React's own swap script. ```tsx twoslash // [!include ~/snippets/server-rendering-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/server-rendering-guide.tsx:streaming] ``` ### One thing to know before you try it **A state update during hydration destroys streamed HTML.** An application's `setup` loop typically registers commands in an effect, which fires while the page is still hydrating. An urgent update reaching a boundary that has not hydrated yet makes React discard the streamed markup and re-render it on the client — *"This Suspense boundary received an update before it finished hydrating."* Wrap the registration in `startTransition`. See [Plugin state](/state#setup-runs-in-an-effect). ## A deferred contribution A **deferred contribution** loads its component in a later bundle chunk. What the server produces for it depends on the render path, and the two paths behave differently. **`renderToPipeableStream` handles it.** The shell carries the contribution's fallback — the provider's `Pending`, or your own inner `Suspense` — and the body arrives in a later chunk. That is the mechanism [streaming](#streaming) already describes. Set `Pending` knowing the trade: the skeleton ships in the streamed shell and React swaps it out — HTML size and layout shift for earlier paint. Unset, the fallback is `null` and nothing extra ships. **`renderToString` does not support `Suspense` at all.** React reports this itself. A `React.lazy` component therefore never reaches the HTML, and awaiting the loader first does not change it: `lazy` resolves in a microtask, and this render is synchronous. | What you wrote | What `renderToString` produces | | --- | --- | | `lazy`, plus the contribution's own `Suspense` | React marks the boundary for a client render and emits your fallback as placeholder markup. The content arrives after hydration. | | `lazy` on its own | React marks the boundary for a client render and emits the `Pending` fallback, or nothing. | A render that does not stream therefore has two answers. Either do not defer that contribution, or resolve its module before you build the plugin list: ```tsx twoslash // [!include ~/snippets/server-rendering-guide.tsx:prelude] // [!include ~/snippets/server-rendering-guide.tsx:catalog] // ---cut--- // [!include ~/snippets/server-rendering-guide.tsx:deferred] ``` The client has to build the list the same way. The contract is unchanged: the same inputs, in the same order, on both sides. A list the server awaited and the client did not is a different list. The pages router does not stream, so this is the shape it needs. The app router streams, and can defer. `React.lazy` splits the chunk, but it does not tell a framework which chunk a page needs. Next.js uses `next/dynamic` for that, and `component` accepts its result. See [splitting a declared contribution](/recipes#splitting-a-declared-contribution) for the client half. ## What is missing from the HTML Two things, both by construction: * **Façade fills.** The runtime channel's server and first hydration snapshots are intentionally empty. Even if a server prepass collected fills, the client could not reproduce entries from subtrees it has not reached yet without a mismatch. Registration therefore happens in an effect; server markup shows the façade host's placeholder, and the fill appears after hydration. * **Anything a `setup` function registers.** Same reason. Both are visible in the pages-router [example](/examples): view source and the plugins' nav items, deal actions and panels are already there, while the status bar still shows its placeholder. ## Failure, on the server `getDerivedStateFromError` never runs on the server, but the `Suspense` boundary around every contribution still does its job: React marks just that boundary for a client render (`` in the HTML), keeps the rest of the markup, and the client re-renders that one contribution — where the class boundary finally catches it. One broken contribution costs its own line, not the page. See [Failure isolation](/failure-isolation#on-the-server). ## A checklist before you ship 1. The resolver's inputs are built from data sent with the HTML, not recomputed. 2. The Resolution is held in a module or a `useMemo` — one line, and it spares the hosts too. 3. Anything a `setup` loop registers is wrapped in `startTransition`. 4. Every contribution that has to be in the markup is **declared**, not a façade fill. 5. A contribution that reads a promise expects its own `Suspense` fallback — which the host already places — rather than the layout's. 6. Every deferred contribution is loaded before a render that does not stream. ## Next * [Failure isolation](/failure-isolation#on-the-server) — what a throw costs here * [Plugin state](/state) — preloading a slice with the HTML * [Errors](/errors#hydration-mismatch) — the two React errors this page can cause * [Examples](/examples) — the same features under three shells # 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. ```tsx twoslash // [!include ~/snippets/failure-isolation-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/failure-isolation-guide.tsx:provider] ``` `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](/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 `setTimeout` or 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](/server-rendering#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: 1. React marks just that boundary for a client render — `` in the HTML. 2. The rest of the markup is kept and sent. 3. The client re-renders that one contribution, where the class boundary finally catches it and `Failed` takes 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](/server-rendering#a-server-host-in-userland) 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. ```tsx twoslash // [!include ~/snippets/failure-isolation-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/failure-isolation-guide.tsx:runtime] ``` 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. ```tsx twoslash // [!include ~/snippets/failure-isolation-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/failure-isolation-guide.tsx:plugin-id] ``` 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](/server-rendering) — the SSR contract and streaming * [Two channels](/channels) — the rest of what the two channels differ on * [Errors](/errors) — every message the library itself throws # Plugin state The library reads two fields — `id` and `contributes` — and nothing else. It has no state, no lifecycle and no command registry, on purpose: it knows who contributes what, in what order, and what happens when a contribution breaks. Everything else is yours. ## Why a store at all On the [declarative channel](/channels) a plugin's contributions do not share a React subtree, so they do not share ordinary `useState`. Two contributions of one plugin are two separate places in the tree that happen to carry the same `pluginId`. A store takes the place of the shared subtree. On the [runtime channel](/slots#the-runtime-channel) the question does not arise: a feature that fills through the `createSlot()` façade is one subtree, so its parts share ordinary state and you need none of this page. ## Extend the manifest `definePlugin` is generic over its argument, so your own fields keep their types. Declare your plugin shape once and let the library ignore the parts that are not its business. ```tsx twoslash // [!include ~/snippets/state-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/state-guide.tsx:plugin-type] ``` Use `satisfies CrmPlugin` at each declaration rather than annotating the constant. The check still runs, and the literal keeps its exact type — which is what lets the application read a field back without a cast: ```tsx twoslash // [!include ~/snippets/state-guide.tsx:prelude] // [!include ~/snippets/state-guide.tsx:plugin-type] // ---cut--- // [!include ~/snippets/state-guide.tsx:satisfies] // ^? ``` ## One rule **Assemble from the catalog before render. Never inject in an effect.** An effect does not run on the server, so state injected from one cannot be preloaded — and a plugin whose slice appears a commit after hydration is a layout shift at best and a mismatch at worst. ## redux A plugin declares its `reducer`, and a `preload` for that slice's server-side initial state. The application combines slices from the **catalog**, not from the enabled list: a store whose shape depends on a toggle cannot be preloaded. ```tsx twoslash // [!include ~/snippets/state-guide.tsx:prelude] // [!include ~/snippets/state-guide.tsx:plugin-type] // ---cut--- // [!include ~/snippets/state-guide.tsx:redux] ``` The server sends the loaded state with the HTML and the client starts from it. ## mobx A plugin declares `createStore`, and the application creates one instance per application instance — which on the server means per request. A contribution finds its own store through `useContribution().pluginId`, the identity only the library knows while it renders. ```tsx twoslash // [!include ~/snippets/state-guide.tsx:prelude] // [!include ~/snippets/state-guide.tsx:plugin-type] // ---cut--- // [!include ~/snippets/state-guide.tsx:mobx] ``` This suits ephemeral, client-only state — a live call, an open dialog — which has nothing to serialise in the first place. ## `setup` runs in an effect The library has no lifecycle, so a plugin that needs one declares it as a field and the application runs it. An effect is the right place: the plugin list is already known, and teardown is the returned function. Anything `setup` registers is absent from server markup by construction, and appears a moment after hydration. That is fine for commands, shortcuts and listeners. It has one trap: ```tsx twoslash // [!include ~/snippets/state-guide.tsx:prelude] // [!include ~/snippets/state-guide.tsx:plugin-type] // ---cut--- // [!include ~/snippets/state-guide.tsx:setup] ``` An urgent update reaching a `Suspense` boundary that has not hydrated yet makes React throw away the streamed HTML and re-render it on the client — *"This Suspense boundary received an update before it finished hydrating."* Wrapping the registration in `startTransition` is what keeps streamed markup intact. See [Streaming](/server-rendering#streaming) and [Errors](/errors). Two details in that hook are load-bearing: * **`plugins` is a dependency**, so the effect re-runs when the list changes and the previous teardown runs first. Keep that array stable — the effect, unlike a [host](/performance), has no content comparison to fall back on. * **Teardown is collected outside the transition**, because the cleanup must run synchronously when the component unmounts. ## Reading state from a contribution A declared contribution gets the host's props, and nothing else. Everything per-plugin it needs — its slice, its logger, its settings namespace — it looks up by `useContribution().pluginId`, as `CallIndicator` does above. `useContribution` throws outside a contribution, so a component that reads it cannot be mounted in the wrong place and quietly get somebody else's state. See [Errors](/errors#usecontribution-called-outside-of-a-plugin-contribution). ## Next * [Recipes](/recipes) — resolving exclusive claims, building an inventory * [Server rendering](/server-rendering) — preloading state with the HTML * [Performance](/performance) — what a re-render costs, and what stays free # 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. ```tsx twoslash // [!include ~/snippets/performance-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/performance-guide.tsx:unstable] ``` The one-line version: ```tsx twoslash // [!include ~/snippets/performance-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/performance-guide.tsx:stable] ``` `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`. ```tsx twoslash // [!include ~/snippets/performance-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/performance-guide.tsx:props] ``` This makes an unchanged host free; it never makes a changed one wrong. ## A contribution is still plain data ```tsx twoslash // [!include ~/snippets/performance-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/performance-guide.tsx:contribution] ``` `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. ```bash npm run test:run # render-count budgets npm run bench # comparative mount and update cost, 10 and 100 contributions ``` # Recipes ## Visibility There is no `when` predicate. A contribution decides for itself, with a plain `if`: ```tsx twoslash // [!include ~/snippets/recipes-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/recipes-guide.tsx:visibility] ``` 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](#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 `disable` by id. Whatever decides it must produce the same answer on the server and on the client; see [the SSR contract](/server-rendering#the-contract-in-one-sentence). * **Façade** — stop rendering the component that fills. Installing a feature is mounting it, so uninstalling is `&&`. ```tsx twoslash // [!include ~/snippets/recipes-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/recipes-guide.tsx:toggle] ``` 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. ```tsx twoslash // [!include ~/snippets/recipes-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/recipes-guide.tsx:split-facade] ``` 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](/server-rendering#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. ```tsx twoslash // [!include ~/snippets/recipes-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/recipes-guide.tsx:split-declared] ``` 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 `setup` function * a `preload` function 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](/state). ### The shorter form leaves a gap ```tsx twoslash // [!include ~/snippets/recipes-guide.tsx:prelude] // [!include ~/snippets/recipes-guide.tsx:split-declared] // ---cut--- // [!include ~/snippets/recipes-guide.tsx:split-trap] ``` 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](/server-rendering#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. ```tsx twoslash // [!include ~/snippets/recipes-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/recipes-guide.tsx:per-host] ``` 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](/slots#many-hosts-one-contribution). ## 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: ```tsx twoslash // [!include ~/snippets/recipes-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/recipes-guide.tsx:empty] ``` For full ownership of the empty state — a count, a call to action — use [`renderEntries`](/api#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. ```tsx twoslash // [!include ~/snippets/recipes-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/recipes-guide.tsx:exclusive] ``` 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. ```tsx twoslash // [!include ~/snippets/recipes-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/recipes-guide.tsx:inventory] ``` 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: ```tsx twoslash // [!include ~/snippets/testing-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/testing-guide.tsx:testing] ``` Two more assertions worth having in a real suite, because both are silent when they break: ```tsx twoslash // [!include ~/snippets/testing-guide.tsx:prelude] // [!include ~/snippets/testing-guide.tsx:testing] // ---cut--- // [!include ~/snippets/testing-guide.tsx:diagnostics] ``` * **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. # API Two entry points. `create-slot/core` is React-free — manifests, descriptors and the resolver — importable from server components, Node scripts and tests. The root entry is the React adapter, and it re-exports the core, so a client module needs one import. Hover any identifier below — the types come from the published package, not from prose. ```ts import { ContributionBoundary, createSlot, definePlugin, defineSlot, entriesOf, resolvePlugins, SlotHost, SlotProvider, useContribution, useSlotProps, } from "create-slot" ``` | Export | Entry | What it is | | --- | --- | --- | | [`defineSlot`](#defineslot) | core | Creates a slot descriptor: `name`, `contribute`, `override`. | | [`definePlugin`](#defineplugin) | core | Identity plus an `id` check. Your own fields keep their types. | | [`resolvePlugins`](#resolveplugins) | core | The whole registry as one pure function: plugins in, `Resolution` out. | | [`entriesOf`](#entriesof) | core | The entries of one slot, typed by its descriptor. | | [`SlotProvider`](#slotprovider) | adapter | Puts one Resolution in scope for every host below it. | | [`SlotHost`](#slothost) | adapter | Renders every contribution to a slot, in resolved order. | | [`useSlotProps`](#useslotprops) | adapter | The nearest host's props for a slot. | | [`useContribution`](#usecontribution) | adapter | Which contribution is rendering: slot, plugin id, contribution id. | | [`ContributionBoundary`](#contributionboundary) | adapter | The per-contribution isolation wrapper, for hand-rolled hosts. | | [`createSlot`](#createslot) | façade | The runtime channel, whole and alone — SPA-only, source-compatible with 2.x/3.x. | Everything that can throw is listed on one page, with its cause and its fix — see [Errors](/errors). ## `defineSlot` ```tsx twoslash // [!include ~/snippets/api-registry.tsx:prelude] // ---cut--- // [!include ~/snippets/api-registry.tsx:define-slot] // ^? ``` A slot is a descriptor — pure data, safe to import from server modules. Type safety lives on this object, because a string id cannot carry `Props`. The name must be non-empty: the Resolution keys contributions by name, so an empty one is two slots quietly sharing a bucket. ### `slot.contribute(id, spec)` ```tsx twoslash // [!include ~/snippets/api-registry.tsx:prelude] // [!include ~/snippets/api-registry.tsx:define-slot] // ---cut--- // [!include ~/snippets/api-registry.tsx:contribute] ``` | argument | type | notes | | --- | --- | --- | | `id` | `string` | Required. Local to the plugin, never contains `/`, unique inside it. The full id `${pluginId}/${id}` is the React key and the override address. | | `spec.component` | `ComponentType` | Receives the host's props as its own. May accept fewer; never different ones. | | `spec.order` | `number` | Defaults to `0`. A priority, not an array index — see [Ordering](/ordering). | The return value is plain data — `{ slot, id, order, component }` — which is what lets the resolver enumerate it before render. A malformed id is reported by the resolver as a diagnostic, not thrown at declaration. ### `slot.override(target, patch)` A typed patch aimed at one full contribution id. It comes from the slot rather than a string because this is where a replacement `component` is checked against the slot's `Props`. ```tsx twoslash // [!include ~/snippets/ordering-guide.tsx:prelude] // [!include ~/snippets/ordering-guide.tsx:tie] // ---cut--- // [!include ~/snippets/ordering-guide.tsx:override] ``` ## `definePlugin` Identity plus a non-empty-`id` check — and the one place where the plugin system is discoverable in a codebase. It is generic over its argument, so your own fields keep their types. ```tsx twoslash // [!include ~/snippets/api-registry.tsx:prelude] // [!include ~/snippets/api-registry.tsx:define-slot] // [!include ~/snippets/api-registry.tsx:contribute] // ---cut--- // [!include ~/snippets/api-registry.tsx:define-plugin] // ^? ``` The library reads `id` and `contributes`. Everything else on the object is yours; see [Plugin state](/state). ## `resolvePlugins` The whole registry as one pure, synchronous, deterministic function: grouping, `disable`, `override` patches, sorting (`order`, then plugin position, then declaration position), key minting. Inputs are never mutated. The same plugins and options produce a deep-equal Resolution every time — which is the entire [SSR contract](/server-rendering#the-contract-in-one-sentence). ```tsx twoslash // [!include ~/snippets/api-registry.tsx:prelude] // [!include ~/snippets/api-registry.tsx:define-slot] // [!include ~/snippets/api-registry.tsx:contribute] // [!include ~/snippets/api-registry.tsx:define-plugin] // ---cut--- // [!include ~/snippets/api-registry.tsx:resolve] ``` | option | type | notes | | --- | --- | --- | | `disable.plugins` | `readonly string[]` | Plugin ids to drop whole. | | `disable.contributions` | `readonly string[]` | Full contribution ids to drop one by one. | | `overrides` | `readonly Override[]` | Typed patches from `slot.override()`. Later patches to one target win. | Problems come back on `resolution.diagnostics` — never thrown, never silently dropped. Six codes: `duplicate-plugin-id`, `duplicate-contribution-id`, `invalid-contribution-id`, `unknown-disable-target`, `unknown-override-target`, `override-slot-mismatch`. In development the provider prints them once per content change; in a test, `expect(resolvePlugins(PLUGINS).diagnostics).toEqual([])` is the catalog validator. ## `entriesOf` ```tsx twoslash // [!include ~/snippets/api-registry.tsx:prelude] // [!include ~/snippets/api-registry.tsx:define-slot] // [!include ~/snippets/api-registry.tsx:contribute] // [!include ~/snippets/api-registry.tsx:define-plugin] // [!include ~/snippets/api-registry.tsx:resolve] // ---cut--- // [!include ~/snippets/api-registry.tsx:entries-of] // ^? ``` Restores what `contribute` erased: each entry's `component` is typed by the slot, no cast. This is what a hand-rolled host — including a [server host](/server-rendering#a-server-host-in-userland) — maps over. ## `SlotProvider` ```tsx twoslash // [!include ~/snippets/api-registry.tsx:prelude] // [!include ~/snippets/api-registry.tsx:define-slot] // [!include ~/snippets/api-registry.tsx:contribute] // [!include ~/snippets/api-registry.tsx:define-plugin] // [!include ~/snippets/api-registry.tsx:resolve] // ---cut--- // [!include ~/snippets/api-registry.tsx:provider] ``` | prop | type | notes | | --- | --- | --- | | `resolution` | `Resolution` | The pre-resolved graph. Identity-compared: hold it at module scope or in `useMemo`. | | `onError` | `(error: SlotError) => void` | Reported when a contribution throws. Inline arrows are free. | | `Failed` | `ComponentType` | Rendered in place of a contribution that threw; `reset` retries it. | | `Pending` | `ComponentType` | Rendered while a deferred contribution loads. Unset keeps `null`. | `Failed` and `Pending` are **components**, not render props: 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 inline values never re-render a host. ## `SlotHost` Renders every contribution to a slot, in resolved order. Its own children are the placeholder while nothing is contributed. | prop | type | notes | | --- | --- | --- | | `slot` | `Slot` | Which slot to render. | | `props` | `Props` | An explicit bag, not a spread — the host's own props can never collide with a slot's, and `children` structurally cannot leak into a contribution. Optional when `Props` is empty. | | `children` | `ReactNode` | The placeholder. Renders only while nothing is contributed. | | `renderEntries` | `(entries: readonly HostEntry[]) => ReactNode` | Full-ownership escape hatch — see below. | Host props are value-held: the host keeps them stable while their values stay the same, and it compares each resolved entry by content, so a host that re-renders for someone else's sake does not re-render the contributions. A host outside a `SlotProvider` [throws](/errors#slothost-rendered-outside-of-slotprovider). ### `renderEntries` Called with every entry — with zero too — and owns layout, wrappers and the empty state; `children` is then ignored. Each `HostEntry` carries the identity (`key`, `pluginId`, `contributionId`, `order`) plus `node`, the contribution already wrapped in memo, error boundary, `Suspense` and identity. Key your own wrappers with `entry.key`, never with an array index. ```tsx twoslash // [!include ~/snippets/api-registry.tsx:prelude] // [!include ~/snippets/api-registry.tsx:define-slot] // [!include ~/snippets/api-registry.tsx:contribute] // [!include ~/snippets/api-registry.tsx:define-plugin] // [!include ~/snippets/api-registry.tsx:resolve] // ---cut--- // [!include ~/snippets/api-registry.tsx:render-entries] ``` ## `useSlotProps` ```tsx twoslash // [!include ~/snippets/api-registry.tsx:prelude] // [!include ~/snippets/api-registry.tsx:define-slot] // ---cut--- // [!include ~/snippets/api-registry.tsx:use-slot-props] ``` Returns `Props | null` — null outside a host. A declared contribution rarely needs it: the host's props arrive as its own. It exists for components nested deeper inside a contribution, and it is context, so it is client-only — a [server host](/server-rendering#a-server-host-in-userland) passes serializable props directly. ## `useContribution` ```tsx twoslash // [!include ~/snippets/api-registry.tsx:prelude] // ---cut--- // [!include ~/snippets/api-registry.tsx:use-contribution] ``` Throws outside a contribution. Use it to give each plugin a store, a prefixed logger, a settings namespace, or a telemetry tag. ## `ContributionBoundary` The per-contribution isolation wrapper the default host places around every entry: contribution identity, an error boundary, and a `Suspense` boundary. Exported so hand-rolled hosts — including RSC server components mapping a Resolution — keep the same failure semantics as the default host. ```tsx twoslash // [!include ~/snippets/api-registry.tsx:prelude] // [!include ~/snippets/api-registry.tsx:define-slot] // [!include ~/snippets/api-registry.tsx:contribute] // [!include ~/snippets/api-registry.tsx:define-plugin] // [!include ~/snippets/api-registry.tsx:resolve] // ---cut--- // [!include ~/snippets/api-registry.tsx:boundary] ``` ## `createSlot` The [runtime channel](/channels), whole and alone — SPA-only by design: the server and hydration snapshots are always empty. Each factory owns a private store, shared with nothing; the registry never merges it. ```tsx twoslash // [!include ~/snippets/api-create-slot.tsx:prelude] // ---cut--- // [!include ~/snippets/api-create-slot.tsx:factory] // ^? ``` ### `` The component itself is the contributor. It renders nothing where it sits and takes exactly one element as its child. ```tsx twoslash // [!include ~/snippets/api-create-slot.tsx:prelude] // [!include ~/snippets/api-create-slot.tsx:factory] // [!include ~/snippets/api-create-slot.tsx:use-props] // ---cut--- // [!include ~/snippets/api-create-slot.tsx:fill] ``` | prop | type | notes | | --- | --- | --- | | `children` | `ReactElement` | Required. Exactly one element — [not text, not siblings](/errors#a-fill-expects-a-single-react-element-as-its-child). | | `order` | `number` | Defaults to `0`. [Read once, on mount](/ordering#read-once). | Both are also the React key's source: the key is assigned on mount and kept, so changing the child's *content* reconciles instead of remounting. ### `` ```tsx twoslash // [!include ~/snippets/api-create-slot.tsx:prelude] // [!include ~/snippets/api-create-slot.tsx:factory] // ---cut--- // [!include ~/snippets/api-create-slot.tsx:host] ``` Unlike `SlotHost`, the façade host spreads its props — the 2.x shape — and tolerates rendering with no provider anywhere, because the façade has none. ### `Slot.useProps()` ```tsx twoslash // [!include ~/snippets/api-create-slot.tsx:prelude] // [!include ~/snippets/api-create-slot.tsx:factory] // ---cut--- // [!include ~/snippets/api-create-slot.tsx:use-props] ``` Unlike `useSlotProps`, this one is not nullable: a `Slot`'s children only ever render inside one of its factory's hosts, so the façade can promise the props. ## Types All exported from the root entry; the core types also from `create-slot/core`. | Type | What it describes | | --- | --- | | `Slot` | What `defineSlot` returns: `name`, `contribute`, `override`. Pure data. | | `ContributionSpec` | The second argument to `contribute`: `{ component, order? }`. | | `Contribution` | What `contribute` returns: `{ slot, id, order, component }`. Plain data. | | `Override` | What `slot.override` returns. Opaque data; `resolvePlugins` consumes it. | | `PluginDefinition` | The two fields the library reads: `id`, `contributes?`. | | `ResolveOptions` | The options bag: `disable?`, `overrides?`. | | `Resolution` | The resolved graph: `slots` (name → sorted entries) plus `diagnostics`. | | `ResolvedEntry` | One entry: `key`, `pluginId`, `contributionId`, `slot`, `order`, `seq`, `component`. | | `Diagnostic` | A problem the resolver found. Returned, never thrown. | | `ErasedComponent` | A contribution's component with its props erased, so one graph holds every slot's. | | `SlotProviderProps` | `resolution`, `onError?`, `Failed?`, `Pending?`, `children?`. | | `SlotHostProps` | `slot`, `props`, `children?`, `renderEntries?`. | | `HostEntry` | What `renderEntries` receives: identity plus the finished `node`. | | `SlotError` | `{ pluginId, contributionId, slot, error }` — what `onError` and `Failed` receive. | | `ContributionInfo` | `{ slot, pluginId, contributionId }` — what `useContribution` returns and `Pending` receives. | | `RuntimeSlot` | What `createSlot` returns: the contributor, plus `Host` and `useProps`. Named `Slot` before 4.0. | `PluginDefinition` is the one to extend. `definePlugin` is generic over its argument, so an intersection type keeps every field of your own: ```tsx twoslash // [!include ~/snippets/api-registry.tsx:prelude] // ---cut--- // [!include ~/snippets/api-registry.tsx:extend] // ^? ``` ## Behavioural notes * A host with no contributions renders its own children. * Multiple hosts of one slot each render the same contributions; `useSlotProps` reflects the host doing the rendering. * `order` is a priority. Equal values are a stable tie: plugin position in the list, then declaration position. A façade fill reads its `order` once, on mount. * Entries are compared by **content**, because `resolvePlugins` mints fresh entry objects on every call and an inline call per render is legal. A host holds its props steady while their values are unchanged. See [Performance](/performance). * Each `createSlot()` factory owns a private store: two factories can never exchange fills, two React roots using one factory always do, and no module-level registry exists for a duplicated package copy to split. # Errors The library throws in six places, reports manifest defects as **diagnostics**, and logs in two. Every message starts with `[create-slot]`, so a search for that prefix finds all of them. Most of these mistakes are caught by TypeScript first. Where that is true, the type error is shown next to the runtime one. ## Thrown errors ### `'defineSlot' requires a non-empty name` The Resolution keys contributions by slot name. An empty name is two slots quietly sharing one bucket, so it is refused at the call. ```tsx twoslash // [!include ~/snippets/errors-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/errors-guide.tsx:slot-name] ``` Use a stable, application-wide string, and prefix names that a library exports. ### `'definePlugin' requires a non-empty id` The id namespaces every contribution id and React key, so it has to identify the plugin. ```tsx twoslash // [!include ~/snippets/errors-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/errors-guide.tsx:plugin-id] ``` A contribution's id, by contrast, is validated by the resolver: a plugin's typo surfaces as a [diagnostic](#diagnostics), not a throw at import time. ### `'SlotHost' rendered outside of 'SlotProvider'` `SlotHost` reads the Resolution from context. No provider means the application never mounted the registry, which is a mistake worth reporting rather than an empty slot. Mount `SlotProvider` above every host: ```tsx twoslash // [!include ~/snippets/errors-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/errors-guide.tsx:provider] ``` `createSlot`'s host is the exception. The façade never had a provider, so its host renders its factory's fills — or its placeholder — anywhere. ### `A fill expects a single React element as its child` A façade fill clones its child to stamp a stable React key on it. A string, a number, an array or a fragment of siblings cannot carry one. ```tsx twoslash // @errors: 2746 import { createSlot } from "create-slot" import type { ReactNode } from "react" const Toolbar = createSlot() declare function Save(): ReactNode declare function Undo(): ReactNode // ---cut--- // Two children: throws at render. export function Broken() { return ( ) } // One element that contains both. export function Fixed() { return ( <> ) } ``` Text is the same case: wrap it in an element. ### `'Slot' without children rendered` `createSlot`'s `Slot` is the contributor, so a `Slot` with nothing to contribute is always a mistake. TypeScript rejects it first: ```tsx twoslash // @errors: 2741 import { createSlot } from "create-slot" const Menu = createSlot<{ current: string }>() // ---cut--- export function Broken() { return } ``` To contribute nothing, do not render the `Slot` at all — installing a runtime feature is mounting it, so uninstalling it is `&&`. ### `'useContribution' called outside of a plugin contribution` `useContribution` reads the context `ContributionBoundary` provides around each declared contribution. It throws anywhere else, including inside a façade fill, which belongs to no plugin. ```tsx twoslash // [!include ~/snippets/errors-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/errors-guide.tsx:use-contribution] ``` That throw is what makes the hook usable as a store key: it cannot silently return the wrong plugin. ## Diagnostics The resolver never throws over a manifest defect — a typo in one plugin must not take the application down. Problems come back on `resolution.diagnostics`, and in development the provider prints each set once per content change with `console.error`. ```tsx twoslash // [!include ~/snippets/errors-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/errors-guide.tsx:diagnostics] ``` | code | meaning | | --- | --- | | `duplicate-plugin-id` | Two plugins share an id, so every contribution id they carry collides. | | `duplicate-contribution-id` | Two contributions resolve to one full id. The first declaration wins; the second is dropped. | | `invalid-contribution-id` | A contribution id is empty or contains `/`. The contribution is dropped. | | `unknown-disable-target` | `disable` names a plugin or contribution that nothing in the list carries. | | `unknown-override-target` | An override targets a full id that nothing in the list carries. | | `override-slot-mismatch` | An override created by one slot targets a contribution of another. The patch is ignored. | Disabling is the integrator's intent, not a defect — a disabled target that exists produces no diagnostic. Names, versions, capabilities and routes are your own manifest fields, so they are yours to check — see [Recipes](/recipes#exclusive-claims). ## Logged warnings ### The host for "x" was given both `renderEntries` and children > `[create-slot] The host for "toolbar" was given both 'renderEntries' and > children; children are ignored while 'renderEntries' is set.` Logged with `console.error` in development only. `renderEntries` owns the empty state, so the children could never render — one of the two has to go. ## Type errors These never reach runtime. They are listed because the message names a structural type, and the cause is easier to see in an example. ### A missing or mismatched props bag `props` is an explicit bag typed by the slot. It is required as soon as the slot declares props, and its values are checked against them. ```tsx twoslash // @errors: 2741 2322 import { defineSlot, SlotHost } from "create-slot" const NavMenu = defineSlot<{ current: string }>("nav-menu") // ---cut--- export function Missing() { return } export function Mismatched() { return } ``` Declare the props on the slot, since they are the host's: ```tsx twoslash // [!include ~/snippets/errors-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/errors-guide.tsx:typed-slot] ``` ### A contribution that asks for props the slot does not give A contribution receives the host's props as its own, so its parameter type must accept them. ```tsx twoslash // @errors: 2322 import { defineSlot } from "create-slot" const NavMenu = defineSlot<{ current: string }>("nav-menu") // ---cut--- function PricingItem({ href }: { href: string }) { return
  • {href}
  • } const contribution = NavMenu.contribute("nav-item", { component: PricingItem }) ``` A contribution may accept fewer props than the host provides — that is ordinary assignability — but never different ones. ### `useSlotProps()` is possibly null `useSlotProps` returns `Props | null`, because nothing guarantees a host above the caller. ```tsx twoslash // @errors: 18047 import { defineSlot, useSlotProps } from "create-slot" const NavMenu = defineSlot<{ current: string }>("nav-menu") // ---cut--- function Item() { const props = useSlotProps(NavMenu) return
  • {props.current}
  • } ``` Handle the null case: ```tsx twoslash // [!include ~/snippets/errors-guide.tsx:prelude] // ---cut--- // [!include ~/snippets/errors-guide.tsx:use-props] ``` `createSlot`'s `useProps` is not nullable: a `Slot`'s children only ever render inside a host. ## React errors you may see through this library ### Hydration mismatch The server and the client resolved from different inputs, or the same inputs in a different order. Matching inputs are the library's only SSR requirement — see [the SSR contract](/server-rendering#the-contract-in-one-sentence). Send the enabled ids with the HTML instead of recomputing them on the client — or resolve on the server and send [the Resolution itself](/server-rendering#react-server-components). ### `This Suspense boundary received an update before it finished hydrating` An urgent state update reached a streamed boundary that had not hydrated yet, so React discarded the streamed HTML and re-rendered it on the client. The usual source is a `setup` loop that registers commands from an effect. Wrap the registration in `startTransition` — see [Plugin state](/state#setup-runs-in-an-effect). ### A missing key warning Not from this library. Every host keys declared contributions with the full id `pluginId/contributionId`, and a façade fill gets a key stamped on the cloned element when it mounts. A key warning points at a list of your own — including wrappers you build in `renderEntries`, which should be keyed with `entry.key`. ## Next * [API](/api) — every export, with the types twoslash infers * [FAQs](/faqs) — the questions that come up in a real application # Examples One CRM whose features are plugins, built three times. The SPA is the [runtime channel](/channels) on its own; the two Next.js shells share `examples/crm-core` — the same manifest rendered by two different servers. ```bash npm run dev:spa # http://localhost:5173 — client-rendered, createSlot only npm run dev:next-pages # http://localhost:3000 — Next.js pages router, SSR npm run dev:next-app # http://localhost:3001 — app router: RSC tier 2 + streaming ``` ## SPA — the runtime channel alone [`examples/spa`](https://github.com/r13v/create-slot/tree/main/examples/spa) No manifest, no Resolution and no provider: a plugin is a component that contributes through the `createSlot()` façade, and installing it is mounting it as a child of the shell. Live toggles are `&&`. Per-row hosts hand the same fill different props. It is also where the runtime channel's cost is visible — no error boundary wraps a fill, so the crash-test feature has to bring its own. ## Pages router — the registry, server-rendered [`examples/nextjs-pages`](https://github.com/r13v/create-slot/tree/main/examples/nextjs-pages) The same features declared up front, resolved once per request, in the HTML the server sends. Run it and view source at `http://localhost:3000/deals?view=stale`: * the plugins' nav items, deal actions and panels are already in the markup; * the saved view has already been applied by the application's resolved table; * the pipeline card carries the target its `preload` fetched. Two things are missing from that HTML **on purpose**: the status bar shows its placeholder, because it is a façade fill, and the command list is empty, because `setup` runs in an effect. Both appear a moment after hydration. That page is the difference between the two channels, stated in markup instead of prose. ## App router — RSC and streaming [`examples/nextjs-app`](https://github.com/r13v/create-slot/tree/main/examples/nextjs-app) The same HTML again, under React Server Components — tier 2 of [the RSC story](/server-rendering#react-server-components). The manifests follow the two-module discipline, so a server layout calls `resolvePlugins` from `create-slot/core` and hands the whole Resolution across the client boundary as a prop. The sidebar carries a host with no client half at all — `entriesOf` plus `ContributionBoundary` in `app/server-nav.tsx` — and one slow dashboard card streams in on its own, after the rest of the page. ## Shared core [`examples/crm-core`](https://github.com/r13v/create-slot/tree/main/examples/crm-core) The features both Next.js shells mount, and where the patterns the library deliberately leaves to the application actually live: * `resolveViews` — [exclusive claims](/recipes#exclusive-claims), resolved into one table before render, reporting the plugin it refused * `describeCatalog` — [an inventory](/recipes#inventory), which is a `.map` * redux slices combined from the catalog, with `preload` for server-side initial state, and a mobx store per application instance — see [Plugin state](/state) * `crm-core/server` — the **server seam**: plugin ids and per-request loaders in a module the server graph can import, separate from the manifests that point at it # FAQs ## Can one slot have several hosts? Yes, any number, mounted at once. Each renders the same contributions and hands them its own props — which is what makes one contribution adapt per row of a table. See [Slots, hosts & fills](/slots#many-hosts-one-contribution). ## What renders when nothing is contributed? The host's own children. There is no separate placeholder API. Note the exact wording: children render while nothing is *contributed*, not while nothing produced *output*. Three contributions that all return `null` mean the host has contributions, so the placeholder stays hidden — and the container is genuinely empty, so [CSS covers it](/recipes#empty-states). ## Does it work with server rendering? The [registry](/registry) does, fully: contributions are in the HTML, with no effects on the render path. The only requirement is that the resolver gets the same inputs, in the same order, on both sides. The runtime channel does not. Its server and first hydration snapshots are both empty; fills register from an effect after hydration, replacing each façade host's placeholder. See [Server rendering](/server-rendering). ## Two contributions with the same `order`? Both render. The tie is stable: plugin position in the list, then declaration position inside the plugin. Nothing is overwritten and nothing is dropped. On the façade, fills that share an `order` render in mount order. ## Why is `useSlotProps()` nullable? Because nothing guarantees a host above its caller. The `createSlot` façade's `useProps` is the non-nullable one, since a `Slot`'s children only ever render inside one of its factory's hosts. Inside a *declared* contribution you rarely need either: the host's props arrive as your component's own props, already narrowed. ## How is this different from a portal? A portal moves DOM nodes. `create-slot` composes UI logically: the contribution is rendered by the host, inside the host's React tree, so it sees the host's context and its props. Nothing is relocated in the DOM after the fact. ## How is this different from pushing elements through context? You are not maintaining an array of elements in a global context, and the host is not re-rendering the world when one feature changes. The registry resolves once into plain data and compares entries by content; the façade reads its private store with `useSyncExternalStore`. ## Do I need `SlotProvider` for `createSlot`? No. The façade has no provider: its host renders its factory's fills — or its placeholder — anywhere. `SlotHost` throws without a `SlotProvider`, because there a missing provider is a mistake worth reporting. ## Can I use both channels in the same slot? No — and that is the point. A `SlotHost` renders declared contributions only, and a façade host renders its factory's fills only, so a late client fill can never displace markup the server already shipped. Use both channels side by side, each with its own host. See [Two channels](/channels#using-both). ## How do I make exactly one plugin own something? Not with a slot. "Exactly one owner" is a routing problem — resolve the claims into one table before render, where your code knows the keys and can report the plugin it refused. See [Exclusive claims](/recipes#exclusive-claims). ## Can a contribution be conditional? Yes: return `null`. There is no `when` predicate, and a contribution is an ordinary component, so the condition can read hooks, context, a store or a flag — which a predicate in the manifest could not. ## What does the library validate? Two throws at declaration — `defineSlot` and `definePlugin` need a non-empty name and id — and one at render: a façade fill's child must be a single element. Everything about the *manifest* comes back as [diagnostics](/errors#diagnostics) on the Resolution instead: duplicate plugin ids, duplicate or invalid contribution ids, disable and override targets that nothing carries. `expect(resolvePlugins(PLUGINS).diagnostics).toEqual([])` in a test is the catalog validator. Names, capabilities, routes and versions are your manifest's fields, so they are yours to check. ## Does a plugin's contributions share state? On the runtime channel, yes: the feature is one subtree, so ordinary `useState` works. On the declarative channel, no — the contributions are separate places in the tree that carry the same `pluginId`, and a store takes the subtree's place. See [Plugin state](/state). ## Does one feature's state change re-render the others? No, as long as the host's own props did not really change. The host compares its props by value, compares each resolved entry by content, and renders each contribution through a memoised view of it — so a hundred contributions do not re-render because a search box beside them took a keystroke, and even an inline `resolvePlugins()` per render re-renders only hosts and boundary shells. See [Performance](/performance). ## Is a slot name a global? A *declared* slot name is a key in whatever Resolution you resolve — scope it by resolving different graphs for different providers. The façade has no names at all: each `createSlot()` factory owns a private store, so two factories can never exchange fills, and a duplicated copy of the package cannot split a store it does not hold. ## Which React versions work? 18 and up, on any renderer. The only React API the library needs that 17 lacks is `useSyncExternalStore`. React is a peer dependency, and the package has no dependencies of its own. `create-slot/core` needs no React at all. ## Can a contribution use hooks, context and data fetching? Yes. A contribution is an ordinary component rendered by the host, inside the host's React tree, so every context above the host is above it too. A declared contribution is additionally wrapped in `Suspense`, so it may read a promise the layout never awaited — that is what makes per-contribution streaming work. The fallback is the provider's `Pending`, or `null` when none is set. See [Server rendering](/server-rendering#streaming). ## Does a plugin that is turned off stay in my bundle? Its manifest does. Its components do not, if you split them. On the runtime channel a plugin is a module, so `lazy` moves all of it into one chunk, and a plugin you never mount is a chunk the browser never requests. On the declarative channel the manifest has to stay: the application reads it to resolve the graph and to assemble state before render. Only the components move. See [Recipes](/recipes#splitting-a-plugin-module). ## Does it work with React Server Components? Yes, in two tiers. `create-slot/core` never imports React, so a server component can import manifests and call `resolvePlugins` itself — and under [the two-module discipline](/server-rendering#tier-2--the-two-module-discipline) the Resolution it returns crosses the client boundary whole, as metadata plus client references. The simpler tier keeps manifests behind one `"use client"` module and sends **ids** across. See [React Server Components](/server-rendering#react-server-components). ## How do I test a contribution? Render the component directly with the props the host would give it — no provider needed. To test the wiring, resolve and render the host inside a `SlotProvider`; to validate the catalog, assert `diagnostics` is empty. See [Testing](/recipes#testing-a-contribution). ## What happens if a contribution throws? A declared one is caught by the boundary around it: `onError` reports it with the plugin id, contribution id and slot name, and the `Failed` component renders in its place. Recovery is the explicit `reset()` — there is no automatic retry. A façade fill gets a `Suspense` boundary with a `null` fallback so suspension cannot tear down its own registration. It gets no error boundary: it is your own code in your own tree. See [Failure isolation](/failure-isolation). ## Where do I find an error message? Every message the library throws or reports is on one page, with its cause and its fix — see [Errors](/errors). They all start with `[create-slot]`. # Migrating to 4.0 4.0 rebuilt the registry around one pure function. In 3.x the provider built an index from the plugin array during render; in 4.0 the application calls `resolvePlugins` itself and hands the **Resolution** to the provider. Hosts became one generic component, contributions gained a required id, and the runtime channel moved entirely into the `createSlot()` façade — the registry never merges fills. ## The map | 3.x | 4.0 | | --- | --- | | `` | `resolvePlugins(plugins)` + `` | | `slot.contribute({ order, component })` | `slot.contribute("id", { order, component })` — the id is **required** | | `` | `` — an explicit bag, not a spread | | `` | the `createSlot()` façade — a descriptor has no `Fill` | | `slot.useProps()` | `useSlotProps(slot)` | | `usePluginId()` | `useContribution().pluginId` | | `renderFailed={({ reset }) => …}` | `Failed={FailedComponent}` — a component, receiving `{ pluginId, contributionId, slot, error, reset }` | | `onError` | unchanged, now also reports `contributionId` | | — | `Pending={PendingComponent}` — the fallback while a deferred contribution loads | | `type SlotDefinition` | `type Slot` — the descriptor: `{ name, contribute, override }` | | `type Slot` (the façade's) | `type RuntimeSlot` | | `type PluginError` | `type SlotError` | | positional React keys (`pluginId#index`) | stable full ids (`pluginId/contributionId`) | | dev-only duplicate-id warning | six [diagnostics](/errors#diagnostics) on `resolution.diagnostics` | | — | `entriesOf`, `override`, `disable`, `ContributionBoundary`, `create-slot/core` | ## The façade is unchanged `createSlot`, `Slot`, `Slot.Host` and `Slot.useProps` are the names 2.x published, and they still mean what they meant — no codemod, no deprecation. One **type** moved: `Slot` now names the descriptor `defineSlot` returns, and the façade's return type is `RuntimeSlot`. ```tsx twoslash // [!include ~/snippets/migrating.tsx:prelude] // ---cut--- // [!include ~/snippets/migrating.tsx:unchanged] ``` ## Provider and host Before, the provider built the index during render: ```tsx // 3.x ``` Now the application resolves, and the provider distributes: ```tsx twoslash // [!include ~/snippets/migrating.tsx:prelude] // ---cut--- // [!include ~/snippets/migrating.tsx:after] ``` Two things this buys: * **The Resolution is inspectable data** — slot name → sorted entries plus diagnostics — so a validator is `expect(resolvePlugins(PLUGINS).diagnostics).toEqual([])` and an inventory is a `.map`. * **It is React-free.** `resolvePlugins` comes from `create-slot/core`, so a server component can resolve and [send the whole graph across the RSC boundary](/server-rendering#tier-2--the-two-module-discipline). ## Contribution ids are required ```tsx // 3.x — keyed by position NavMenu.contribute({ order: 10, component: PricingItem }) // 4.0 — keyed by id NavMenu.contribute("nav-item", { order: 10, component: PricingItem }) ``` The full id `pluginId/contributionId` is the React key, the `disable` and `override` address, and the name diagnostics use. Because the key is the id, inserting or removing a neighbouring contribution never remounts the others — **the 3.x rule about fixed-shape `contributes` arrays is gone**, along with the advice to hide conditional UI inside a component for the key's sake. ## `renderFailed` becomes `Failed` A component, not a render prop: its identity is stable, and a component reference crosses an RSC boundary where a closure cannot. ```tsx twoslash // [!include ~/snippets/migrating.tsx:prelude] // [!include ~/snippets/migrating.tsx:after] // ---cut--- // [!include ~/snippets/migrating.tsx:failed] ``` There is still no automatic reset — recovery is the `reset` your component is given. `Pending`, new in 4.0, fills the same `Suspense` boundary while a [deferred contribution](/server-rendering#a-deferred-contribution) loads. ## `usePluginId` becomes `useContribution` ```tsx twoslash // [!include ~/snippets/migrating.tsx:prelude] // [!include ~/snippets/migrating.tsx:after] // ---cut--- // [!include ~/snippets/migrating.tsx:identity] ``` ## Fills on a defined slot A 3.x `defineSlot` host merged declared contributions with runtime fills. That host no longer exists: a `SlotHost` renders declared contributions only, so a late client fill can never displace markup the server already shipped. A fill that was genuinely runtime — a status entry that exists only while a dialog is open — moves to a `createSlot()` façade slot with its own host. A fill that existed to inject known-up-front content becomes a declared contribution. See [Two channels](/channels) for the decision written out. ## Migrating from 2.x The façade path is unchanged since 3.0: `order` is a priority, not an array index — two fills that share one both render, in mount order — so order bands, strides, duplicate-order detectors and `resetKey` bookkeeping all stay deletable. See [Ordering](/ordering#read-once) for the read-once rule.