Persistent chrome on Leptos islands

July 16, 2026 · View on GitHub

This guide documents the workspace chrome persistence technique used in examples/fullstack-app: sidebar org switcher, account menu, and theme toggle stay the same DOM nodes when the user moves between dashboard, settings, and account pages.

Use it whenever you build a product shell on Leptos islands + Spin/WASI and chrome must not look like it is “loading again” on every route change.

The problem

With pure multi-page islands (no client router script), every <a href> is a full document load. The whole shell re-SSR’s and every #[island] remounts.

Even with a client cache:

let (snapshot, set_snapshot) = signal(None);
// async: after_island_hydration → read cache → set_snapshot(Some(...))

each remount still starts from None, swaps the view tree, and flickers.

Symptoms:

  • Org switcher / account flyout flash skeleton or generic “Workspace” shell
  • Theme toggle briefly shows the wrong glyph (default then localStorage)
  • Network tab fills with repeated get_current_session / list_organizations

Two layers of the fix

LayerWhat it does
A. Cache-first islandsSession + org list in memory + sessionStorage; paint real labels as soon as possible after hydrate
B. Persistent chrome soft-navDo not destroy chrome islands on in-app navigation; only swap page content

B is the real fix for flicker. A is still useful for cold load and for when islands must remount (hard refresh, leaving the shell).

Architecture

┌─────────────────────────────────────────────────────────┐
│ #workspace-shell                                        │
│  ┌──────────────┐  ┌──────────────────────────────────┐ │
│  │ aside        │  │ main                             │ │
│  │  brand       │  │  topbar (title region swaps)     │ │
│  │  #workspace- │  │  #workspace-content  ← SWAPPED   │ │
│  │   primary-nav│  │    (page body + page islands)    │ │
│  │   ← SWAPPED  │  │                                  │ │
│  │  #workspace- │  │                                  │ │
│  │   chrome-foot│  │                                  │ │
│  │   org/account│  │                                  │ │
│  │   theme      │  │                                  │ │
│  │   ← PERSIST  │  │                                  │ │
│  └──────────────┘  └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

Soft navigation for workspace → workspace:

  1. Intercept same-origin clicks (capture phase).
  2. fetch(url, { headers: { "Islands-Router": "true" } }).
  3. Parse HTML; require #workspace-shell on both sides.
  4. Replace only:
    • #workspace-content
    • #workspace-primary-nav
    • #workspace-topbar-title
  5. Hydrate new leptos-island nodes in those regions (window.__hydrateIsland).
  6. history.pushState / replaceState + fire nav-active event.

Chrome foot islands are never touched, so their Leptos state stays alive.

Prerequisites

1. Enable islands router on the shell

// src/app/router.rs — document shell
<HydrationScripts
    options=options.clone()
    islands=true
    islands_router=true   // injects islands_routing.js + soft-nav protocol
    root=""
/>

islands=true alone is not enough. Without islands_router=true, every hop is a full document load.

2. Server must honor Islands-Router

With leptos-wasi-runtime / leptos_wasi feature islands-router, the handler detects the header, provides IslandsRouterNavigation, and can omit redundant hydration scripts on subsequent navigations.

The fullstack example already enables:

leptos_wasi = { ..., features = ["wasip3", "islands-router", "tracing"] }

3. Stable region IDs in the shell markup

RegionRole
#workspace-shellDetect “still in product chrome”
#workspace-contentPage body (always swap)
#workspace-primary-navProduct ↔ settings links (swap)
#workspace-topbar-titlePage title text (swap)
#workspace-chrome-footOrg / account / theme (persist)

Mark persistent areas with data-chrome-persist="true" for documentation and future tooling.

Optional CSS for smoother transitions:

/* via Tailwind arbitrary properties on classes */
[view-transition-name:workspace-chrome-foot]
[view-transition-name:workspace-content]

Implementation sketch (client)

