Skip to main content

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.

Shippedv0.7.37 (core/router.ts)
ProcessBIKE.L1-0008
Substratesrc/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

  1. First visit to a tab. The router fetches /?tab={name}&partial=1, which returns just that module's screen() HTML + styles + script. The fragment gets inserted into a fresh .module-pane element under .module-host. The module's client IIFE runs as part of the fragment's <script>.
  2. Subsequent visits. The pane already exists. The router just toggles the data-active attribute on .module-host so CSS shows the right pane and hides the others. No fetch. No re-evaluation. No state loss.
  3. Back / forward. pushState / popstate are wired, so the browser's Back button steps through tab history and the URL stays canonical for deep-linking.
  4. Deep link. Loading /products directly 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:

PatternWhy 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