@rangojs/router 0.0.0-experimental.79 → 0.0.0-experimental.7d061845

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 (252) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +2138 -841
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +68 -21
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/document-cache/SKILL.md +78 -55
  13. package/skills/handler-use/SKILL.md +3 -1
  14. package/skills/hooks/SKILL.md +229 -20
  15. package/skills/host-router/SKILL.md +45 -20
  16. package/skills/i18n/SKILL.md +276 -0
  17. package/skills/intercept/SKILL.md +26 -4
  18. package/skills/layout/SKILL.md +6 -7
  19. package/skills/links/SKILL.md +247 -17
  20. package/skills/loader/SKILL.md +219 -9
  21. package/skills/middleware/SKILL.md +15 -9
  22. package/skills/migrate-nextjs/SKILL.md +4 -2
  23. package/skills/migrate-react-router/SKILL.md +5 -0
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +12 -6
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +242 -24
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +66 -9
  31. package/skills/route/SKILL.md +33 -4
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +751 -0
  34. package/skills/streams-and-websockets/SKILL.md +283 -0
  35. package/skills/testing/SKILL.md +816 -0
  36. package/skills/typesafety/SKILL.md +319 -27
  37. package/skills/use-cache/SKILL.md +34 -5
  38. package/skills/view-transitions/SKILL.md +294 -0
  39. package/src/__augment-tests__/augment.ts +81 -0
  40. package/src/__augment-tests__/augmented.check.ts +117 -0
  41. package/src/browser/action-coordinator.ts +53 -36
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/event-controller.ts +86 -70
  44. package/src/browser/history-state.ts +21 -0
  45. package/src/browser/index.ts +3 -3
  46. package/src/browser/navigation-bridge.ts +65 -9
  47. package/src/browser/navigation-client.ts +45 -25
  48. package/src/browser/navigation-store.ts +32 -9
  49. package/src/browser/navigation-transaction.ts +10 -28
  50. package/src/browser/partial-update.ts +52 -26
  51. package/src/browser/prefetch/cache.ts +124 -26
  52. package/src/browser/prefetch/fetch.ts +114 -38
  53. package/src/browser/prefetch/queue.ts +36 -5
  54. package/src/browser/rango-state.ts +53 -13
  55. package/src/browser/react/Link.tsx +18 -13
  56. package/src/browser/react/NavigationProvider.tsx +72 -31
  57. package/src/browser/react/filter-segment-order.ts +51 -7
  58. package/src/browser/react/index.ts +3 -0
  59. package/src/browser/react/location-state-shared.ts +175 -4
  60. package/src/browser/react/location-state.ts +39 -13
  61. package/src/browser/react/use-handle.ts +17 -9
  62. package/src/browser/react/use-navigation.ts +22 -2
  63. package/src/browser/react/use-params.ts +20 -8
  64. package/src/browser/react/use-reverse.ts +106 -0
  65. package/src/browser/react/use-router.ts +22 -2
  66. package/src/browser/react/use-segments.ts +11 -8
  67. package/src/browser/response-adapter.ts +25 -0
  68. package/src/browser/rsc-router.tsx +64 -22
  69. package/src/browser/scroll-restoration.ts +22 -14
  70. package/src/browser/segment-structure-assert.ts +2 -2
  71. package/src/browser/server-action-bridge.ts +23 -30
  72. package/src/browser/types.ts +21 -0
  73. package/src/build/collect-fallback-refs.ts +107 -0
  74. package/src/build/generate-manifest.ts +60 -35
  75. package/src/build/generate-route-types.ts +2 -0
  76. package/src/build/index.ts +2 -0
  77. package/src/build/route-trie.ts +2 -1
  78. package/src/build/route-types/codegen.ts +4 -4
  79. package/src/build/route-types/include-resolution.ts +1 -1
  80. package/src/build/route-types/per-module-writer.ts +7 -4
  81. package/src/build/route-types/router-processing.ts +55 -14
  82. package/src/build/route-types/scan-filter.ts +1 -1
  83. package/src/build/route-types/source-scan.ts +118 -0
  84. package/src/build/runtime-discovery.ts +9 -20
  85. package/src/cache/cache-scope.ts +28 -42
  86. package/src/cache/cf/cf-cache-store.ts +54 -13
  87. package/src/client.rsc.tsx +3 -0
  88. package/src/client.tsx +10 -8
  89. package/src/context-var.ts +5 -5
  90. package/src/decode-loader-results.ts +36 -0
  91. package/src/errors.ts +30 -1
  92. package/src/handle.ts +26 -13
  93. package/src/host/index.ts +2 -2
  94. package/src/host/router.ts +129 -57
  95. package/src/host/types.ts +31 -2
  96. package/src/host/utils.ts +1 -1
  97. package/src/href-client.ts +140 -20
  98. package/src/index.rsc.ts +9 -4
  99. package/src/index.ts +16 -6
  100. package/src/loader-store.ts +500 -0
  101. package/src/loader.rsc.ts +21 -6
  102. package/src/loader.ts +3 -10
  103. package/src/missing-id-error.ts +68 -0
  104. package/src/outlet-context.ts +1 -1
  105. package/src/prerender.ts +4 -4
  106. package/src/response-utils.ts +37 -0
  107. package/src/reverse.ts +65 -39
  108. package/src/route-content-wrapper.tsx +6 -28
  109. package/src/route-definition/dsl-helpers.ts +253 -265
  110. package/src/route-definition/helper-factories.ts +29 -139
  111. package/src/route-definition/helpers-types.ts +43 -15
  112. package/src/route-definition/resolve-handler-use.ts +6 -0
  113. package/src/route-definition/use-item-types.ts +32 -0
  114. package/src/route-types.ts +19 -41
  115. package/src/router/basename.ts +14 -0
  116. package/src/router/content-negotiation.ts +15 -2
  117. package/src/router/error-handling.ts +1 -1
  118. package/src/router/handler-context.ts +21 -41
  119. package/src/router/intercept-resolution.ts +4 -18
  120. package/src/router/lazy-includes.ts +3 -3
  121. package/src/router/loader-resolution.ts +19 -2
  122. package/src/router/match-api.ts +4 -3
  123. package/src/router/match-handlers.ts +63 -20
  124. package/src/router/match-middleware/cache-lookup.ts +44 -91
  125. package/src/router/match-middleware/cache-store.ts +3 -2
  126. package/src/router/match-result.ts +53 -32
  127. package/src/router/metrics.ts +1 -1
  128. package/src/router/middleware-types.ts +15 -26
  129. package/src/router/middleware.ts +99 -84
  130. package/src/router/pattern-matching.ts +101 -17
  131. package/src/router/prerender-match.ts +1 -1
  132. package/src/router/preview-match.ts +3 -1
  133. package/src/router/request-classification.ts +4 -28
  134. package/src/router/revalidation.ts +58 -2
  135. package/src/router/router-interfaces.ts +45 -28
  136. package/src/router/router-options.ts +40 -1
  137. package/src/router/router-registry.ts +2 -5
  138. package/src/router/segment-resolution/fresh.ts +27 -6
  139. package/src/router/segment-resolution/revalidation.ts +147 -106
  140. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  141. package/src/router/substitute-pattern-params.ts +56 -0
  142. package/src/router/telemetry.ts +99 -0
  143. package/src/router/trie-matching.ts +18 -13
  144. package/src/router/types.ts +8 -0
  145. package/src/router/url-params.ts +49 -0
  146. package/src/router.ts +38 -23
  147. package/src/rsc/handler-context.ts +2 -2
  148. package/src/rsc/handler.ts +28 -69
  149. package/src/rsc/helpers.ts +91 -43
  150. package/src/rsc/index.ts +1 -1
  151. package/src/rsc/origin-guard.ts +28 -10
  152. package/src/rsc/progressive-enhancement.ts +4 -0
  153. package/src/rsc/response-route-handler.ts +46 -53
  154. package/src/rsc/rsc-rendering.ts +35 -51
  155. package/src/rsc/runtime-warnings.ts +9 -10
  156. package/src/rsc/server-action.ts +17 -37
  157. package/src/rsc/ssr-setup.ts +16 -0
  158. package/src/rsc/types.ts +8 -2
  159. package/src/search-params.ts +4 -4
  160. package/src/segment-system.tsx +122 -56
  161. package/src/serialize.ts +243 -0
  162. package/src/server/context.ts +118 -51
  163. package/src/server/cookie-store.ts +28 -4
  164. package/src/server/request-context.ts +20 -42
  165. package/src/ssr/index.tsx +5 -1
  166. package/src/static-handler.ts +1 -1
  167. package/src/testing/cache-status.ts +166 -0
  168. package/src/testing/collect-handle.ts +63 -0
  169. package/src/testing/dispatch.ts +440 -0
  170. package/src/testing/dom.entry.ts +22 -0
  171. package/src/testing/e2e/fixture.ts +154 -0
  172. package/src/testing/e2e/index.ts +149 -0
  173. package/src/testing/e2e/matchers.ts +51 -0
  174. package/src/testing/e2e/page-helpers.ts +272 -0
  175. package/src/testing/e2e/parity.ts +306 -0
  176. package/src/testing/e2e/server.ts +183 -0
  177. package/src/testing/flight-matchers.ts +104 -0
  178. package/src/testing/flight-runtime.d.ts +57 -0
  179. package/src/testing/flight-tree.ts +332 -0
  180. package/src/testing/flight.entry.ts +46 -0
  181. package/src/testing/flight.ts +224 -0
  182. package/src/testing/generated-routes.ts +223 -0
  183. package/src/testing/index.ts +106 -0
  184. package/src/testing/internal/context.ts +304 -0
  185. package/src/testing/internal/flight-client-globals.ts +30 -0
  186. package/src/testing/internal/seed-vars.ts +42 -0
  187. package/src/testing/render-handler.ts +267 -0
  188. package/src/testing/render-route.tsx +565 -0
  189. package/src/testing/run-loader.ts +341 -0
  190. package/src/testing/run-middleware.ts +188 -0
  191. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  192. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  193. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  194. package/src/testing/vitest-stubs/version.ts +5 -0
  195. package/src/testing/vitest.ts +270 -0
  196. package/src/types/global-namespace.ts +39 -26
  197. package/src/types/handler-context.ts +68 -50
  198. package/src/types/index.ts +1 -0
  199. package/src/types/loader-types.ts +5 -6
  200. package/src/types/request-scope.ts +126 -0
  201. package/src/types/segments.ts +35 -1
  202. package/src/urls/include-helper.ts +10 -53
  203. package/src/urls/index.ts +0 -3
  204. package/src/urls/path-helper-types.ts +11 -3
  205. package/src/urls/path-helper.ts +17 -52
  206. package/src/urls/pattern-types.ts +36 -19
  207. package/src/urls/response-types.ts +22 -29
  208. package/src/urls/type-extraction.ts +26 -116
  209. package/src/urls/urls-function.ts +1 -5
  210. package/src/use-loader.tsx +413 -42
  211. package/src/vite/debug.ts +185 -0
  212. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  213. package/src/vite/discovery/discover-routers.ts +101 -51
  214. package/src/vite/discovery/discovery-errors.ts +194 -0
  215. package/src/vite/discovery/gate-state.ts +171 -0
  216. package/src/vite/discovery/prerender-collection.ts +67 -26
  217. package/src/vite/discovery/route-types-writer.ts +40 -84
  218. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  219. package/src/vite/discovery/state.ts +33 -0
  220. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  221. package/src/vite/index.ts +2 -0
  222. package/src/vite/plugin-types.ts +67 -0
  223. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  224. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  225. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  226. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  227. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  228. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  229. package/src/vite/plugins/expose-action-id.ts +54 -30
  230. package/src/vite/plugins/expose-id-utils.ts +12 -8
  231. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  232. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  233. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  234. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  235. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  236. package/src/vite/plugins/performance-tracks.ts +29 -25
  237. package/src/vite/plugins/use-cache-transform.ts +65 -50
  238. package/src/vite/plugins/version-injector.ts +39 -23
  239. package/src/vite/plugins/version-plugin.ts +59 -2
  240. package/src/vite/plugins/virtual-entries.ts +2 -2
  241. package/src/vite/rango.ts +116 -29
  242. package/src/vite/router-discovery.ts +750 -100
  243. package/src/vite/utils/ast-handler-extract.ts +15 -15
  244. package/src/vite/utils/banner.ts +1 -1
  245. package/src/vite/utils/bundle-analysis.ts +4 -2
  246. package/src/vite/utils/client-chunks.ts +190 -0
  247. package/src/vite/utils/forward-user-plugins.ts +193 -0
  248. package/src/vite/utils/manifest-utils.ts +21 -5
  249. package/src/vite/utils/package-resolution.ts +41 -1
  250. package/src/vite/utils/prerender-utils.ts +5 -4
  251. package/src/vite/utils/shared-utils.ts +107 -26
  252. package/src/browser/action-response-classifier.ts +0 -99
