Routing
The shell owns the address bar and hands each module a view and its params, which is why a module never touches window.location.
A module never touches window.location. The shell owns the address bar, and each module receives
its own slice of it through props — which is what lets the same module tree run at a domain root, at
a sub-path, or embedded in somebody else's app without a line of it changing.
The router is hand-rolled and about 160 lines. There is no react-router, because the vendored
dependency set stays frozen: everything you would have to install to run this app is already in it.
The shape of an address
/<moduleId>[/<view>]?<params>The first segment is the module id — the same string as the directory name and the manifest id.
The second, when there is one, is the module's own view. Everything after ? is that module's
deep-link state.
/contacts is the contacts module at its root. /contacts/fields?density=compact is the same
module, a different screen, with one parameter it understands.
What a module gets
Five props, and no way around them.
| Prop | What it is |
|---|---|
view | the path segment after the module id, '' at the module's root |
setView | move within this module — a view change is a place, so it pushes |
params | this module's deep-link params, the part after ? |
setParams | replace them, with replaceState, adding no history entry |
navigate | somewhere else in the app, as an app-relative path like /livechat?c=42 |
That split is the whole navigation policy. A view is a destination and belongs in the back stack; a filter, a density or an open row is a refinement of where you already are and does not. Get it backwards and the Back button either does nothing or takes eleven presses to leave a screen.
When the address points at some other module, the one being rendered gets view: '' — it is not the
page that was asked for, so it opens at its root rather than at a screen it half-recognises.
Every write comes back through one listener
pushState fires no event of its own, so the shell's navigate dispatches the popstate that
everything already listens for. One code path, whether the shell moved, a module linked into another
module, the assistant undid a navigation, or somebody pressed Back.
Going nowhere is not a navigation: an identical address is dropped. That is also what stops a module which writes its params on every render from looping.
A plain link inside the app is a navigation
Modules write ordinary anchors — "Open in Live Chat", "set this up in Automations" — and a module
may not touch the router, so the shell catches the click instead. Same-origin, unmodified, not a new
tab, not a download, no rel="external": it is turned into a navigation. Everything else is left to
the browser.
An app-relative href is resolved against the mount point at that moment, because the mount point is
the one thing a module is not allowed to know.
Where the app is mounted is the shell's business
The app can be served from a sub-path, and BASE comes from Vite's base option — the same value
that rewrote the asset URLs in index.html, so a build served from anywhere else is broken before
routing gets involved.
The rule that follows holds everywhere: inside the app, an address is written app-relative, and exactly one file knows where the app really is.
Old links still work
Addresses minted while this app routed in the fragment — #/livechat?c=1 — are still in invite mail,
in password-reset mail and in bookmarks. One function rewrites one of those, once, before the first
render.
Supabase's own bare key=value fragment is left exactly where it is, and resolves to the auth
callback whatever the path says, so the gate can explain what happened.
The rule every host has to obey
Real paths mean /deals/board is a real address, and a host that answers it with a 404 produces a
failure that looks like a routing bug and is not one. Each host needs its own SPA fallback — see
picking a host.
What it costs
Address parsing is per module and it fails silently by design: a value the parser does not recognise falls back to the default rather than throwing, because a stale link has to render something. The practical consequence is that a view you forgot to add to the module's list of accepted values looks like a tab that does nothing. Your first change walks through exactly that.
And / is nobody's screen. Until there is a landing page the app opens on the first module in the
menu and rewrites the address to say which one — never on a hidden surface.