Skip to content

fix(router): redirect on cross-app navigation so the target document is fully re-established - #555

Merged
ivogt merged 8 commits into
mainfrom
fix/crossapp-document-css
Jun 10, 2026
Merged

ivogt merged 8 commits into
mainfrom
fix/crossapp-document-css

Conversation

@ivogt

@ivogt ivogt commented Jun 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Under a host router (createHostRouter) that mounts multiple apps on one origin (e.g. a site app at / and a store app at /shop), a client-side navigation that crosses an app boundary renders the target app with a broken document. The component tree mounts, but document-level state is silently not re-established:

  • Stylesheets shared across apps are dropped. React 19 de-duplicates <link rel="stylesheet"> by href. When the source app renders a shared href unmanaged (no precedence) and the target renders the same href managed, the soft switch removes the source's link and dedupes the target's against the still-registered href → the target renders unstyled (the .css bytes stay cached; only the applying <link> is gone). This is overwhelmingly a Tailwind problem: every app's @import "tailwindcss" compiles to one shared hashed asset.
  • Theme never takes effect. applyAppShell deliberately omits themeConfig/initialTheme (also warmupEnabled, prefetchCacheTTL) — they're document-lifetime. So the target app runs under the source app's theme runtime; if the source has no theme and the target calls useTheme(), it throws.

The root cause is one class: a soft cross-app swap cannot faithfully re-establish the target app's document.

Fix

Make a cross-app navigation a real document navigation instead of a soft swap. A full document load re-establishes the entire target document — stylesheets, theme, meta, warmup, prefetch-TTL — by construction, fixing the whole class at once.

