03 / Develop · Shell · Federation

Federation ​

Rendering each satellite's UI inside the Shell — so a signed-in user moves between apps with no reload — is the UI's defining job. It runs on Module Federation: the Shell is the host, each satellite a remote that exposes one entry, ./ShellEntry. The host discovers a remote from the app registry, loads it, and mounts it at /<slug>.

The runtime path ​

When a user opens /<slug>, the host walks this path — the code is ui/src/federation/ plus hooks/useServiceLoader.ts:

  1. Discover — the slug is looked up in the app registry. The row's ui_url is the remote's origin; getRemoteEntryUrl() appends remoteEntry.js. A per-browser localStorage override can repoint a slug at a local dev server.
  2. Load — useServiceLoader({ serviceId }) dynamically imports remoteEntry.js and pulls its ./ShellEntry, racing a timeout with 3 retries (exponential backoff). State runs idle → loading → loaded | error and exposes a retry() the error UI calls.
  3. Mount — the Shell hands the remote a bare DOM element and calls mount(container, props). The remote runs its own ReactDOM.createRoot() on that element and returns a cleanup function. It mounts itself rather than being rendered into the Shell's React tree — that isolation is what avoids the two-React-instances crash.
  4. Chrome & context — the remote's optional meta (displayName, icon, version, routes) drives the nav label, breadcrumb and title. props.embedded (and props.displayMode) tells it whether the Shell is already drawing the header around it — see Display modes; props.apiBaseUrl is the origin it should call its own API at; props.config is per-org settings.
  5. Auth — the Shell broadcasts its token to mounted remotes (utils/token-distribution.ts); each remote subscribes and calls requestToken() on mount, so it authenticates off the Shell's session with no login of its own.

The ShellEntry contract ​

A remote exposes exactly one module as ./ShellEntry. Satisfying this contract is the satellite author's side of federation — see Build a satellite → Frontend:

ts
// The module the Shell pulls from the remote's remoteEntry.js
interface ShellEntryModule {
  mount?: (container: HTMLElement, props?: RemoteMountProps) => () => void; // preferred
  default?: React.ComponentType;    // legacy fallback — a plain root component
  meta?: RemoteMeta;                // optional display metadata
}

interface RemoteMountProps {
  embedded: boolean;                // Shell already draws the header chrome
  displayMode?: 'full-browser' | 'main-layout'; // `embedded` by name; older Shells omit it
  shell?: ShellBridge;              // calls back into the Shell; older Shells omit it — guard every call
  apiBaseUrl?: string;              // call your API here — same-origin /_api/<slug> on a portal domain
  config?: Record<string, unknown>; // per-org app settings, delivered verbatim
}

interface ShellBridge {
  setTitle(title: string | null): void; // title in the Shell's header (main-layout); null = derive from URL
}

interface RemoteMeta {
  displayName?: string;             // "HRM", "Inventory" — nav label, breadcrumb, title
  icon?: string;                    // nav/card icon
  version?: string;
  routes?: Record<string, string>;  // path → label, for breadcrumbs
}

mount() is preferred because the remote owns its React tree; a bare default component is kept working for older remotes. The Shell surfaces meta through useServiceLoader() for the launchpad and breadcrumbs.

Display modes ​

Each app opens one of two ways. The app's own default is apps.display_mode; an org can pick its own on its binding (organizations_apps.display_mode, NULL = inherit) from Settings → Apps → Customize. URLs are the same /<slug>/… in both, so switching never breaks a link.

Zonefull-browsermain-layout (embedded: true)
Rail (app switch, settings, avatar)the app's own, or nonethe Shell's
Top bar (title, notifications, theme, plan)the app's headerthe Shell's — title it with shell.setTitle
App navigation (folders, sections)the app'sthe app's, beside the page
Pagethe app'sthe app's

What the remote must do when embedded:

  • Drop its header, keep its own navigation. Put the page's name in the Shell's header with props.shell?.setTitle(…); the Shell clears it when the app unmounts.
  • Fill, don't grow. The app's root is h-full min-h-0 and its own content container scrolls (a list and a reading pane each scroll on their own). Standalone, it takes the viewport (h-svh).
  • On phones, the Shell hides its hamburger on app routes, so show a slim bar that opens the app's navigation and goes back to the Shell.
  • Global shortcuts (Cmd/Ctrl-K) only standalone — embedded, the keyboard is the Shell's.

The app-frame block in vela-ui does all four; build new apps on it rather than branching on embedded by hand.

Common snags ​

SymptomFix
A remote's assets 404 from :5173Its chunks load from the host page, so relative URLs resolve against the Shell's origin — set an absolute base / publicPath to the remote's own origin (Frontend build).
Cold-load "not signed in", fine after a refreshThe remote mounted but never called requestToken() — it races the Shell's first token broadcast. Always request the token after subscribing.

See also ​