OfficeGest
Module System

Slots

Extension points that let any module contribute UI into a place another module owns, without the owner knowing who the contributors are.

When to use a slot

OGMF modules combine in two distinct ways, and choosing the wrong one creates coupling that is hard to unwind later.

Direct import is for a declared dependency: a module knows it needs a specific component or service from another module and imports it by path (the workshop module importing a customer combobox from entities). The dependency is named, deliberate, and visible in the import graph.

A slot is for the opposite case: a module owns a place where it wants any module to be able to contribute, without knowing in advance who will. The owner exposes the extension point; contributors register against it; neither side imports the other. Reach for a slot only when the set of contributors is open-ended.

If you can name the one module you depend on, import it directly. If you are exposing a seam for unknown future modules to plug into, use a slot.


How a slot works

A slot has two sides. The owning module renders a <Slot> where it wants contributions to appear. Contributing modules declare entries in the slots array of their ModuleDefinition. The module registry collects every contribution at startup, groups them by slot name, and sorts them; <Slot> reads that grouped list and renders each contributed component.

Neither side imports the other. The only shared surface is the slot name (a string constant) and the contribution type, both of which the owner publishes in its own slots.ts.


Rendering a slot (the owner)

The owning module places <Slot> from @/core/slot at the extension point:

import { Slot } from "@/core/slot";

<Slot name="customer:detail:sections" context={{ customer }} />

<Slot> resolves contributions through useRegistry().slots.get(name) and renders each contribution's component, spreading context onto it as props. Its props:

PropTypePurpose
namestringThe slot name to render contributions for. Must match what contributors target.
contextTContext (optional)Data passed as props to every contributed component. Each contribution receives the same context.
wrapperReact.ComponentType<{ children: React.ReactNode }> (optional)A component wrapped around all rendered contributions — e.g. a flex container for an action row.
fallbackReact.ReactNode (optional)Rendered when no contributions are registered for the slot. Defaults to null.
<Slot
  name="customer:detail:actions"
  context={{ customer }}
  wrapper={({ children }) => <div className="flex gap-2">{children}</div>}
  fallback={<p>No extensions installed.</p>}
/>

A contribution whose contribution.component is missing is skipped<Slot> logs [Slot:${name}] Contribution at index ${index} has no "component" property, skipping and renders nothing for it. Every contribution intended to render UI must carry a component.

The context is typed by the generic on <Slot<TContext>>, but it is the owner's responsibility to keep that type in sync with the contribution contract it publishes — the props a contributor's component declares and the context the owner passes are matched by convention, not enforced across the slot boundary.


Contributing to a slot (the contributor)

A contributing module lists its contributions in the slots field of its ModuleDefinition. Build each entry with the slotContribution<T> helper from @/core/types rather than constructing the object literal by hand — it keeps the contribution payload type-checked against the owner's contract.

import { slotContribution } from "@/core/types";
import type { ModuleDefinition } from "@/core/types";
import { WORKSHOP_SLOTS, type WorkshopAppointmentSection } from "@/modules/workshop/slots";

import { MyAppointmentPanel } from "@/modules/my-module/components/my-appointment-panel";

const myModule: ModuleDefinition = {
  id: "my-module",
  slots: [
    slotContribution<WorkshopAppointmentSection>(WORKSHOP_SLOTS.APPOINTMENT_FORM_SECTIONS, {
      component: MyAppointmentPanel,
      order: 20,
    }),
  ],
};

export default myModule;

The SlotContribution<T> shape

slotContribution<T>(slot, contribution) returns a SlotContribution<T>:

FieldTypePurpose
slotstringThe target slot name — must equal the name the owner gave <Slot>.
contributionT & { order?: number }The payload. T is the owner's contract (which carries component and any extra fields); order is always available.

The registry sorts contributions within each slot by order ascending, defaulting to 50. Lower order renders (and, where a slot resolves a single winner, wins) first. Two contributions with the same order keep registration order.

ModuleDefinition.slots is typed as SlotContribution[], so a module can contribute to several different slots from one definition — each entry names its own slot.


Naming conventions

Slot names are namespaced strings, scoped to the owning module so they never collide. A module that owns slots publishes a slots.ts exporting both the name constants and the contribution payload types, so contributors import a single typed surface instead of hard-coding magic strings.

The workshop module names its slot with a colon-delimited module:area:place convention:

export const WORKSHOP_SLOTS = {
  APPOINTMENT_FORM_SECTIONS: "workshop:appointment:form-sections",
} as const;

export type WorkshopAppointmentSection = {
  component: ComponentType<WorkshopAppointmentSectionProps>;
  order?: number;
};

export type WorkshopAppointmentSectionProps = {
  form: UseFormReturn<any>;
};

The form builder follows the same pattern — a FORM_SLOTS const for the names and a FormFieldType contract for the payload, from @/components/forms/slots:

const FORM_SLOTS = {
  FIELD_TYPES: "forms:field-types",
} as const;

type FormFieldType = {
  type: string;
  component: ComponentType<{
    name: string;
    config: unknown;
    field: AnyFieldApi;
  }>;
  order?: number;
};

The constant + contract pair is what makes a slot type-safe end to end. Contributors pass the owner's type to slotContribution<T>, so the payload (e.g. type and component for FormFieldType) is checked at the contribution site, and they reference the name via FORM_SLOTS.FIELD_TYPES rather than retyping "forms:field-types".


Worked example: the _development field type

The _development module is a real, shipped consumer of the slot system. Its entire job today is to contribute a licensePlate field type to the form builder's FORM_SLOTS.FIELD_TYPES slot:

import { slotContribution } from "@/core/types";
import { FORM_SLOTS } from "@/components/forms/slots";

import type { ModuleDefinition } from "@/core/types";
import type { FormFieldType } from "@/components/forms/slots";

import { LicensePlateField } from "@/modules/_development/forms-playground/custom-types/license-plate-field";

const _developmentModule: ModuleDefinition = {
  id: "_development",
  slots: [
    slotContribution<FormFieldType>(FORM_SLOTS.FIELD_TYPES, {
      type: "licensePlate",
      component: LicensePlateField as FormFieldType["component"],
    }),
  ],
};

export default _developmentModule;

This is the slot mechanism powering a feature you may already use: the form builder resolves a field's type against its built-ins first, then falls back to whatever modules contributed to FORM_SLOTS.FIELD_TYPES. The form builder owns the slot and renders contributions; _development plugs licensePlate in without the form builder ever importing it. See Custom Field Types for how a contributed type also augments FieldTypeRegistry so the new type value autocompletes in FormFields<T>.

Because the slot contract (FormFieldType) sorts by order and the form builder checks built-ins first, a contribution cannot silently override a built-in field type — built-ins win on a key collision, and order only sorts contributions among themselves.


Where slots fit in the module system

ConceptPage
slots as a field of ModuleDefinitionAnatomy
How contributions are discovered, merged, and sorted at startupDiscovery