A cross-app SPA request (_rsc_partial with a _rsc_rid that doesn't match the matched app) now classifies as a terminal app-switch plan; the server emits X-RSC-Reload: <target> (the exact mechanism version-mismatch already uses), and the client — which already honors it — does window.location.href = target. Within-app navigation is unchanged (soft SPA).

Before vs. after

Same consumer code (a normal cross-app link); only the runtime behavior changes:

// In the site app (mounted at /), linking into the store app (mounted at /shop)
<Link to="/shop">Store</Link>
Before (soft swap) After (this PR)
Navigation in-tree app-shell swap, no reload full document load of /shop
Store's stylesheet (shared href, unmanaged in site) dropped → store renders unstyled applies (fresh load)
Store's theme (useTheme, theme config) source's runtime → wrong / throws store's own runtime applies
Within-app nav (e.g. /shop → /shop/cart) soft SPA soft SPA (unchanged)

Semantics

Request Classification Result
_rsc_partial + _rsc_rid ≠ matched app app-switch (new, terminal) X-RSC-Reload: <clean target> → full document nav
_rsc_partial + _rsc_rid ≠ matched app, target route missing app-switch reload (the check runs before route resolution, so a missing target route reloads — not an in-place 404 under the source app)
_rsc_partial + matching _rsc_rid partial-render soft SPA update (unchanged)
Direct load of a cross-app URL (no _rsc_partial) full-render normal render — it is the document nav, no reload
version mismatch version-mismatch X-RSC-Reload (unchanged)

Code changes

  • router/request-classification.ts — new terminal AppSwitchReloadPlan (mode: "app-switch", clean reloadUrl); a cross-app partial returns it instead of silently downgrading to full-render. The check runs before route resolution (like version-mismatch), so a cross-app navigation to a route that doesn't exist in the target app still reloads — rather than throwing RouteNotFoundError and rendering the target's 404 in place under the source app's document.
  • rsc/handler.ts — emits X-RSC-Reload for the app-switch plan.
  • rsc/origin-guard.ts — app-switch classified terminal (no origin check) — the exhaustiveness guard required it.
  • browser/partial-update.ts — the soft app-switch branch is removed (see cleanup below).

Cleanup (also in this PR)

With the soft cross-app transition gone, the client-side soft app-switch machinery is dead:

  • Removed the dead soft-switch machinery (net −159 lines): applyAppShell + the unused updateAppShell, AppShellRef.update (the shell is set once at init, read-only now), the clearHistoryCacheLocal → clearCacheInternalLocal → clearPrefetchCacheLocal chain, and setRangoStateLocal — each only reachable through the soft app-switch that no longer happens. clearPrefetchCache (the broadcasting variant) stays.
  • Removed the dead store.setRouterId sync in partial-update.ts — it re-read routerId from partial payloads, but routerId is a session constant (seeded at init, restored from history-cache on popstate, cross-app crossings hard-reload), so it stored a value the store already held.
  • routerId stays serialized in all payloads (full + partial + action) so the frontend can read the current app/router identity. With the redirect it always equals the current app's id — a cross-app crossing is intercepted server-side (X-RSC-Reload) and never delivers a different-router payload, so it can't signal a different router at runtime, but it remains available as the current-app identity.
  • version unchanged — the dev HMR refetch (classified partial-render) learns a bumped version only from that payload (no reload in dev), so it must stay; it looks redundant on the steady-state nav path (which only sends _rsc_v outbound) but the HMR refetch rides the same branch.

Client integrity guard (defense in depth)

The server is authoritative for cross-app detection, but the client previously trusted that a response belonged to its app and applied whatever came back. Two complementary layers now verify it — both inert in normal operation (within-app responses carry a matching routerId; cross-app crossings redirect), firing only if a foreign payload appears from a stale/edge cache, a proxy mixing responses, or a server bug:

  • Pre-decode (header). The server stamps X-RSC-Router-Id on RSC content responses only (not on control reload/redirect responses, so reloads stay self-steering). The client checks it via checkRouterIdHeader (both-present guard) before createFromFetch — refusing a foreign payload before its chunks are imported. Wired, ordered strictly after the reload handler (no reload loop), into: navigation (validateRscHeaders → reload to target), server actions (server-action-bridge → reload — this apply path has its own decode and is not covered by the post-decode guard, so it was the real gap), and prefetch (executePrefetchFetch → drop, never warm).
  • Post-decode (payload). The partial-update guard rejects a payload whose metadata.routerId mismatches before merge. Kept as the authoritative backstop for the warm/inflight-prefetch reuse paths (navigation runs no fetch at click time, so the pre-decode nav check is bypassed) and any header-absent response.

This is the concrete reason routerId stays serialized in every payload. The full server response-site + client decode-site surface was mapped (incl. bypass + false-positive analysis) so no path is missed and no legitimate response (init/redirect/reload/unseeded) can false-fire.

Test plan

  • Unit (request-classification.test.ts): a cross-app partial → app-switch reload with the clean URL; a cross-app partial to a missing route (findMatch → null) still classifies as app-switch (not a throw); a direct (non-partial) cross-app load still full-render.
  • e2e — dev + production (examples/cloudflare-multi-router): a cross-app nav does a full reload (expectFullReload helper); the target's CSS applies afterward (including a stylesheet shared across apps, via shared-tailwind.css); the target's theme applies (app-b opts into data-theme, site doesn't → <html data-theme="dark"> after the switch); and a cross-app nav to a missing target route (/app-b/does-not-exist) reloads rather than 404-ing in place — proven to catch the regression (disabling the reload check makes this test fail); and a same-app nav to a missing route stays a soft in-place 404 (no reload — only crossing an app boundary reloads). Within-app nav still asserts expectNoReload. 54 passed.
  • Unit — integrity guard. partial-update.test.ts (post-decode: mismatch discarded+reload, match applied); response-adapter.test.ts (checkRouterIdHeader match/mismatch/absent — the false-positive guard); navigation-client.test.ts (mismatch → reload to target; reload header wins over a routerId mismatch — no double reload); prefetch-fetch.test.ts (mismatch → dropped, never decoded).
  • typecheck, unit (3402), lint, format.
  • Full router e2e — 1667 passed; the only failures were flaky hmr-intercept (confirmed green in a clean isolated run — HMR reads version, which is kept, never routerId), plus the CI-excluded route-types-hmr watcher tests. The cleanup was independently verified by an earlier full e2e (1684 passed).

Notes / follow-ups

  • This intentionally removes the soft cross-app transition (every cross-app nav is now a full document load). For independently-authored apps with separate documents/themes, a hard boundary is the correct semantics. Within-app navigation is unaffected.
  • The soft applyAppShell machinery (+ the rango-state "smooth cross-app transition" helpers) has been removed in this PR (see the cleanup section above). routerId remains serialized in payloads as the current-app identity for frontend consumers.
  • Skills updated in this PR: /host-router (cross-app = full reload), /css (general CSS guidance; precedence is good practice, not a cross-app requirement), /tailwind (same), /rango catalog.
  • This supersedes an earlier soft-fix approach (a dev+prod "stylesheet dropped" warning + "use precedence everywhere" guidance), which the theme finding showed was incomplete — the reload fixes the whole class.

…is fully re-established

A client-side navigation that crosses a host-router app boundary was a soft
in-tree app-shell swap (no reload). An audit showed a soft swap cannot
faithfully re-establish the target app's document, in more than one way:
- stylesheets shared across apps are dropped by React 19's by-`href` resource
  dedup when one app renders the href unmanaged and another managed (classically
  every app's `@import "tailwindcss"` compiling to one hashed asset); and
- theme / warmup / prefetch-TTL are document-lifetime (applyAppShell excludes
  them, see browser/app-shell.ts), so the target app's theme never takes effect
  (it could even make useTheme() throw).

Both are the same class — target-app document state silently not re-established
on a soft swap. Fix: a cross-app SPA request (`_rsc_partial` + a mismatched
`_rsc_rid`) now classifies as a terminal `app-switch` plan, and the server emits
`X-RSC-Reload`; the client (which already honors it) does a full document
navigation. The target app's whole document re-establishes by construction.
Within-app navigation is unchanged (soft SPA).

- router/request-classification.ts: new terminal `app-switch` plan (clean reload URL).
- rsc/handler.ts: emit X-RSC-Reload for it (same mechanism as version-mismatch).
- rsc/origin-guard.ts: classify `app-switch` as terminal (no origin check).
- browser/partial-update.ts: the soft app-switch branch is now a dead defensive
  fallback (documented); slated for removal.

Tests: classification unit (app-switch reload + a direct cross-app load still
full-renders); examples/cloudflare-multi-router e2e (dev + prod) — a cross-app
nav does a full reload and re-establishes the target's CSS (even a stylesheet
shared across apps) AND theme; new expectFullReload helper. 50 e2e passed.

Skills: /host-router, /css, /tailwind, /rango reframed — cross-app is a full
reload, so document state always re-establishes; `precedence` is good practice,
not a cross-app requirement.

Follow-up: remove the now-dead soft app-switch machinery (applyAppShell + the
rango-state smooth-transition helpers).
@pkg-pr-new

pkg-pr-new Bot commented Jun 9, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 0fb1911

Follow-up cleanup to the cross-app reload fix. Now that a cross-app navigation
is a full document load (X-RSC-Reload), the client-side soft app-switch path is
dead.

Removed (only ever reachable through the soft app-switch that no longer
happens):
- navigation-bridge.ts: applyAppShell + the unused updateAppShell method, and
  the now-unused appShellRef param.
- partial-update.ts: the app-switch branch (applyAppShell call + isPartial
  downgrade), and the dead store.setRouterId sync that re-read routerId from
  partial payloads (routerId is a session constant — seeded at init, restored
  from the history cache on popstate — so the sync stored a value the store
  already held).
- app-shell.ts: AppShellRef.update (the ref is now set once at init, read-only).
- navigation-store.ts: clearHistoryCacheLocal -> clearCacheInternalLocal chain.
- prefetch/cache.ts: clearPrefetchCacheLocal (its sole caller was the chain
  above; clearPrefetchCache, the broadcasting one, stays).
- rango-state.ts: setRangoStateLocal (only applyAppShell called it).
- types.ts: the corresponding NavigationBridge/NavigationStore interface members.
- Reframed the AppShell / rsc-router shell comments away from "smooth cross-app
  transition" (which no longer exists).

routerId stays serialized in payloads (full + partial + action) so the frontend
can read the current app/router identity; with the reload it always equals the
current app's id (a cross-app crossing is intercepted server-side and never
delivers a different-router payload). version is likewise unchanged (the dev HMR
refetch reads it from the partial-render branch).

Verified: typecheck, unit (3391), lint, format; multi-router e2e 50 (dev+prod);
full router e2e (only failures were flaky hmr-intercept, confirmed green in
clean isolation, + the CI-excluded route-types-hmr watcher tests).
@ivogt
ivogt force-pushed the fix/crossapp-document-css branch from 00e57a3 to 4841005 Compare June 9, 2026 12:07
ivogt added 6 commits June 9, 2026 15:12
Addresses a review finding: several source comments still described the removed
soft/smooth in-session app-switch (app shell / basename / rango-state updating
live), contradicting the new invariant that cross-app navigation is a full
document load (X-RSC-Reload). Comment-only; no behavior change.

- rsc-router.tsx / NavigationProvider.tsx (appShellRef prop): the shell is read
  through the ref but set once at init and not swapped in-session.
- use-router.ts: basename is read from context per call but is stable within a
  session (cross-app remounts fresh).
- rango-state.ts: the per-routerId key is bound once at init; no smooth
  cross-app rebind (that path — setRangoStateLocal — was removed).
- types.ts (RscMetadata.routerId): a routerId mismatch is detected server-side
  (via _rsc_rid) and answered with X-RSC-Reload, not a client-side tree
  replacement; within a session it always equals the current app.
The app-switch reload check ran AFTER route resolution, so a cross-app SPA
navigation to a route that doesn't exist in the target app threw
RouteNotFoundError during classification — before the app-switch branch — and
the handler returned a 404 RSC payload the client applied in place, under the
SOURCE app's document. That broke the invariant that crossing a host-router app
boundary is always a full document load (e.g. /app-a -> /app-b/missing rendered
app-b's 404 inside app-a's shell instead of reloading).

Fix: move the app-switch check BEFORE route resolution, mirroring the
version-mismatch check (placed early for the same "stale client requesting a
removed route" reason). A cross-app SPA request now returns the app-switch
reload plan without resolving the route, so a missing target route reloads
instead of 404-ing in place.

- request-classification.ts: app-switch check moved above resolveRoute.
- unit: a cross-app partial with findMatch -> null now classifies as app-switch
  (not a throw).
- e2e (dev + production): app-a links to /app-b/does-not-exist; clicking it must
  hard-reload (expectFullReload) and replace app-a's document. Proven to catch
  the regression — with the reload check disabled the test fails (no reload).

Also reframes two remaining comments that still described live in-session
app-shell swapping (NavigationProvider.tsx, rsc-router.tsx), addressing the
review's Low finding.
…er test helper

/simplify cleanup (behavior-neutral):
- handler.ts: extract createReloadResponse(reloadUrl) — the version-mismatch and
  app-switch terminal plans emitted byte-identical X-RSC-Reload responses;
  centralizes the reload-response contract (status + header + content-type).
- examples/cloudflare-multi-router/e2e/helper.ts: extract injectReloadMarker(page)
  shared by expectNoReload and expectFullReload (they differ only in the dispose
  assertion).
- NavigationProvider.tsx: reframe a stale "smooth (no-reload) app switch" comment
  on the ThemeProvider wrap (no smooth swap exists; cross-app reloads).

Skipped (per review): unifying version-mismatch + app-switch into one plan (the
exhaustive origin-guard enum + distinct rationales make the peers clearer than a
generic reload mode); collapsing AppShellRef (still earns its keep via
memoization stability + closure capture of the live shell).

Verified: typecheck, unit (3392), lint, format, multi-router e2e 52.
…defense in depth)

The server is authoritative for cross-app detection (it redirects on a _rsc_rid
mismatch via X-RSC-Reload), so a partial payload's routerId should always match
the client's. But the client trusted that and merged whatever segments came
back. If a foreign-app payload ever slipped through — a stale/edge cache keyed
without the routerId, a proxy mixing app responses, or a server classification
bug — the client would splice another app's segments + client references into
the current document (content confusion).

Add a client integrity guard in partial-update: if a partial payload's
metadata.routerId doesn't match this client's routerId (store.getRouterId(),
seeded at init), refuse to merge it and force a full reload to the navigation
target, so the server re-establishes the authoritative document for that URL.
(This is the concrete reason routerId stays serialized in every payload.)

The guard is inert in normal operation — within-app partials match, and
cross-app crossings never deliver a partial payload (they redirect). It fires
only on the abnormal foreign-payload case.

Tests (partial-update.test.ts): a mismatched-routerId partial is discarded and
reloads (no merge, console.error); a matching-routerId partial is applied.

Verified: typecheck, unit (3394), lint, format, multi-router e2e 52.
…04 coverage

Hardens the cross-app integrity guard from post-decode to pre-decode, and closes
the one decode-and-apply path the post-decode guard didn't cover (server actions).

Server — stamp X-RSC-Router-Id on the RSC CONTENT responses the client decodes:
- rsc-rendering.ts (the primary partial/full RSC content path),
- server-action.ts (action error + revalidate responses),
- handler.ts (not-found RSC branch).
Control-only responses are deliberately NOT stamped (createReloadResponse,
redirects, response-route bodies) so app-switch/version-mismatch reloads stay
self-steering and header identity never competes with a reload.

Client — checkRouterIdHeader(response, expectedId) in response-adapter.ts, with a
both-present guard ("mismatch" only when header AND expected id are present and
differ; absent header or absent id => pass-through, so init/redirect/reload/
unseeded never false-fire). Wired PRE-decode, ordered strictly after
handleReloadHeader (no reload loop):
- navigation-client validateRscHeaders: mismatch -> reload to the nav target
  before createFromFetch imports the foreign chunks.
- server-action-bridge: mismatch -> reload (this apply path has its own
  createFromFetch and is NOT covered by the post-decode guard — the real gap).
- prefetch/fetch executePrefetchFetch: mismatch -> drop (never warm/decode);
  routerId threaded from both call sites.
The post-decode partial-update guard is KEPT as the authoritative backstop for
the warm/inflight-prefetch reuse paths (no fetch at click time) and any
header-absent response.

Surface mapped by a workflow (server response sites + client decode sites +
bypass/false-positive analysis) to ensure completeness.

Tests:
- response-adapter: checkRouterIdHeader match/mismatch/absent (the false-positive
  guard).
- navigation-client: mismatch -> reload to target; reload header WINS over a
  routerId mismatch (no double reload).
- prefetch-fetch: mismatch -> dropped, never decoded.
- e2e (dev + production): same-app navigation to a missing route stays SOFT
  (in-place 404, no reload) — only crossing an app boundary reloads.

Verified: typecheck, unit (3402), lint, format, multi-router e2e 54 (within-app
navs unaffected with the header present; cross-app reload + same-app 404 green).
…3-value union)

/simplify cleanup (behavior-neutral). checkRouterIdHeader returned
"match" | "mismatch" | "absent", but all three call sites only branched on
=== "mismatch" — the "match" value was dead and "match"/"absent" both meant
pass-through. Renamed to isForeignRouterId returning a boolean (true only when
the header and expected id are both present and differ); callers read as a
plain `if (isForeignRouterId(...))`. Self-documenting and one fewer concept.

Skipped (per review): consolidating the three pre-decode call sites into one
shared validator — the check is already a single helper; only the per-site
actions differ (nav reloads to target, action reloads + handle.complete,
prefetch drops), and inline checks match the existing convention (handleReload-
Header/extractRscHeaderUrl are also called inline at each fetch site).

Verified: typecheck, unit (3402), lint, format.
@ivogt
ivogt force-pushed the fix/crossapp-document-css branch from 5b8375d to 0fb1911 Compare June 10, 2026 05:10
@ivogt
ivogt merged commit edbdd69 into main Jun 10, 2026
25 checks passed
@ivogt
ivogt deleted the fix/crossapp-document-css branch June 10, 2026 09:08
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