Keep-mounted SPA navigation
The operator switches tabs all day. Switching tabs must be free — instantaneous, no re-fetch, no re-render of the module the operator was just inside. The Kernel's router holds every visited module mounted in its own .module-pane and toggles which one is visible. A module's client IIFE runs once per session, not once per visit.
| Shipped | v0.7.37 (core/router.ts) |
| Process | BIKE.L1-0008 |
| Substrate | src/core/router.ts + .module-pane rendering in src/core/layouts.ts |
What changes vs. a naive SPA
A naive SPA replaces the body's contents on every tab click — the module's CSS + markup + script get re-evaluated, the in-memory state (typed search queries, expanded sections, ledger scroll position, pending edits) disappears. The operator typed into a customer popup, switched to Products to check stock, came back — and their typing is gone.
The keep-mounted router holds each module's pane in the DOM after first visit. The second tab click on Customers just sets display back; the typed text, the ledger scroll, the expanded section, everything is still there.
How it works
- First visit to a tab. The router fetches
/?tab={name}&partial=1, which returns just that module'sscreen()HTML + styles + script. The fragment gets inserted into a fresh.module-paneelement under.module-host. The module's client IIFE runs as part of the fragment's<script>. - Subsequent visits. The pane already exists. The router just toggles the
data-activeattribute on.module-hostso CSS shows the right pane and hides the others. No fetch. No re-evaluation. No state loss. - Back / forward.
pushState/popstateare wired, so the browser's Back button steps through tab history and the URL stays canonical for deep-linking. - Deep link. Loading
/productsdirectly lands the operator on Products on first paint — the same partial-fetch path runs server-side to render the Products module's pane as the initial body.
What modules need to know
Every module-authoring decision has to assume the client runs once per session, not once per visit:
| Pattern | Why it matters |
|---|---|
| Element IDs must be module-prefixed. | id="cd-contact-edit-btn" (Customers) and id="sc-product-edit-btn" (Products) coexist in the DOM at the same time. Two modules using id="search-input" would collide. |
| Event listeners attach once. | document.addEventListener('click', …) in a module's client IIFE attaches at first visit and stays. Don't re-attach on every "visit" — there's no such event. |
| Don't assume the module's pane is visible when the script runs. | The client IIFE runs at first mount, regardless of whether the operator is looking at the tab right now. UI work that depends on layout (height, focus) needs to defer until visible. |
| Cleanup is the operator's job, not the framework's. | A modal opened in Customers and left open survives a tab switch — it's still there when the operator comes back. Modules close their own modals on tab switch only if that's the desired UX (most don't; the open modal is the operator's intent). |
What it composes with
- The Module contract (Three strata) —
screen()still returns the full fragment; the router just controls when it's rendered (once) vs. when it's visible (any time). - Permissions (core/perms.ts) — the
screen.{module}permission is checked at fragment-render time; staff without it never get the pane mounted. - Audit — tab navigation events do not write audit rows. The chain is for mutations and security events, not chrome.
See also
- Three strata — Kernel · Meta · Modules — the architectural shape
- No-inline-editing doctrine — the per-section popup pattern that benefits from keep-mounted state
- Customers module and Products module — both rely on the keep-mounted contract