Skip to content

fix(router): swap app shell on cross-router SPA navigation - #476

Merged
ivogt merged 7 commits into
mainfrom
fix/cross-app-shell-swap
Apr 21, 2026
Merged

ivogt merged 7 commits into
mainfrom
fix/cross-app-shell-swap

Conversation

@ivogt

@ivogt ivogt commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Cross-router SPA navigation now swaps the target app's document shell instead of reusing the initial app's. Previously, the client detected the routerId mismatch, downgraded to a full tree rebuild, but kept the initial app's rootLayout, basename, and version captured in closures/memos — so app B's Document never rendered.

Changes

  • Live app shell: new AppShellRef holds { routerId, rootLayout, basename, version }. renderSegments reads rootLayout through the ref at call time; NavigationProvider exposes basename/version as live getters on a stable context value (no memo churn).
  • applyAppShell on mismatch: in partial-update.ts, when routerId changes, invokes the new bridge.applyAppShell(next) before the full-update render. It updates the ref, rebinds rango-state to the target app's namespaced localStorage key, and drops this tab's nav + prefetch caches locally (no cross-tab broadcast, no shared-state rotation).
  • Namespaced rango-state: rango-state:{routerId} instead of a single key. Sibling apps on the same origin can't observe each other's invalidations; the storage listener filters by the tab's current key so smooth transitions don't let other tabs clobber this tab's state.
  • Document-lifetime fields: themeConfig, initialTheme, warmupEnabled, and prefetchCacheTTL are intentionally NOT part of the shell — each has a documented reason (ThemeProvider must not remount; warmup idle listeners; TTL not serialized on every full-render path). A hard document navigation applies them.

Test plan

  • E2E in examples/cloudflare-multi-router (dev + production):
    • Cross-app SPA nav swaps rootLayout (observable via per-Document data-app-shell attribute) without full reload.
    • Back navigation restores previous rootLayout.
    • Cross-tab app isolation: tab 1 smooth-switches A→B while tab 2 stays in A and writes a rotated token to A's namespaced key. Tab 1's next SPA request must NOT send tab 2's rotated A token. Verified the test catches the regression by temporarily relaxing the storage-key filter.
  • Updated existing rango-state-invalidation.test.ts and cross-tab-invalidation.test.ts to locate the namespaced key via a helper (instead of the legacy single-app key).
  • Typecheck, 2970 unit tests, 40 multi-router e2e, 30 rango-state + cross-tab e2e, 39 theme e2e, lint, format all green.

Out of scope (follow-ups)

  • Prefetch basename guard to avoid speculatively fetching cross-app URLs.
  • Extending navigation cache entries with an app-shell snapshot for popstate restore (conservative clearHistoryCacheLocal on app switch covers the common case today).
  • Hard-reload fallback when the target payload is missing shell metadata.

Previously, when a same-origin SPA navigation crossed into a different
router (via host router path mount), the client detected the routerId
mismatch and downgraded to a full tree rebuild — but the target app's
rootLayout, basename, and version all stayed captured from the initial
load. The target app's Document was never rendered.

Introduces a live AppShell ref holding { routerId, rootLayout, basename,
version }. renderSegments reads rootLayout through the ref at call time
so the initial closure capture is gone; NavigationProvider exposes
basename/version as live getters on a stable context value so consumers
read current values without memo-invalidation churn.

On routerId mismatch, partial-update invokes bridge.applyAppShell(next)
before the full-update render. applyAppShell updates the ref, sets the
app version, rebinds rango-state to the target app's namespaced
localStorage key, and drops this tab's navigation + prefetch caches
locally — no cross-tab broadcast, no shared-state rotation.

Contract decisions:
- theme, warmup, and prefetchCacheTTL are document-lifetime. ThemeProvider
  sits above the segment tree and must not remount on smooth transitions;
  warmup toggles tear down long-lived idle listeners; prefetchCacheTTL is
  not serialized on every full-render path (e.g. the not-found fallback),
  so carrying it in the mutable shell would silently erase it on cross-
  app nav to a 404. A full document navigation applies these.
- rango-state is namespaced per-router (rango-state:{routerId}) so sibling
  apps on the same origin cannot observe each other's invalidations. The
  storage listener filters by the current key so smooth transitions in
  this tab don't let other tabs in the old app clobber the B-app state.
- Smooth app-switch does not write the shared rango-state key or broadcast
  cache invalidation. setRangoStateLocal mutates only this tab's in-memory
  state; clearHistoryCacheLocal skips broadcast.

E2E coverage in examples/cloudflare-multi-router covers dev + production:
- cross-app SPA nav swaps rootLayout (data-app-shell attribute in each
  app's Document changes from a→b without full reload)
- back navigation restores previous rootLayout
- cross-tab app isolation: tab 1 smooth-switches A→B while tab 2 invalidates
  A; tab 1 must keep sending B's rango-state on its next request

Verified by temporarily relaxing the storage-key filter — the isolation
test correctly flags the cross-app token bleed.
@pkg-pr-new

pkg-pr-new Bot commented Apr 21, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@476

commit: aec7626

@ivogt
ivogt marked this pull request as ready for review April 21, 2026 12:11
ivogt added 3 commits April 21, 2026 15:12
rango-state's localStorage key is now `rango-state:{routerId}` for
multi-app isolation; legacy `rango-state` remains the fallback when no
routerId is supplied. Add a helper that locates the key regardless of
namespace and replace direct `localStorage.getItem("rango-state")` reads
in tests/vite-rsc-demo.

Also picks up format fixes on README.md and skills/links/SKILL.md that
oxfmt 0.35 flagged after the main merge.
@ivogt
ivogt marked this pull request as draft April 21, 2026 15:16
ivogt added 3 commits April 21, 2026 18:20
…rective

oxfmt treated mixed server+client snippets as one TS module and
rewrote the mid-block "use client"; as ("use client");, turning the
directive into an ineffective parenthesized string expression. Users
copying those examples would get server components by mistake.

Split each mixed example into two adjacent fenced blocks so the
client side starts with "use client" at the top — oxfmt now
recognizes it as a valid directive prologue and leaves it bare.

Affected: README.md and skills/{links,hooks,typesafety,breadcrumbs}
/SKILL.md.
# Conflicts:
#	packages/rangojs-router/skills/links/SKILL.md
@ivogt
ivogt marked this pull request as ready for review April 21, 2026 18:02
@ivogt
ivogt merged commit 83ca949 into main Apr 21, 2026
7 checks passed
@ivogt
ivogt deleted the fix/cross-app-shell-swap branch April 21, 2026 18:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant