@rangojs/router 0.0.0-experimental.83 → 0.0.0-experimental.8332dbe4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +112 -17
  2. package/dist/vite/index.js +1197 -454
  3. package/package.json +4 -2
  4. package/skills/breadcrumbs/SKILL.md +3 -1
  5. package/skills/handler-use/SKILL.md +2 -0
  6. package/skills/hooks/SKILL.md +30 -2
  7. package/skills/i18n/SKILL.md +276 -0
  8. package/skills/intercept/SKILL.md +25 -0
  9. package/skills/layout/SKILL.md +2 -0
  10. package/skills/links/SKILL.md +234 -16
  11. package/skills/loader/SKILL.md +70 -3
  12. package/skills/middleware/SKILL.md +2 -0
  13. package/skills/migrate-nextjs/SKILL.md +3 -1
  14. package/skills/migrate-react-router/SKILL.md +4 -0
  15. package/skills/parallel/SKILL.md +9 -0
  16. package/skills/rango/SKILL.md +2 -0
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/server-actions/SKILL.md +739 -0
  20. package/skills/streams-and-websockets/SKILL.md +283 -0
  21. package/skills/typesafety/SKILL.md +9 -1
  22. package/skills/view-transitions/SKILL.md +212 -0
  23. package/src/browser/app-shell.ts +52 -0
  24. package/src/browser/event-controller.ts +44 -4
  25. package/src/browser/navigation-bridge.ts +113 -6
  26. package/src/browser/navigation-store.ts +25 -1
  27. package/src/browser/partial-update.ts +44 -10
  28. package/src/browser/prefetch/cache.ts +16 -0
  29. package/src/browser/rango-state.ts +53 -13
  30. package/src/browser/react/NavigationProvider.tsx +64 -16
  31. package/src/browser/react/filter-segment-order.ts +51 -7
  32. package/src/browser/react/index.ts +3 -0
  33. package/src/browser/react/use-params.ts +8 -5
  34. package/src/browser/react/use-reverse.ts +99 -0
  35. package/src/browser/react/use-router.ts +8 -1
  36. package/src/browser/react/use-segments.ts +11 -8
  37. package/src/browser/rsc-router.tsx +34 -6
  38. package/src/browser/types.ts +19 -0
  39. package/src/build/route-trie.ts +2 -1
  40. package/src/cache/cf/cf-cache-store.ts +5 -7
  41. package/src/client.rsc.tsx +3 -0
  42. package/src/client.tsx +5 -1
  43. package/src/href-client.ts +4 -1
  44. package/src/index.rsc.ts +3 -0
  45. package/src/index.ts +3 -0
  46. package/src/outlet-context.ts +1 -1
  47. package/src/response-utils.ts +28 -0
  48. package/src/reverse.ts +62 -39
  49. package/src/route-definition/dsl-helpers.ts +16 -3
  50. package/src/route-definition/helpers-types.ts +6 -1
  51. package/src/route-definition/resolve-handler-use.ts +6 -0
  52. package/src/router/handler-context.ts +21 -41
  53. package/src/router/lazy-includes.ts +1 -1
  54. package/src/router/loader-resolution.ts +3 -0
  55. package/src/router/match-api.ts +4 -3
  56. package/src/router/match-handlers.ts +1 -0
  57. package/src/router/match-result.ts +21 -2
  58. package/src/router/middleware-types.ts +14 -25
  59. package/src/router/middleware.ts +54 -7
  60. package/src/router/pattern-matching.ts +101 -17
  61. package/src/router/revalidation.ts +15 -1
  62. package/src/router/segment-resolution/fresh.ts +8 -0
  63. package/src/router/segment-resolution/revalidation.ts +128 -100
  64. package/src/router/substitute-pattern-params.ts +56 -0
  65. package/src/router/trie-matching.ts +18 -13
  66. package/src/router/url-params.ts +49 -0
  67. package/src/router.ts +1 -2
  68. package/src/rsc/handler.ts +8 -4
  69. package/src/rsc/progressive-enhancement.ts +2 -0
  70. package/src/rsc/response-route-handler.ts +11 -10
  71. package/src/rsc/rsc-rendering.ts +3 -0
  72. package/src/rsc/server-action.ts +2 -0
  73. package/src/rsc/types.ts +6 -0
  74. package/src/segment-system.tsx +60 -9
  75. package/src/server/request-context.ts +10 -42
  76. package/src/ssr/index.tsx +5 -1
  77. package/src/types/handler-context.ts +12 -39
  78. package/src/types/loader-types.ts +5 -6
  79. package/src/types/request-scope.ts +126 -0
  80. package/src/types/segments.ts +17 -0
  81. package/src/urls/response-types.ts +2 -10
  82. package/src/vite/debug.ts +184 -0
  83. package/src/vite/discovery/discover-routers.ts +31 -3
  84. package/src/vite/discovery/gate-state.ts +171 -0
  85. package/src/vite/discovery/prerender-collection.ts +48 -1
  86. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  87. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  88. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  89. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  90. package/src/vite/plugins/expose-action-id.ts +52 -28
  91. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  92. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  93. package/src/vite/plugins/performance-tracks.ts +17 -9
  94. package/src/vite/plugins/use-cache-transform.ts +56 -43
  95. package/src/vite/plugins/version-injector.ts +37 -11
  96. package/src/vite/rango.ts +49 -14
  97. package/src/vite/router-discovery.ts +498 -52
  98. package/src/vite/utils/banner.ts +1 -1
  99. package/src/vite/utils/package-resolution.ts +41 -1
  100. package/src/vite/utils/prerender-utils.ts +5 -4
@@ -5,6 +5,8 @@ import type {
5
5
  ResolvedSegment,
6
6
  } from "./types.js";
7
7
  import { setAppVersion } from "./app-version.js";
8
+ import { setRangoStateLocal } from "./rango-state.js";
9
+ import type { AppShell, AppShellRef } from "./app-shell.js";
8
10
  import * as React from "react";
9
11
  import { startTransition } from "react";
10
12
  import {
@@ -48,8 +50,13 @@ export { createNavigationTransaction };
48
50
  */
49
51
  export interface NavigationBridgeConfigWithController extends NavigationBridgeConfig {
50
52
  eventController: EventController;
51
- /** RSC version from initial payload metadata */
53
+ /** RSC version from initial payload metadata (fallback when appShellRef is not provided) */
52
54
  version?: string;
55
+ /**
56
+ * Live app-shell ref. When supplied, the bridge reads version/basename
57
+ * from this ref so cross-app navigations propagate correctly.
58
+ */
59
+ appShellRef?: AppShellRef;
53
60
  }
54
61
 
55
62
  /**
@@ -68,9 +75,46 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
68
75
  export function createNavigationBridge(
69
76
  config: NavigationBridgeConfigWithController,
70
77
  ): NavigationBridge {
71
- const { store, client, eventController, onUpdate, renderSegments } = config;
78
+ const {
79
+ store,
80
+ client,
81
+ eventController,
82
+ onUpdate,
83
+ renderSegments,
84
+ appShellRef,
85
+ } = config;
72
86
  let version = config.version;
73
87
 
88
+ /**
89
+ * Replace the active app-shell snapshot atomically. Called by the partial
90
+ * updater when a response's routerId indicates the navigation crossed
91
+ * into a different app. Runs the local-only side-effects tied to
92
+ * app-shell fields (app version, rango-state namespace) so the new app
93
+ * owns them after the swap. Theme, warmup, and prefetch TTL are
94
+ * document-lifetime and are NOT touched here.
95
+ */
96
+ function applyAppShell(next: AppShell): void {
97
+ if (appShellRef) {
98
+ appShellRef.update(next);
99
+ }
100
+ if (next.version !== undefined) {
101
+ version = next.version;
102
+ setAppVersion(next.version);
103
+ // Use the local-only setter — initRangoState writes the shared
104
+ // localStorage key and fires a storage event in other tabs still in
105
+ // the old app. setRangoStateLocal only mutates this tab's in-memory
106
+ // cache and rebinds it to the target app's routerId-scoped key,
107
+ // preserving the "local-only, no broadcast/rotation" contract for
108
+ // smooth app-switch transitions.
109
+ setRangoStateLocal(next.version, next.routerId);
110
+ }
111
+ // Cross-app: prior cache entries belong to a different app's segments.
112
+ // Drop them locally only — do NOT broadcast invalidation or rotate the
113
+ // shared X-Rango-State token, since other tabs still in the old app are
114
+ // unaffected by this tab's transition.
115
+ store.clearHistoryCacheLocal();
116
+ }
117
+
74
118
  // Create shared partial updater
75
119
  const fetchPartialUpdate = createPartialUpdater({
76
120
  store,
@@ -78,6 +122,7 @@ export function createNavigationBridge(
78
122
  onUpdate,
79
123
  renderSegments,
80
124
  getVersion: () => version,
125
+ applyAppShell,
81
126
  });
82
127
 
83
128
  return {
@@ -496,7 +541,14 @@ export function createNavigationBridge(
496
541
  },
497
542
  scroll: { restore: true, isStreaming },
498
543
  };
499
- const hasTransition = cachedSegments.some((s) => s.transition);
544
+ // Intercept-driven popstate (entering OR leaving an intercept) only
545
+ // mutates the parallel slot; the main outlet shows the same content.
546
+ // Skip startViewTransition in those cases — same rationale as the
547
+ // intercept guard in partial-update.ts's hasTransition computation.
548
+ const hasTransition =
549
+ !isIntercept &&
550
+ !isLeavingIntercept &&
551
+ cachedSegments.some((s) => s.transition);
500
552
  if (hasTransition) {
501
553
  startTransition(() => {
502
554
  if (addTransitionType) {
@@ -623,6 +675,48 @@ export function createNavigationBridge(
623
675
  this.handlePopstate();
624
676
  };
625
677
 
678
+ // React's experimental ViewTransition integration deliberately skips
679
+ // animations for commits originating from a popstate event handler —
680
+ // popstate must finish synchronously to preserve scroll/form
681
+ // restoration, which conflicts with running a transition. The
682
+ // Navigation API's `navigate` event runs in an async-safe context
683
+ // (`event.intercept({ handler })` lets us await), so commits made
684
+ // inside the intercept handler are NOT popstate-originated from
685
+ // React's perspective and the VT walker fires normally for
686
+ // back-/forward-navigations. Falls back to popstate on browsers
687
+ // without Navigation API support (Firefox today); on those browsers
688
+ // back-nav view transitions won't fire — matching the React
689
+ // limitation and current behavior.
690
+ // See https://react.dev/reference/react/ViewTransition.
691
+ const navigationApi: any = (window as any).navigation;
692
+ const supportsNavigationApi =
693
+ !!navigationApi && typeof navigationApi.addEventListener === "function";
694
+
695
+ const handleNavigateEvent = (event: any): void => {
696
+ // Only handle browser history traversal (back/forward).
697
+ // Push/replace are still driven by setupLinkInterception →
698
+ // this.navigate(...) (which calls history.pushState/replaceState).
699
+ if (event.navigationType !== "traverse") return;
700
+ if (!event.canIntercept) return;
701
+ // canIntercept doesn't exclude every cross-document case (e.g., back
702
+ // to a previous same-origin non-Rango document, or a doc-level
703
+ // history.go() target). Without this guard, event.intercept() would
704
+ // forcibly turn that into a same-document navigation and route it
705
+ // through handlePopstate — silently breaking the destination page.
706
+ if (!event.destination?.sameDocument) return;
707
+ if (event.hashChange || event.downloadRequest) return;
708
+ event.intercept({
709
+ // Rango manages scroll restoration itself via
710
+ // handleNavigationStart/handleNavigationEnd inside handlePopstate.
711
+ // Default "after-transition" would double-restore once the intercept
712
+ // promise resolves — opt out so the browser leaves it to us.
713
+ scroll: "manual",
714
+ handler: async () => {
715
+ await this.handlePopstate();
716
+ },
717
+ });
718
+ };
719
+
626
720
  // When the browser restores a page from bfcache (back-forward cache),
627
721
  // any in-flight navigation state is stale. This happens when:
628
722
  // 1. A navigation triggers X-RSC-Reload (e.g., response route hit via SPA)
@@ -648,13 +742,22 @@ export function createNavigationBridge(
648
742
  this.refresh();
649
743
  });
650
744
 
651
- window.addEventListener("popstate", handlePopstate);
745
+ if (supportsNavigationApi) {
746
+ navigationApi.addEventListener("navigate", handleNavigateEvent);
747
+ debugLog("[Browser] Navigation bridge ready (Navigation API)");
748
+ } else {
749
+ window.addEventListener("popstate", handlePopstate);
750
+ debugLog("[Browser] Navigation bridge ready (popstate fallback)");
751
+ }
652
752
  window.addEventListener("pageshow", handlePageShow);
653
- debugLog("[Browser] Navigation bridge ready");
654
753
 
655
754
  return () => {
656
755
  cleanupLinks();
657
- window.removeEventListener("popstate", handlePopstate);
756
+ if (supportsNavigationApi) {
757
+ navigationApi.removeEventListener("navigate", handleNavigateEvent);
758
+ } else {
759
+ window.removeEventListener("popstate", handlePopstate);
760
+ }
658
761
  window.removeEventListener("pageshow", handlePageShow);
659
762
  };
660
763
  },
@@ -664,6 +767,10 @@ export function createNavigationBridge(
664
767
  setAppVersion(newVersion);
665
768
  store.clearHistoryCache();
666
769
  },
770
+
771
+ updateAppShell(next: AppShell): void {
772
+ applyAppShell(next);
773
+ },
667
774
  };
668
775
  }
669
776
 
@@ -12,7 +12,10 @@ import type {
12
12
  ActionStateListener,
13
13
  HandleData,
14
14
  } from "./types.js";
15
- import { clearPrefetchCache } from "./prefetch/cache.js";
15
+ import {
16
+ clearPrefetchCache,
17
+ clearPrefetchCacheLocal,
18
+ } from "./prefetch/cache.js";
16
19
 
17
20
  /**
18
21
  * Default action state (idle with no payload)
@@ -335,6 +338,18 @@ export function createNavigationStore(
335
338
  clearPrefetchCache();
336
339
  }
337
340
 
341
+ /**
342
+ * Drop this tab's navigation + prefetch caches without broadcasting or
343
+ * rotating shared state. Used when the local session changes in a way that
344
+ * doesn't affect other tabs — e.g. this tab crosses into a different app
345
+ * via a cross-router navigation. Other tabs in the old app keep their
346
+ * caches and their X-Rango-State token.
347
+ */
348
+ function clearCacheInternalLocal(): void {
349
+ historyCache.length = 0;
350
+ clearPrefetchCacheLocal();
351
+ }
352
+
338
353
  /**
339
354
  * Mark all cache entries as stale (internal - does not broadcast)
340
355
  */
@@ -668,6 +683,15 @@ export function createNavigationStore(
668
683
  clearCacheAndBroadcast();
669
684
  },
670
685
 
686
+ /**
687
+ * Drop this tab's navigation + prefetch caches locally without
688
+ * broadcasting or rotating shared state. Intended for cross-app
689
+ * transitions where the session state diverges for this tab only.
690
+ */
691
+ clearHistoryCacheLocal(): void {
692
+ clearCacheInternalLocal();
693
+ },
694
+
671
695
  /**
672
696
  * Mark cache as stale and broadcast to other tabs
673
697
  * Called after server actions - allows SWR pattern for popstate
@@ -14,7 +14,10 @@ const addTransitionType: ((type: string) => void) | undefined =
14
14
  import type { RenderSegmentsOptions } from "../segment-system.js";
15
15
  import { reconcileSegments } from "./segment-reconciler.js";
16
16
  import type { ReconcileActor } from "./segment-reconciler.js";
17
- import { hasActiveIntercept as hasActiveInterceptSlots } from "./intercept-utils.js";
17
+ import {
18
+ hasActiveIntercept as hasActiveInterceptSlots,
19
+ isInterceptSegment,
20
+ } from "./intercept-utils.js";
18
21
  import type { BoundTransaction } from "./navigation-transaction.js";
19
22
  import { ServerRedirect } from "../errors.js";
20
23
  import { debugLog } from "./logging.js";
@@ -28,6 +31,23 @@ function toScrollPayload(
28
31
  return { enabled: scroll !== false ? scroll : false };
29
32
  }
30
33
 
34
+ /**
35
+ * Whether to wrap an update in startViewTransition.
36
+ *
37
+ * Intercept-driven updates only mutate the parallel slot — the main outlet
38
+ * shows the same content — so transitions on the underlying main segments
39
+ * shouldn't fire (otherwise their elements get hoisted above the modal).
40
+ */
41
+ function shouldStartViewTransition(segments: ResolvedSegment[]): boolean {
42
+ let hasIntercept = false;
43
+ let hasTransition = false;
44
+ for (const s of segments) {
45
+ if (isInterceptSegment(s)) hasIntercept = true;
46
+ else if (s.transition) hasTransition = true;
47
+ }
48
+ return !hasIntercept && hasTransition;
49
+ }
50
+
31
51
  /**
32
52
  * Configuration for creating a partial updater
33
53
  */
@@ -41,6 +61,13 @@ export interface PartialUpdateConfig {
41
61
  ) => Promise<ReactNode> | ReactNode;
42
62
  /** RSC version getter — returns the current version (may change after HMR) */
43
63
  getVersion?: () => string | undefined;
64
+ /**
65
+ * Replace the active app-shell when a cross-app navigation is detected.
66
+ * Called before the full-update tree replacement renders, so the new
67
+ * payload's rootLayout, basename, and version are picked up. Theme,
68
+ * warmup, and prefetch TTL are not part of the shell — see AppShell.
69
+ */
70
+ applyAppShell?: (next: import("./app-shell.js").AppShell) => void;
44
71
  }
45
72
 
46
73
  /**
@@ -110,6 +137,7 @@ export function createPartialUpdater(
110
137
  onUpdate,
111
138
  renderSegments,
112
139
  getVersion = () => undefined,
140
+ applyAppShell,
113
141
  } = config;
114
142
 
115
143
  /**
@@ -228,7 +256,12 @@ export function createPartialUpdater(
228
256
  // Detect app switch: if routerId changed, the navigation crossed into
229
257
  // a different router (e.g., via host router path mount). Downgrade
230
258
  // partial to full so the entire tree is replaced without reconciliation
231
- // against stale segments from the previous app.
259
+ // against stale segments from the previous app, and replace the app
260
+ // shell (rootLayout, basename, version) so the target app's document
261
+ // and router config take effect instead of remaining captured from the
262
+ // initial load. Theme, warmup, and prefetch TTL are intentionally
263
+ // document-lifetime (see AppShell doc); a new document navigation
264
+ // applies them.
232
265
  if (payload.metadata?.routerId) {
233
266
  const prevRouterId = store.getRouterId?.();
234
267
  if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
@@ -236,6 +269,12 @@ export function createPartialUpdater(
236
269
  `[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
237
270
  );
238
271
  payload.metadata.isPartial = false;
272
+ applyAppShell?.({
273
+ routerId: payload.metadata.routerId,
274
+ rootLayout: payload.metadata.rootLayout,
275
+ basename: payload.metadata.basename,
276
+ version: payload.metadata.version,
277
+ });
239
278
  }
240
279
  store.setRouterId?.(payload.metadata.routerId);
241
280
  }
@@ -319,10 +358,7 @@ export function createPartialUpdater(
319
358
  scroll: toScrollPayload(commitScroll),
320
359
  };
321
360
 
322
- const cachedHasTransition = existingSegments.some(
323
- (s) => s.transition,
324
- );
325
- if (cachedHasTransition) {
361
+ if (shouldStartViewTransition(existingSegments)) {
326
362
  startTransition(() => {
327
363
  if (addTransitionType) {
328
364
  addTransitionType("navigation");
@@ -508,7 +544,7 @@ export function createPartialUpdater(
508
544
 
509
545
  // Emit update to trigger React render.
510
546
  // Scroll info is included so NavigationProvider applies it after React commits.
511
- const hasTransition = reconciled.mainSegments.some((s) => s.transition);
547
+ const hasTransition = shouldStartViewTransition(reconciled.segments);
512
548
  const scrollPayload = toScrollPayload(navScroll);
513
549
 
514
550
  if (mode.type === "action" || mode.type === "stale-revalidation") {
@@ -570,9 +606,7 @@ export function createPartialUpdater(
570
606
  })
571
607
  : tx.commit(segmentIds, segments);
572
608
 
573
- const fullHasTransition = segments.some(
574
- (s: ResolvedSegment) => s.transition,
575
- );
609
+ const fullHasTransition = shouldStartViewTransition(segments);
576
610
  const fullScrollPayload = toScrollPayload(fullScroll);
577
611
 
578
612
  if (mode.type === "stale-revalidation") {
@@ -296,3 +296,19 @@ export function clearPrefetchCache(): void {
296
296
  abortAllPrefetches();
297
297
  invalidateRangoState();
298
298
  }
299
+
300
+ /**
301
+ * Drop all in-memory prefetch state for this tab without rotating rango-state.
302
+ *
303
+ * Use for local-only invalidations (e.g. app switch in this tab) where
304
+ * other tabs should NOT observe a state rotation. Unlike clearPrefetchCache,
305
+ * does not call invalidateRangoState, so the shared X-Rango-State token
306
+ * stays intact and siblings in the old app keep their prefetches.
307
+ */
308
+ export function clearPrefetchCacheLocal(): void {
309
+ generation++;
310
+ inflight.clear();
311
+ inflightPromises.clear();
312
+ cache.clear();
313
+ abortAllPrefetches();
314
+ }
@@ -6,21 +6,37 @@
6
6
  * navigation requests. The server responds with `Vary: X-Rango-State`,
7
7
  * so the browser HTTP cache keys responses by (URL, X-Rango-State value).
8
8
  *
9
- * Format: `{buildVersion}:{invalidationTimestamp}`
9
+ * Value format: `{buildVersion}:{invalidationTimestamp}`
10
10
  * - Build version changes on deploy, busting all cached prefetches.
11
11
  * - Timestamp changes on server action invalidation.
12
12
  *
13
- * localStorage is cross-tab and survives page refresh, so:
14
- * - One tab's prefetch warms the cache for all tabs.
15
- * - Invalidation in one tab is picked up by other tabs on next fetch.
13
+ * Storage key is namespaced per routerId (`rango-state:{routerId}`) so
14
+ * tabs in different apps on the same origin do not collide. Two tabs in
15
+ * the same app share a key → one tab's invalidation is picked up by the
16
+ * other via the `storage` event. A smooth cross-app transition in this
17
+ * tab rebinds to the target app's key; other tabs still in the old app
18
+ * keep their own key intact.
19
+ *
20
+ * If no routerId is supplied, falls back to a single legacy key for
21
+ * backward compatibility (single-app deployments unaffected).
16
22
  */
17
23
 
18
- const STORAGE_KEY = "rango-state";
24
+ const LEGACY_STORAGE_KEY = "rango-state";
25
+
26
+ function buildStorageKey(routerId: string | undefined): string {
27
+ return routerId ? `${LEGACY_STORAGE_KEY}:${routerId}` : LEGACY_STORAGE_KEY;
28
+ }
19
29
 
20
30
  // Module-level cache avoids hitting localStorage on every getRangoState() call.
21
31
  // Initialized from localStorage on first access or by initRangoState().
22
32
  let cachedState: string | null = null;
23
33
 
34
+ // The localStorage key this tab is currently bound to. Rebinds on
35
+ // initRangoState (document boot) and setRangoStateLocal (smooth app
36
+ // switch). The storage listener filters cross-tab events by this key so
37
+ // events from tabs in a different app are ignored.
38
+ let currentStorageKey: string = LEGACY_STORAGE_KEY;
39
+
24
40
  // Cross-tab sync: the `storage` event fires in OTHER tabs when one tab writes
25
41
  // to localStorage, keeping cachedState fresh without polling.
26
42
  let storageListenerAttached = false;
@@ -28,7 +44,10 @@ let storageListenerAttached = false;
28
44
  function attachStorageListener(): void {
29
45
  if (storageListenerAttached || typeof window === "undefined") return;
30
46
  window.addEventListener("storage", (e) => {
31
- if (e.key !== STORAGE_KEY) return;
47
+ // Only react to events for this tab's current app namespace. Events
48
+ // under other routerId-scoped keys belong to other apps and must not
49
+ // clobber this tab's state.
50
+ if (e.key !== currentStorageKey) return;
32
51
  cachedState = e.newValue;
33
52
  });
34
53
  storageListenerAttached = true;
@@ -37,16 +56,22 @@ function attachStorageListener(): void {
37
56
  /**
38
57
  * Initialize the Rango state key in localStorage.
39
58
  * Called once at app startup with the build version from the server.
40
- * If localStorage already has a key with matching version prefix, keeps it
41
- * (preserves invalidation state across refresh). Otherwise writes a new key.
59
+ * The routerId scopes the storage key to this app; in multi-app setups
60
+ * each app owns its own `rango-state:{routerId}` key and cannot observe
61
+ * invalidations from sibling apps on the same origin.
62
+ *
63
+ * If localStorage already has a matching-version entry under the key,
64
+ * keeps it (preserves invalidation state across refresh). Otherwise
65
+ * writes a new value.
42
66
  */
43
- export function initRangoState(version: string): void {
67
+ export function initRangoState(version: string, routerId?: string): void {
68
+ currentStorageKey = buildStorageKey(routerId);
44
69
  if (typeof window === "undefined") return;
45
70
 
46
71
  attachStorageListener();
47
72
 
48
73
  try {
49
- const existing = localStorage.getItem(STORAGE_KEY);
74
+ const existing = localStorage.getItem(currentStorageKey);
50
75
  if (existing) {
51
76
  const colonIdx = existing.indexOf(":");
52
77
  if (colonIdx > 0) {
@@ -59,7 +84,7 @@ export function initRangoState(version: string): void {
59
84
  }
60
85
  // New version or first load
61
86
  const newState = `${version}:${Date.now()}`;
62
- localStorage.setItem(STORAGE_KEY, newState);
87
+ localStorage.setItem(currentStorageKey, newState);
63
88
  cachedState = newState;
64
89
  } catch {
65
90
  // localStorage may be unavailable (private browsing in some browsers)
@@ -77,7 +102,7 @@ export function getRangoState(): string {
77
102
  if (typeof window === "undefined") return "0:0";
78
103
 
79
104
  try {
80
- const stored = localStorage.getItem(STORAGE_KEY);
105
+ const stored = localStorage.getItem(currentStorageKey);
81
106
  if (stored) {
82
107
  cachedState = stored;
83
108
  return stored;
@@ -89,6 +114,21 @@ export function getRangoState(): string {
89
114
  return "0:0";
90
115
  }
91
116
 
117
+ /**
118
+ * Update the in-memory rango-state to a new version WITHOUT writing
119
+ * localStorage. Intended for smooth cross-app transitions in this tab only:
120
+ * subsequent requests from this tab send the new token, but other tabs
121
+ * still in the previous app do not observe a storage event. Rebinds this
122
+ * tab's storage key to the target app's namespace (`rango-state:{routerId}`)
123
+ * so subsequent storage events only reflect the new app. On the next hard
124
+ * reload, initRangoState reconciles localStorage from the server's
125
+ * authoritative version.
126
+ */
127
+ export function setRangoStateLocal(version: string, routerId?: string): void {
128
+ currentStorageKey = buildStorageKey(routerId);
129
+ cachedState = `${version}:${Date.now()}`;
130
+ }
131
+
92
132
  /**
93
133
  * Invalidate the Rango state key. Called when server actions mutate data.
94
134
  * Updates the timestamp portion while keeping the version prefix.
@@ -105,7 +145,7 @@ export function invalidateRangoState(): void {
105
145
  if (typeof window === "undefined") return;
106
146
 
107
147
  try {
108
- localStorage.setItem(STORAGE_KEY, newState);
148
+ localStorage.setItem(currentStorageKey, newState);
109
149
  } catch {
110
150
  // Silently handle localStorage errors
111
151
  }
@@ -28,6 +28,7 @@ import { NonceContext } from "./nonce-context.js";
28
28
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
29
29
  import { cancelAllPrefetches } from "../prefetch/queue.js";
30
30
  import { handleNavigationEnd } from "../scroll-restoration.js";
31
+ import type { AppShellRef } from "../app-shell.js";
31
32
 
32
33
  /**
33
34
  * Process handles from an async generator, updating the event controller
@@ -46,10 +47,22 @@ async function processHandles(
46
47
  store: NavigationStore;
47
48
  matched?: string[];
48
49
  isPartial?: boolean;
50
+ /** Server's `resolvedIds`: every segment re-resolved this request,
51
+ * including null-component ones excluded from `diff`/`segments`.
52
+ * Drives cleanup of stale handle buckets when a re-resolved segment
53
+ * pushed nothing. */
54
+ resolvedIds?: string[];
49
55
  historyKey: string;
50
56
  },
51
57
  ): Promise<void> {
52
- const { eventController, store, matched, isPartial, historyKey } = opts;
58
+ const {
59
+ eventController,
60
+ store,
61
+ matched,
62
+ isPartial,
63
+ resolvedIds,
64
+ historyKey,
65
+ } = opts;
53
66
 
54
67
  let yieldCount = 0;
55
68
  for await (const handleData of handlesGenerator) {
@@ -64,7 +77,7 @@ async function processHandles(
64
77
  }
65
78
 
66
79
  yieldCount++;
67
- eventController.setHandleData(handleData, matched, isPartial);
80
+ eventController.setHandleData(handleData, matched, isPartial, resolvedIds);
68
81
  }
69
82
 
70
83
  // Check again before final updates
@@ -72,12 +85,11 @@ async function processHandles(
72
85
  return;
73
86
  }
74
87
 
75
- // For partial updates where the generator yielded nothing (cached handlers),
76
- // we still need to update the segment order to clean up stale handle data.
77
- // This happens when navigating away from a route - the handlers for the new
78
- // route might not push any breadcrumbs, but we still need to remove the old ones.
88
+ // For partial updates where the generator yielded nothing (every
89
+ // re-resolved handler pushed nothing), still call setHandleData so the
90
+ // cleanup pass can clear out stale buckets for those segments.
79
91
  if (yieldCount === 0 && matched) {
80
- eventController.setHandleData({}, matched, true);
92
+ eventController.setHandleData({}, matched, true, resolvedIds);
81
93
  }
82
94
 
83
95
  // After handles processing completes, update the cache's handleData.
@@ -133,15 +145,23 @@ export interface NavigationProviderProps {
133
145
  warmupEnabled?: boolean;
134
146
 
135
147
  /**
136
- * App version from server payload (stable, immutable).
137
- * Forwarded to context for cache key building.
148
+ * App version from server payload.
149
+ * Used only as a fallback when `appShellRef` is not supplied.
138
150
  */
139
151
  version?: string;
140
152
 
141
153
  /**
142
154
  * URL prefix for all routes (from createRouter({ basename })).
155
+ * Used only as a fallback when `appShellRef` is not supplied.
143
156
  */
144
157
  basename?: string;
158
+
159
+ /**
160
+ * Live app-shell ref. When provided, the context's `basename` and `version`
161
+ * properties become live getters that track app-switch updates without
162
+ * invalidating the memoized context value.
163
+ */
164
+ appShellRef?: AppShellRef;
145
165
  }
146
166
 
147
167
  /**
@@ -175,6 +195,7 @@ export function NavigationProvider({
175
195
  warmupEnabled,
176
196
  version,
177
197
  basename,
198
+ appShellRef,
178
199
  }: NavigationProviderProps): ReactNode {
179
200
  // Track current payload for rendering (this triggers re-renders)
180
201
  const [payload, setPayload] = useState(initialPayload);
@@ -196,18 +217,39 @@ export function NavigationProvider({
196
217
  await bridge.refresh();
197
218
  }, []);
198
219
 
199
- // Context value is stable (store, eventController, navigate, refresh never change)
200
- const contextValue = useMemo<NavigationStoreContextValue>(
201
- () => ({
220
+ // Context value is stable (store, eventController, navigate, refresh never
221
+ // change). When an appShellRef is supplied, `basename` and `version` are
222
+ // installed as live getters so app-switch transitions (which update the ref)
223
+ // propagate to consumers without forcing a tree-wide rerender.
224
+ const contextValue = useMemo<NavigationStoreContextValue>(() => {
225
+ if (appShellRef) {
226
+ const value = {
227
+ store,
228
+ eventController,
229
+ navigate,
230
+ refresh,
231
+ } as NavigationStoreContextValue;
232
+ Object.defineProperty(value, "basename", {
233
+ configurable: true,
234
+ enumerable: true,
235
+ get: () => appShellRef.get().basename,
236
+ });
237
+ Object.defineProperty(value, "version", {
238
+ configurable: true,
239
+ enumerable: true,
240
+ get: () => appShellRef.get().version,
241
+ });
242
+ return value;
243
+ }
244
+ return {
202
245
  store,
203
246
  eventController,
204
247
  navigate,
205
248
  refresh,
206
249
  version,
207
250
  basename,
208
- }),
209
- [],
210
- );
251
+ };
252
+ }, []);
211
253
 
212
254
  // Connection warmup: keep TLS alive after idle periods.
213
255
  // After 60s of no user interaction, marks connection as "cold".
@@ -363,6 +405,7 @@ export function NavigationProvider({
363
405
  store,
364
406
  matched: update.metadata.matched,
365
407
  isPartial: update.metadata.isPartial,
408
+ resolvedIds: update.metadata.resolvedIds,
366
409
  historyKey,
367
410
  }).catch((err) =>
368
411
  console.error("[NavigationProvider] Error consuming handles:", err),
@@ -381,6 +424,7 @@ export function NavigationProvider({
381
424
  {}, // Empty data - all existing data not in matched will be cleaned up
382
425
  update.metadata.matched,
383
426
  true, // partial update - will clean up segments not in matched
427
+ update.metadata.resolvedIds,
384
428
  );
385
429
  }
386
430
  });
@@ -402,7 +446,11 @@ export function NavigationProvider({
402
446
  // Build the content tree
403
447
  let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
404
448
 
405
- // Wrap with ThemeProvider when theme is enabled
449
+ // Wrap with ThemeProvider when theme is enabled. The ThemeProvider is
450
+ // document-lifetime: its config comes from the initial load and does NOT
451
+ // swap on cross-app transitions, because the ThemeProvider sits above the
452
+ // segment tree and a smooth (no-reload) app switch cannot safely remount
453
+ // it. A new theme config only takes effect on a full document load.
406
454
  if (themeConfig) {
407
455
  content = (
408
456
  <ThemeProvider config={themeConfig} initialTheme={initialTheme}>