@@ -0,0 +1,52 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+
3
+ /**
4
+ * App-shell metadata: the set of per-router fields that describe the
5
+ * "envelope" around the current app's segment tree. These fields are set
6
+ * from the initial RSC payload and must be replaced atomically when the
7
+ * client navigates into a different router (app switch).
8
+ *
9
+ * Intentionally NOT part of the shell (all document-lifetime):
10
+ * - themeConfig / initialTheme: ThemeProvider is mounted above the segment
11
+ * tree and must not remount on smooth transitions.
12
+ * - warmupEnabled: attached to the NavigationProvider's lifetime effect;
13
+ * toggling it mid-session would tear down and restart idle listeners.
14
+ * Also not serialized on every full-render path (e.g. the not-found
15
+ * fallback), so carrying it here would be unreliable.
16
+ * - prefetchCacheTTL: the not-found full-render payload does not serialize
17
+ * it, so a cross-app nav into a 404 would silently erase the setting.
18
+ * Mutable shell fields must be serialized on EVERY full-render path,
19
+ * otherwise absent fields are indistinguishable from "new app has no
20
+ * value" and the old app's value is dropped.
21
+ *
22
+ * A new document navigation (hard reload) applies these fields from the
23
+ * target app's initial payload.
24
+ */
25
+ export interface AppShell {
26
+ /** Router identity. Used to namespace per-app client state (e.g. the
27
+ * rango-state localStorage key) so sibling apps on the same origin
28
+ * cannot observe each other's cache invalidations. */
29
+ routerId?: string;
30
+ rootLayout?: ComponentType<{ children: ReactNode }>;
31
+ basename?: string;
32
+ version?: string;
33
+ }
34
+
35
+ /**
36
+ * Mutable container for the active app shell. Read-through via `get()` so
37
+ * closures capture the ref, not the shell, and pick up updates at call time.
38
+ */
39
+ export interface AppShellRef {
40
+ get(): AppShell;
41
+ update(next: AppShell): void;
42
+ }
43
+
44
+ export function createAppShellRef(initial: AppShell): AppShellRef {
45
+ let current = initial;
46
+ return {
47
+ get: () => current,
48
+ update: (next) => {
49
+ current = next;
50
+ },
51
+ };
52
+ }
@@ -113,11 +113,24 @@ export type ActionStateListener = (state: TrackedActionState) => void;
113
113
  export type HandleListener = () => void;
