Repository navigation
fix(router): redirect on cross-app navigation so the target document is fully re-established - #555
Merged
Merged
Conversation
…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).
commit: |
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
force-pushed
the
fix/crossapp-document-css
branch
from
June 9, 2026 12:07
00e57a3 to
4841005
Compare
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
force-pushed
the
fix/crossapp-document-css
branch
from
June 10, 2026 05:10
5b8375d to
0fb1911
Compare
8 of 9 tasks
4 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Under a host router (
createHostRouter) that mounts multiple apps on one origin (e.g. asiteapp at/and astoreapp 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:<link rel="stylesheet">byhref. When the source app renders a shared href unmanaged (noprecedence) 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.cssbytes 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.applyAppShelldeliberately omitsthemeConfig/initialTheme(alsowarmupEnabled,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 callsuseTheme(), 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_partialwith a_rsc_ridthat doesn't match the matched app) now classifies as a terminalapp-switchplan; the server emitsX-RSC-Reload: <target>(the exact mechanism version-mismatch already uses), and the client — which already honors it — doeswindow.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:
/shophref, unmanaged in site)useTheme, theme config)/shop→/shop/cart)Semantics
_rsc_partial+_rsc_rid≠ matched appapp-switch(new, terminal)X-RSC-Reload: <clean target>→ full document nav_rsc_partial+_rsc_rid≠ matched app, target route missingapp-switch_rsc_partial+ matching_rsc_ridpartial-render_rsc_partial)full-renderversion-mismatchX-RSC-Reload(unchanged)Code changes
router/request-classification.ts— new terminalAppSwitchReloadPlan(mode: "app-switch", cleanreloadUrl); a cross-app partial returns it instead of silently downgrading tofull-render. The check runs before route resolution (likeversion-mismatch), so a cross-app navigation to a route that doesn't exist in the target app still reloads — rather than throwingRouteNotFoundErrorand rendering the target's 404 in place under the source app's document.rsc/handler.ts— emitsX-RSC-Reloadfor theapp-switchplan.rsc/origin-guard.ts—app-switchclassified 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:
applyAppShell+ the unusedupdateAppShell,AppShellRef.update(the shell is set once at init, read-only now), theclearHistoryCacheLocal → clearCacheInternalLocal → clearPrefetchCacheLocalchain, andsetRangoStateLocal— each only reachable through the soft app-switch that no longer happens.clearPrefetchCache(the broadcasting variant) stays.store.setRouterIdsync inpartial-update.ts— it re-readrouterIdfrom partial payloads, butrouterIdis 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.routerIdstays 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.versionunchanged — the dev HMR refetch (classifiedpartial-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_voutbound) 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:X-RSC-Router-Idon RSC content responses only (not on control reload/redirect responses, so reloads stay self-steering). The client checks it viacheckRouterIdHeader(both-present guard) beforecreateFromFetch— 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).partial-updateguard rejects a payload whosemetadata.routerIdmismatches 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
routerIdstays 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
request-classification.test.ts): a cross-app partial →app-switchreload with the clean URL; a cross-app partial to a missing route (findMatch → null) still classifies asapp-switch(not a throw); a direct (non-partial) cross-app load stillfull-render.examples/cloudflare-multi-router): a cross-app nav does a full reload (expectFullReloadhelper); the target's CSS applies afterward (including a stylesheet shared across apps, viashared-tailwind.css); the target's theme applies (app-bopts intodata-theme,sitedoesn'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 assertsexpectNoReload. 54 passed.partial-update.test.ts(post-decode: mismatch discarded+reload, match applied);response-adapter.test.ts(checkRouterIdHeadermatch/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).hmr-intercept(confirmed green in a clean isolated run — HMR readsversion, which is kept, neverrouterId), plus the CI-excludedroute-types-hmrwatcher tests. The cleanup was independently verified by an earlier full e2e (1684 passed).Notes / follow-ups
applyAppShellmachinery (+ the rango-state "smooth cross-app transition" helpers) has been removed in this PR (see the cleanup section above).routerIdremains serialized in payloads as the current-app identity for frontend consumers./host-router(cross-app = full reload),/css(general CSS guidance;precedenceis good practice, not a cross-app requirement),/tailwind(same),/rangocatalog.