Reference: examples/fullstack-app/src/app/mod.rsinitWorkspaceChromePersist (inline JS exported to WASM via #[wasm_bindgen(inline_js = ...)]).

Installed once from a small shell island:

#[island]
pub fn WorkspaceSidebarControls() -> impl IntoView {
    Effect::new(move |_| {
        #[cfg(feature = "hydrate")]
        {
            init_workspace_sidebar();
            init_workspace_chrome_persist();
        }
    });
    view! { <span class="hidden" aria-hidden="true"></span> }
}

Core navigation rules:

  • Only intercept when both current and target path are “workspace chrome” paths (dashboard, account, organizations, /org/…, admin, …).
  • Leave login/register/public pages to full navigation.
  • Use stopImmediatePropagation in the capture phase so the default islands-router full-tree diff does not remount chrome when branch markers diverge.
  • On failure (network, missing shell), fall back to window.location.href.

Hydrate only unhydrated islands after the swap:

for (const island of root.querySelectorAll("leptos-island")) {
  if (!island.$$hydrated && window.__hydrateIsland) {
    window.__hydrateIsland(island, island.dataset.component);
    island.$$hydrated = true;
  }
}

Cache-first chrome data (layer A)

Shared snapshot for session + memberships (used by org switcher, account menu, settings nav):

  • In-memory thread_local (WASM)
  • sessionStorage key (e.g. workspace-chrome-v1) for remounts within the tab
  • Coalesced refresh so three islands do not stampede /api/ui/*

Pattern:

  1. SSR: stable non-pulse shell (or better: SSR real labels from the cookie).
  2. Hydrate: if cache hit → set_snapshot(Ok(cached)) immediately.
  3. Background: refresh_workspace_chrome_cache() → update if changed.
  4. Client active state: data-nav + mark_active_nav(pathname) (no Router in island context).

See WorkspaceOrgSwitcher, WorkspaceUserMenu, and WorkspaceSettingsNavLinks in examples/fullstack-app/src/app/workspace/mod.rs.

Client-side focus for flyouts

Islands do not sit in the Router context. Highlight the current account / settings item with DOM attributes:

<a href="/account/profile" data-nav="account-profile" class="...">Profile</a>
// mark_active_nav(pathname) toggles .is-active on [data-nav=...]

Re-run after soft-nav via workspace-nav-mark (dispatched from pushState hooks and chrome-persist).

What not to do

Anti-patternWhy it fails
browser_load + skeleton in chrome islands onlyRemount → None → flash every hop
Whole-sidebar #[island]Hydration panics / stuck loaders in practice
Rely only on islands-router tree walkBranch markers in outlet/nav can still replace large ranges
Put chrome islands inside {move || if path …} branchesDiff replaces the branch including the islands
Forget islands_router=trueFull reloads forever

Verification checklist

After changing chrome:

  1. Hard refresh on /dashboard (cold hydrate).
  2. Tag islands in DevTools (dataset.probe = "1").
  3. Click Organizations, Profile, a settings section.
  4. Assert same leptos-island nodes for theme / account / org (probe still set).
  5. Assert #workspace-content text changed.
  6. Assert zero animate-pulse under #workspace-chrome-foot.
  7. Confirm console has no hydrate errors.

Playwright sketch:

themeIsland.dataset.persistProbe = "theme-v1";
await page.click('a[href="/organizations"]');
// expect themeIsland.dataset.persistProbe === "theme-v1"
// expect location.pathname === "/organizations"

Relation to the fullstack template

Product files dual-sync to the CLI template:

# from monorepo root
bash examples/fullstack-app/scripts/sync_fullstack_template.sh
# or check drift
bash examples/fullstack-app/scripts/sync_fullstack_template.sh check

Generated apps from ddd init --preset fullstack pick up the same shell regions and initWorkspaceChromePersist when the CLI package is released.

When this technique is enough

  • Authenticated product shell with a stable sidebar
  • Many routes, same chrome, heavy page islands
  • Spin/WASI + islands (not full CSR SPA)

Prefer a full SPA shell only if you need client-only nested routers without SSR HTML swaps. For most SaaS dashboards on islands, content-only soft-nav + cache-first chrome data is the right balance.

Reference files

FileRole
examples/fullstack-app/src/app/mod.rsinitWorkspaceChromePersist, nav-active helpers
examples/fullstack-app/src/app/router.rsHydrationScripts { islands_router: true }
examples/fullstack-app/src/app/workspace/mod.rsShell regions, chrome islands, cache
examples/fullstack-app/src/app/helpers.rsmark_active_nav including account flyout
examples/fullstack-app/src/ui/classes.rsView-transition names / foot styles
examples/fullstack-app/DESIGN.mdProduct UI tokens + pointers

See also