114
114
 
115
115
  /**
116
- * Internal handle state stored in controller
116
+ * Internal handle state stored in controller.
117
+ *
118
+ * Two segment lists are exposed because they serve different consumers:
119
+ *
120
+ * - `segmentOrder` drives handle collection (collectHandleData). Includes
121
+ * parallel slot ids and reorders them after their parent so later-wins
122
+ * collect functions (e.g. Meta) get the right precedence.
123
+ * - `routeSegmentIds` is the layouts-and-routes-only list documented by
124
+ * `useSegments().segmentIds`. Parallels and loader sub-ids are stripped;
125
+ * raw matched order is preserved.
126
+ *
127
+ * Both are derived from the same `matched` input on each setHandleData call
128
+ * so they stay in sync.
117
129
  */
118
130
  export interface HandleState {
119
131
  data: HandleData;
120
132
  segmentOrder: string[];
133
+ routeSegmentIds: string[];
121
134
  }
122
135
 
123
136
  /**
@@ -202,6 +215,14 @@ export interface EventController {
202
215
  data: HandleData,
203
216
  matched?: string[],
204
217
  isPartial?: boolean,
218
+ /**
219
+ * Segment ids that were re-resolved on the server this request (the
220
+ * partial response's `diff`). On a partial update, any existing bucket
221
+ * keyed under one of these ids that has no incoming entry is treated as
222
+ * stale and cleared. Without this, a parallel slot that revalidates but
223
+ * pushes nothing leaves its previous bucket in place forever.
224
+ */
225
+ resolvedIds?: string[],
205
226
  ): void;
