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:
- Discover — the slug is looked up in the app registry. The row's
ui_urlis the remote's origin;getRemoteEntryUrl()appendsremoteEntry.js. A per-browserlocalStorageoverride can repoint a slug at a local dev server. - Load —
useServiceLoader({ serviceId })dynamically importsremoteEntry.jsand pulls its./ShellEntry, racing a timeout with 3 retries (exponential backoff). State runsidle → loading → loaded | errorand exposes aretry()the error UI calls. - Mount — the Shell hands the remote a bare DOM element and calls
mount(container, props). The remote runs its ownReactDOM.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. - Chrome & context — the remote's optional
meta(displayName,icon,version,routes) drives the nav label, breadcrumb and title.props.embedded(andprops.displayMode) tells it whether the Shell is already drawing the header around it — see Display modes;props.apiBaseUrlis the origin it should call its own API at;props.configis per-org settings. - Auth — the Shell broadcasts its token to mounted remotes (
utils/token-distribution.ts); each remote subscribes and callsrequestToken()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:
// 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.
| Zone | full-browser | main-layout (embedded: true) |
|---|---|---|
| Rail (app switch, settings, avatar) | the app's own, or none | the Shell's |
| Top bar (title, notifications, theme, plan) | the app's header | the Shell's — title it with shell.setTitle |
| App navigation (folders, sections) | the app's | the app's, beside the page |
| Page | the app's | the 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-0and 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
| Symptom | Fix |
|---|---|
A remote's assets 404 from :5173 | Its 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 refresh | The remote mounted but never called requestToken() — it races the Shell's first token broadcast. Always request the token after subscribing. |
See also
- Overview — what the Shell is and how it's built.
- Build a satellite → Frontend — the remote author's side of this contract.