206
227
  getHandleState(): HandleState;
207
228
 
@@ -247,6 +268,20 @@ function matchesActionId(
247
268
  return entryActionId.endsWith(`#${subscriptionId}`);
248
269
  }
249
270
 
271
+ // Coalesce rapid notifications into one microtask-deferred fan-out; the
272
+ // setTimeout(0) batching prevents render storms. Each notifier owns its timer
273
+ // so listener kinds coalesce independently.
274
+ function makeDebouncedNotifier(listeners: Set<() => void>): () => void {
275
+ let timeout: ReturnType<typeof setTimeout> | null = null;
276
+ return () => {
277
+ if (timeout !== null) clearTimeout(timeout);
278
+ timeout = setTimeout(() => {
279
+ timeout = null;
280
+ listeners.forEach((listener) => listener());
281
+ }, 0);
282
+ };
283
+ }
284
+
250
285
  // ============================================================================
251
286
  // Implementation
252
287
  // ============================================================================
@@ -300,6 +335,7 @@ export function createEventController(
300
335
  // Handle data from RSC payload
301
336
  let handleData: HandleData = {};
302
337
  let handleSegmentOrder: string[] = [];
338
+ let routeSegmentIds: string[] = [];
303
339
 
304
340
  // Merged route params from current match
305
341
  let routeParams: Record<string, string> = {};
@@ -312,18 +348,7 @@ export function createEventController(
312
348
  const actionListeners = new Map<string, Set<ActionStateListener>>();
313
349
  const handleListeners = new Set<HandleListener>();
314
350
 
315
- // Debounce state notifications to batch rapid updates
316
- let notifyTimeout: ReturnType<typeof setTimeout> | null = null;
317
-
318
- function notify() {
319
- if (notifyTimeout !== null) {
320
- clearTimeout(notifyTimeout);
321
- }
322
- notifyTimeout = setTimeout(() => {
323
- notifyTimeout = null;
324
- stateListeners.forEach((listener) => listener());
325
- }, 0);
326
- }
351
+ const notify = makeDebouncedNotifier(stateListeners);
327
352
 
328
353
  // Debounce per-action notifications
329
354
  const actionNotifyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
@@ -349,18 +374,7 @@ export function createEventController(
349
374
  );
350
375
  }
351
376
 
352
- // Debounce handle notifications
353
- let handleNotifyTimeout: ReturnType<typeof setTimeout> | null = null;
354
-
355
- function notifyHandles() {
356
- if (handleNotifyTimeout !== null) {
357
- clearTimeout(handleNotifyTimeout);
358
- }
359
- handleNotifyTimeout = setTimeout(() => {
360
- handleNotifyTimeout = null;
361
- handleListeners.forEach((listener) => listener());
362
- }, 0);
363
- }
377
+ const notifyHandles = makeDebouncedNotifier(handleListeners);
364
378
 
365
379
  // ========================================================================
366
380
  // Derived State
@@ -407,22 +421,17 @@ export function createEventController(
407
421
  }
408
422
 
409
423
  function getActionState(actionId: string): TrackedActionState {
410
- // Find the most recent action with this ID that's not settling
411
- // Uses suffix matching when actionId is just a name (no #)
412
- const activeEntry = [...inflightActions.values()]
413
- .filter(
414
- (a) => matchesActionId(actionId, a.actionId) && a.phase !== "settling",
415
- )
416
- .sort((a, b) => b.startedAt - a.startedAt)[0];
417
-
418
- // Also check for settling entries to get result/error
419
- const settlingEntry = [...inflightActions.values()]
420
- .filter(
421
- (a) => matchesActionId(actionId, a.actionId) && a.phase === "settling",
422
- )
423
- .sort((a, b) => b.startedAt - a.startedAt)[0];
424
-
425
- const entry = activeEntry || settlingEntry;
424
+ // Prefer the most-recent non-settling entry; fall back to most-recent
425
+ // settling so a just-settled action's result/error stays readable.
426
+ const entry = [...inflightActions.values()]
427
+ .filter((a) => matchesActionId(actionId, a.actionId))
428
+ .reduce<ActionEntry | undefined>((best, a) => {
429
+ if (!best) return a;
430
+ const aActive = a.phase !== "settling";
431
+ const bActive = best.phase !== "settling";
432
+ if (aActive !== bActive) return aActive ? a : best;
433
+ return a.startedAt > best.startedAt ? a : best;
434
+ }, undefined);
426
435
 
427
436
  if (!entry) {
428
437
  return { ...DEFAULT_ACTION_STATE };
@@ -610,6 +619,19 @@ export function createEventController(
610
619
  doSettle();
611
620
  }
612
621
 
622
+ // streamingEnded is forced here for the "streaming never started" case so
623
+ // tryFinalize can run; otherwise the streaming token's end() finalizes.
624
+ function settleWith(result: NonNullable<typeof pendingResult>) {
625
+ if (!inflightActions.has(id) || settled) return;
626
+ actionCompleted = true;
627
+ entry.completed = true;
628
+ pendingResult = result;
629
+ if (entry.phase === "fetching" || streamingEnded) {
630
+ streamingEnded = true;
631
+ tryFinalize();
632
+ }
633
+ }
634
+
613
635
  return {
614
636
  id,
615
637
  abort,
@@ -646,35 +668,11 @@ export function createEventController(
646
668
  },
647
669
 
648
670
  complete(result?: unknown) {
649
- if (!inflightActions.has(id) || settled) return;
650
-
651
- actionCompleted = true;
652
- entry.completed = true;
653
- pendingResult = { type: "success", value: result };
654
-
655
- // If streaming never started or already ended, finalize immediately
656
- // Otherwise wait for streaming to end
657
- if (entry.phase === "fetching" || streamingEnded) {
658
- streamingEnded = true; // Mark as ended if never started
659
- tryFinalize();
660
- }
661
- // If streaming is in progress, tryFinalize() will be called when streaming ends
671
+ settleWith({ type: "success", value: result });
662
672
  },
663
673
 
664
674
  fail(error: unknown) {
665
- if (!inflightActions.has(id) || settled) return;
666
-
667
- actionCompleted = true;
668
- entry.completed = true;
669
- pendingResult = { type: "error", value: error };
670
-
671
- // If streaming never started or already ended, finalize immediately
672
- // Otherwise wait for streaming to end
673
- if (entry.phase === "fetching" || streamingEnded) {
674
- streamingEnded = true; // Mark as ended if never started
675
- tryFinalize();
676
- }
677
- // If streaming is in progress, tryFinalize() will be called when streaming ends
675
+ settleWith({ type: "error", value: error });
678
676
  },
679
677
 
680
678
  getRevalidatedSegments(): Set<string> {
@@ -744,8 +742,15 @@ export function createEventController(
744
742
  data: HandleData,
745
743
  matched?: string[],
746
744
  isPartial?: boolean,
745
+ resolvedIds?: string[],
747
746
  ): void {
748
- const newSegmentOrder = filterSegmentOrder(matched ?? []);
747
+ const rawMatched = matched ?? [];
748
+ const newSegmentOrder = filterSegmentOrder(rawMatched);
749
+ // Separate list for useSegments(): "layouts and routes only" — strip
750
+ // parallels (".@") and loader sub-ids (D digit) without reordering.
751
+ const newRouteSegmentIds = rawMatched.filter(
752
+ (id) => !id.includes(".@") && !/D\d+\./.test(id),
753
+ );
749
754
 
750
755
  if (isPartial && newSegmentOrder.length > 0) {
751
756
  // Partial update: merge new data with existing
@@ -757,10 +762,19 @@ export function createEventController(
757
762
  handleData[handleName][segmentId] = data[handleName][segmentId];
758
763
  }
759
764
  }
760
- // Clean up data from segments no longer in the matched list
765
+ const resolvedIdSet =
766
+ resolvedIds && resolvedIds.length > 0 ? new Set(resolvedIds) : null;
767
+ // Cleanup pass:
768
+ // a) segment dropped from the match list — delete its bucket.
769
+ // b) segment was re-resolved this request but pushed nothing for
770
+ // this handle — its previous bucket is stale.
771
+ // (a) is the existing behavior; (b) requires resolvedIds.
761
772
  for (const handleName of Object.keys(handleData)) {
762
773
  for (const segmentId of Object.keys(handleData[handleName])) {
763
- if (!newSegmentOrder.includes(segmentId)) {
774
+ const droppedFromMatch = !newSegmentOrder.includes(segmentId);
775
+ const reresolvedWithoutPush =
776
+ resolvedIdSet?.has(segmentId) && !data[handleName]?.[segmentId];
777
+ if (droppedFromMatch || reresolvedWithoutPush) {
764
778
  delete handleData[handleName][segmentId];
765
779
  }
766
780
  }
@@ -770,6 +784,7 @@ export function createEventController(
770
784
  handleData = data;
771
785
  }
772
786
  handleSegmentOrder = newSegmentOrder;
787
+ routeSegmentIds = newRouteSegmentIds;
773
788
 
774
789
  notifyHandles();
775
790
  }
@@ -778,6 +793,7 @@ export function createEventController(
778
793
  return {
779
794
  data: handleData,
780
795
  segmentOrder: handleSegmentOrder,
796
+ routeSegmentIds,
781
797
  };
782
798
  }
783
799
 
@@ -61,6 +61,27 @@ export function buildHistoryState(
61
61
  return Object.keys(result).length > 0 ? result : null;
62
62
  }
63
63
 
64
+ /**
65
+ * Stamp an `idx` on the next history entry's state and call push/replaceState.
66
+ * Push increments the current idx; replace keeps it. Initial entry idx is 0.
67
+ * Used by useRouter().back() to detect "first entry in this session" without
68
+ * relying on the Navigation API.
69
+ */
70
+ export function pushHistoryWithIdx(
71
+ state: Record<string, unknown> | null,
72
+ url: string,
73
+ replace: boolean,
74
+ ): void {
75
+ const oldIdx = (window.history.state as { idx?: number } | null)?.idx ?? 0;
76
+ const newIdx = replace ? oldIdx : oldIdx + 1;
77
+ const finalState = { ...(state ?? {}), idx: newIdx };
78
+ if (replace) {
79
+ window.history.replaceState(finalState, "", url);
80
+ } else {
81
+ window.history.pushState(finalState, "", url);
82
+ }
83
+ }
84
+
64
85
  /**
65
86
  * Merge server-set location state into the current history entry.
66
87
  * Replaces the current history state and dispatches notification event
@@ -1,9 +1,9 @@
1
1
  // ============================================================================
2
- // Browser Module - Browser entry point for RSC Router
2
+ // Browser Module - Browser entry point for Rango
3
3
  // ============================================================================
4
4
  //
5
5
  // Usage:
6
- // import { initBrowserApp, RSCRouter } from "rsc-router/browser";
6
+ // import { initBrowserApp, Rango } from "rsc-router/browser";
7
7
  //
8
8
  // For React components (Link, useNavigation, etc.):
9
9
  // import { Link, useNavigation, useAction, href } from "rsc-router/client";
@@ -13,6 +13,6 @@
13
13
  // Browser app initialization
14
14
  export {
15
15
  initBrowserApp,
16
- RSCRouter,
16
+ Rango,
17
17
  type InitBrowserAppOptions,
18
18
  } from "./rsc-router.js";
@@ -5,13 +5,15 @@ 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 {
11
13
  createNavigationTransaction,
12
14
  resolveNavigationState,
13
15
  } from "./navigation-transaction.js";
14
- import { buildHistoryState } from "./history-state.js";
16
+ import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
15
17
  import {
16
18
  handleNavigationStart,
17
19
  handleNavigationEnd,
@@ -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 {
@@ -159,11 +204,7 @@ export function createNavigationBridge(
159
204
  },
160
205
  {},
161
206
  );
162
- if (options.replace) {
163
- window.history.replaceState(historyState, "", url);
164
- } else {
165
- window.history.pushState(historyState, "", url);
166
- }
207
+ pushHistoryWithIdx(historyState, url, options?.replace ?? false);
167
208
 
168
209
  // Ensure new history entry has a scroll restoration key
169
210
  ensureHistoryKey();
@@ -496,7 +537,14 @@ export function createNavigationBridge(
496
537
  },
497
538
  scroll: { restore: true, isStreaming },
498
539
  };
499
- const hasTransition = cachedSegments.some((s) => s.transition);
540
+ // Intercept-driven popstate (entering OR leaving an intercept) only
541
+ // mutates the parallel slot; the main outlet shows the same content.
542
+ // Skip startViewTransition in those cases — same rationale as the
543
+ // intercept guard in partial-update.ts's hasTransition computation.
544
+ const hasTransition =
545
+ !isIntercept &&
546
+ !isLeavingIntercept &&
547
+ cachedSegments.some((s) => s.transition);
500
548
  if (hasTransition) {
501
549
  startTransition(() => {
502
550
  if (addTransitionType) {
@@ -659,11 +707,19 @@ export function createNavigationBridge(
659
707
  };
660
708
  },
661
709
 
710
+ getVersion(): string | undefined {
711
+ return version;
712
+ },
713
+
662
714
  updateVersion(newVersion: string): void {
663
715
  version = newVersion;
664
716
  setAppVersion(newVersion);
665
717
  store.clearHistoryCache();
666
718
  },
719
+
720
+ updateAppShell(next: AppShell): void {
721
+ applyAppShell(next);
722
+ },
667
723
  };
668
724
  }
669
725
 
@@ -15,10 +15,12 @@ import { getRangoState } from "./rango-state.js";
15
15
  import {
16
16
  extractRscHeaderUrl,
17
17
  emptyResponse,
18
+ handleReloadHeader,
18
19
  teeWithCompletion,
19
20
  } from "./response-adapter.js";
20
21
  import {
21
22
  buildPrefetchKey,
23
+ buildSourceKey,
22
24
  consumeInflightPrefetch,
23
25
  consumePrefetch,
24
26
  } from "./prefetch/cache.js";
@@ -30,8 +32,10 @@ import {
30
32
  * deserializing the response using the RSC runtime.
31
33
  *
32
34
  * Checks the in-memory prefetch cache before making a network request.
33
- * The cache key is source-dependent (includes the previous URL) so
34
- * prefetch responses match the exact diff the server would produce.
35
+ * Tries the source-scoped key first (populated when the server tagged
36
+ * the response as source-sensitive via `X-RSC-Prefetch-Scope: source`)
37
+ * and falls back to the Rango-state-keyed wildcard slot used for the
38
+ * common source-agnostic case.
35
39
  *
36
40
  * @param deps - RSC browser dependencies (createFromFetch)
37
41
  * @returns NavigationClient instance
@@ -93,17 +97,19 @@ export function createNavigationClient(
93
97
  fetchUrl.searchParams.set("_rsc_rid", routerId);
94
98
  }
95
99
 
96
- // Check completed in-memory prefetch cache before making a network request.
97
- // The cache key includes the source URL (previousUrl) because the
98
- // server's diff response depends on the source page context.
100
+ // Check completed in-memory prefetch cache before making a network
101
+ // request. Try the source-scoped key first (populated when the server
102
+ // tagged the prefetch response as source-sensitive, e.g. intercepts,
103
+ // or when a Link opted in with `prefetchKey=":source"`), then fall
104
+ // back to the wildcard slot shared across source pages.
105
+ // Both keys embed the Rango state, so state rotation (deploy or
106
+ // server-action invalidation) auto-invalidates both scopes.
99
107
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
100
108
  // fresh modules), and intercept contexts (source-dependent responses).
101
- //
102
109
  const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
103
- const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
104
- // Wildcard key matches prefetch entries stored with a custom prefetchKey
105
- // (Link's prefetchKey prop stores under "*" instead of the source URL).
106
- const wildcardKey = "*\0" + fetchUrl.pathname + fetchUrl.search;
110
+ const rangoState = getRangoState();
111
+ const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
112
+ const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
107
113
 
108
114
  let cachedResponse: Response | null = null;
109
115
  let hitKey: string | null = null;
@@ -143,21 +149,17 @@ export function createNavigationClient(
143
149
  source: string,
144
150
  ): Response | Promise<Response> => {
145
151
  // Version mismatch — server wants a full page reload
146
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
147
- if (reload === "blocked") {
148
- resolveStreamComplete();
149
- return emptyResponse();
150
- }
151
- if (reload) {
152
- if (tx) {
153
- browserDebugLog(tx, `version mismatch, reloading (${source})`, {
154
- reloadUrl: reload.url,
155
- });
156
- }
157
- window.location.href = reload.url;
158
- // Block further processing — page is reloading
159
- return new Promise<Response>(() => {});
160
- }
152
+ const reloadResult = handleReloadHeader(response, {
153
+ onBlocked: resolveStreamComplete,
154
+ onReload: (url) => {
155
+ if (tx) {
156
+ browserDebugLog(tx, `version mismatch, reloading (${source})`, {
157
+ reloadUrl: url,
158
+ });
159
+ }
160
+ },
161
+ });
162
+ if (reloadResult) return reloadResult;
161
163
 
162
164
  // Server-side redirect without state: the server returned 204 with
163
165
  // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
@@ -244,6 +246,7 @@ export function createNavigationClient(
244
246
  wildcard: hitKey === wildcardKey,
245
247
  });
246
248
  }
249
+ const adoptedViaWildcard = hitKey === wildcardKey;
247
250
  responsePromise = inflightResponsePromise.then(async (response) => {
248
251
  if (!response) {
249
252
  if (tx) {
@@ -252,6 +255,23 @@ export function createNavigationClient(
252
255
  return doFreshFetch();
253
256
  }
254
257
 
258
+ // Cross-source safety: an inflight promise adopted via the
259
+ // wildcard key may turn out to be source-scoped (server emitted
260
+ // `X-RSC-Prefetch-Scope: source`), which means it was built for
261
+ // a different source page. Discard and refetch.
262
+ if (
263
+ adoptedViaWildcard &&
264
+ response.headers.get("x-rsc-prefetch-scope") === "source"
265
+ ) {
266
+ if (tx) {
267
+ browserDebugLog(
268
+ tx,
269
+ "wildcard inflight turned out source-scoped, refetching",
270
+ );
271
+ }
272
+ return doFreshFetch();
273
+ }
274
+
255
275
  const validated = validateRscHeaders(response, "inflight prefetch");
256
276
  if (validated instanceof Promise) return validated;
257
277
 
@@ -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)
@@ -280,18 +283,17 @@ export function createNavigationStore(
280
283
  /**
281
284
  * Create a debounced function that batches rapid calls
282
285
  */
286
+ // A non-keyed notifier is the keyed one restricted to a single constant key;
287
+ // its own keyed instance means the "" key never collides with action keys.
283
288
  function createDebouncedNotifier<T extends (...args: any[]) => void>(
284
289
  fn: T,
285
290
  ms: number = 20,
286
291
  ): T {
287
- let timeout: ReturnType<typeof setTimeout> | null = null;
288
- return ((...args: Parameters<T>) => {
289
- if (timeout !== null) clearTimeout(timeout);
290
- timeout = setTimeout(() => {
291
- timeout = null;
292
- fn(...args);
293
- }, ms);
294
- }) as T;
292
+ const keyed = createKeyedDebouncedNotifier(
293
+ (_key: string, ...args: any[]) => fn(...args),
294
+ ms,
295
+ );
296
+ return ((...args: Parameters<T>) => keyed("", ...args)) as T;
295
297
  }
296
298
 
297
299
  /**
@@ -335,6 +337,18 @@ export function createNavigationStore(
335
337
  clearPrefetchCache();
336
338
  }
337
339
 
340
+ /**
341
+ * Drop this tab's navigation + prefetch caches without broadcasting or
342
+ * rotating shared state. Used when the local session changes in a way that
343
+ * doesn't affect other tabs — e.g. this tab crosses into a different app
344
+ * via a cross-router navigation. Other tabs in the old app keep their
345
+ * caches and their X-Rango-State token.
346
+ */
347
+ function clearCacheInternalLocal(): void {
348
+ historyCache.length = 0;
349
+ clearPrefetchCacheLocal();
350
+ }
351
+
338
352
  /**
339
353
  * Mark all cache entries as stale (internal - does not broadcast)
340
354
  */
@@ -668,6 +682,15 @@ export function createNavigationStore(
668
682
  clearCacheAndBroadcast();
669
683
  },
670
684
 
685
+ /**
686
+ * Drop this tab's navigation + prefetch caches locally without
687
+ * broadcasting or rotating shared state. Intended for cross-app
688
+ * transitions where the session state diverges for this tab only.
689
+ */
690
+ clearHistoryCacheLocal(): void {
691
+ clearCacheInternalLocal();
692
+ },
693
+
671
694
  /**
672
695
  * Mark cache as stale and broadcast to other tabs
673
696
  * Called after server actions - allows SWR pattern for popstate