@rangojs/router 0.0.0-experimental.77 → 0.0.0-experimental.77ed8945

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 (239) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/vite/index.js +2103 -861
  4. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  5. package/package.json +13 -8
  6. package/skills/api-client/SKILL.md +211 -0
  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/css/SKILL.md +76 -0
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +3 -1
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +66 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +26 -4
  19. package/skills/layout/SKILL.md +6 -7
  20. package/skills/links/SKILL.md +247 -17
  21. package/skills/loader/SKILL.md +219 -9
  22. package/skills/middleware/SKILL.md +47 -12
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +27 -0
  26. package/skills/observability/SKILL.md +137 -0
  27. package/skills/parallel/SKILL.md +12 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +238 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +122 -47
  32. package/skills/route/SKILL.md +33 -4
  33. package/skills/router-setup/SKILL.md +3 -3
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/tailwind/SKILL.md +27 -3
  37. package/skills/typesafety/SKILL.md +319 -27
  38. package/skills/use-cache/SKILL.md +34 -5
  39. package/skills/view-transitions/SKILL.md +294 -0
  40. package/src/__augment-tests__/augment.ts +81 -0
  41. package/src/__augment-tests__/augmented.check.ts +116 -0
  42. package/src/browser/action-coordinator.ts +53 -36
  43. package/src/browser/app-shell.ts +39 -0
  44. package/src/browser/event-controller.ts +86 -70
  45. package/src/browser/history-state.ts +21 -0
  46. package/src/browser/index.ts +3 -3
  47. package/src/browser/navigation-bridge.ts +29 -9
  48. package/src/browser/navigation-client.ts +99 -77
  49. package/src/browser/navigation-store.ts +7 -8
  50. package/src/browser/navigation-transaction.ts +10 -28
  51. package/src/browser/partial-update.ts +60 -40
  52. package/src/browser/prefetch/cache.ts +196 -49
  53. package/src/browser/prefetch/fetch.ts +203 -59
  54. package/src/browser/prefetch/queue.ts +36 -5
  55. package/src/browser/rango-state.ts +37 -13
  56. package/src/browser/react/Link.tsx +18 -13
  57. package/src/browser/react/NavigationProvider.tsx +75 -31
  58. package/src/browser/react/filter-segment-order.ts +51 -7
  59. package/src/browser/react/index.ts +3 -0
  60. package/src/browser/react/location-state-shared.ts +175 -4
  61. package/src/browser/react/location-state.ts +39 -13
  62. package/src/browser/react/use-handle.ts +17 -9
  63. package/src/browser/react/use-navigation.ts +22 -2
  64. package/src/browser/react/use-params.ts +20 -8
  65. package/src/browser/react/use-reverse.ts +106 -0
  66. package/src/browser/react/use-router.ts +23 -2
  67. package/src/browser/react/use-segments.ts +11 -8
  68. package/src/browser/response-adapter.ts +52 -1
  69. package/src/browser/rsc-router.tsx +71 -22
  70. package/src/browser/scroll-restoration.ts +22 -14
  71. package/src/browser/segment-reconciler.ts +10 -14
  72. package/src/browser/segment-structure-assert.ts +2 -2
  73. package/src/browser/server-action-bridge.ts +44 -30
  74. package/src/browser/types.ts +12 -2
  75. package/src/build/collect-fallback-refs.ts +107 -0
  76. package/src/build/generate-manifest.ts +60 -35
  77. package/src/build/generate-route-types.ts +2 -0
  78. package/src/build/index.ts +8 -1
  79. package/src/build/prefix-tree-utils.ts +123 -0
  80. package/src/build/route-trie.ts +45 -1
  81. package/src/build/route-types/codegen.ts +4 -4
  82. package/src/build/route-types/include-resolution.ts +1 -1
  83. package/src/build/route-types/per-module-writer.ts +7 -4
  84. package/src/build/route-types/router-processing.ts +55 -14
  85. package/src/build/route-types/scan-filter.ts +1 -1
  86. package/src/build/route-types/source-scan.ts +118 -0
  87. package/src/build/runtime-discovery.ts +9 -20
  88. package/src/cache/cache-runtime.ts +17 -5
  89. package/src/cache/cache-scope.ts +51 -49
  90. package/src/cache/cf/cf-cache-store.ts +502 -32
  91. package/src/cache/cf/index.ts +3 -0
  92. package/src/cache/handle-snapshot.ts +103 -0
  93. package/src/cache/index.ts +3 -0
  94. package/src/cache/memory-segment-store.ts +3 -2
  95. package/src/cache/types.ts +10 -6
  96. package/src/client.rsc.tsx +3 -0
  97. package/src/client.tsx +96 -205
  98. package/src/context-var.ts +5 -5
  99. package/src/decode-loader-results.ts +36 -0
  100. package/src/errors.ts +30 -4
  101. package/src/handle.ts +4 -6
  102. package/src/host/index.ts +2 -2
  103. package/src/host/router.ts +129 -57
  104. package/src/host/types.ts +31 -2
  105. package/src/host/utils.ts +1 -1
  106. package/src/href-client.ts +140 -21
  107. package/src/index.rsc.ts +10 -6
  108. package/src/index.ts +17 -8
  109. package/src/loader-store.ts +500 -0
  110. package/src/loader.rsc.ts +2 -5
  111. package/src/loader.ts +3 -10
  112. package/src/missing-id-error.ts +68 -0
  113. package/src/outlet-context.ts +1 -1
  114. package/src/prerender/store.ts +9 -7
  115. package/src/prerender.ts +4 -4
  116. package/src/response-utils.ts +37 -0
  117. package/src/reverse.ts +65 -39
  118. package/src/route-content-wrapper.tsx +6 -28
  119. package/src/route-definition/dsl-helpers.ts +253 -265
  120. package/src/route-definition/helper-factories.ts +29 -139
  121. package/src/route-definition/helpers-types.ts +43 -15
  122. package/src/route-definition/resolve-handler-use.ts +6 -0
  123. package/src/route-definition/use-item-types.ts +32 -0
  124. package/src/route-types.ts +26 -41
  125. package/src/router/content-negotiation.ts +15 -2
  126. package/src/router/error-handling.ts +1 -1
  127. package/src/router/find-match.ts +54 -6
  128. package/src/router/handler-context.ts +21 -41
  129. package/src/router/intercept-resolution.ts +4 -18
  130. package/src/router/lazy-includes.ts +41 -22
  131. package/src/router/loader-resolution.ts +82 -36
  132. package/src/router/manifest.ts +41 -19
  133. package/src/router/match-api.ts +4 -3
  134. package/src/router/match-handlers.ts +1 -0
  135. package/src/router/match-middleware/cache-lookup.ts +57 -95
  136. package/src/router/match-middleware/cache-store.ts +3 -2
  137. package/src/router/match-result.ts +53 -32
  138. package/src/router/metrics.ts +1 -1
  139. package/src/router/middleware-types.ts +15 -26
  140. package/src/router/middleware.ts +99 -84
  141. package/src/router/pattern-matching.ts +116 -19
  142. package/src/router/prerender-match.ts +40 -15
  143. package/src/router/preview-match.ts +3 -1
  144. package/src/router/request-classification.ts +40 -37
  145. package/src/router/revalidation.ts +58 -2
  146. package/src/router/router-interfaces.ts +51 -35
  147. package/src/router/router-options.ts +25 -1
  148. package/src/router/router-registry.ts +2 -5
  149. package/src/router/segment-resolution/fresh.ts +27 -6
  150. package/src/router/segment-resolution/revalidation.ts +147 -106
  151. package/src/router/segment-resolution/static-store.ts +19 -5
  152. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  153. package/src/router/substitute-pattern-params.ts +56 -0
  154. package/src/router/trie-matching.ts +40 -16
  155. package/src/router/types.ts +8 -0
  156. package/src/router/url-params.ts +49 -0
  157. package/src/router.ts +37 -25
  158. package/src/rsc/handler-context.ts +2 -2
  159. package/src/rsc/handler.ts +58 -77
  160. package/src/rsc/helpers.ts +72 -43
  161. package/src/rsc/index.ts +1 -1
  162. package/src/rsc/manifest-init.ts +28 -41
  163. package/src/rsc/origin-guard.ts +30 -10
  164. package/src/rsc/progressive-enhancement.ts +4 -0
  165. package/src/rsc/response-error.ts +79 -12
  166. package/src/rsc/response-route-handler.ts +76 -61
  167. package/src/rsc/rsc-rendering.ts +45 -51
  168. package/src/rsc/runtime-warnings.ts +9 -10
  169. package/src/rsc/server-action.ts +33 -39
  170. package/src/rsc/ssr-setup.ts +16 -0
  171. package/src/rsc/types.ts +8 -2
  172. package/src/search-params.ts +4 -4
  173. package/src/segment-content-promise.ts +67 -0
  174. package/src/segment-loader-promise.ts +122 -0
  175. package/src/segment-system.tsx +132 -116
  176. package/src/serialize.ts +243 -0
  177. package/src/server/context.ts +175 -53
  178. package/src/server/cookie-store.ts +28 -4
  179. package/src/server/request-context.ts +57 -51
  180. package/src/ssr/index.tsx +5 -1
  181. package/src/static-handler.ts +1 -1
  182. package/src/types/global-namespace.ts +39 -26
  183. package/src/types/handler-context.ts +68 -50
  184. package/src/types/index.ts +1 -0
  185. package/src/types/loader-types.ts +11 -9
  186. package/src/types/request-scope.ts +126 -0
  187. package/src/types/route-entry.ts +11 -0
  188. package/src/types/segments.ts +35 -2
  189. package/src/urls/include-helper.ts +34 -67
  190. package/src/urls/index.ts +1 -5
  191. package/src/urls/path-helper-types.ts +17 -3
  192. package/src/urls/path-helper.ts +17 -52
  193. package/src/urls/pattern-types.ts +36 -19
  194. package/src/urls/response-types.ts +22 -29
  195. package/src/urls/type-extraction.ts +58 -139
  196. package/src/urls/urls-function.ts +1 -5
  197. package/src/use-loader.tsx +413 -42
  198. package/src/vite/debug.ts +185 -0
  199. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  200. package/src/vite/discovery/discover-routers.ts +106 -75
  201. package/src/vite/discovery/discovery-errors.ts +194 -0
  202. package/src/vite/discovery/gate-state.ts +171 -0
  203. package/src/vite/discovery/prerender-collection.ts +72 -31
  204. package/src/vite/discovery/route-types-writer.ts +40 -84
  205. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  206. package/src/vite/discovery/state.ts +33 -0
  207. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  208. package/src/vite/index.ts +2 -0
  209. package/src/vite/plugin-types.ts +67 -0
  210. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  211. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  212. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  213. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  214. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  215. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  216. package/src/vite/plugins/expose-action-id.ts +54 -30
  217. package/src/vite/plugins/expose-id-utils.ts +12 -8
  218. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  219. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  220. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  221. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  222. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  223. package/src/vite/plugins/performance-tracks.ts +29 -25
  224. package/src/vite/plugins/use-cache-transform.ts +65 -50
  225. package/src/vite/plugins/version-injector.ts +39 -23
  226. package/src/vite/plugins/version-plugin.ts +59 -2
  227. package/src/vite/plugins/virtual-entries.ts +2 -2
  228. package/src/vite/rango.ts +116 -29
  229. package/src/vite/router-discovery.ts +753 -104
  230. package/src/vite/utils/ast-handler-extract.ts +15 -15
  231. package/src/vite/utils/banner.ts +1 -1
  232. package/src/vite/utils/bundle-analysis.ts +4 -2
  233. package/src/vite/utils/client-chunks.ts +190 -0
  234. package/src/vite/utils/forward-user-plugins.ts +193 -0
  235. package/src/vite/utils/manifest-utils.ts +8 -59
  236. package/src/vite/utils/package-resolution.ts +41 -1
  237. package/src/vite/utils/prerender-utils.ts +5 -4
  238. package/src/vite/utils/shared-utils.ts +107 -26
  239. package/src/browser/action-response-classifier.ts +0 -99
@@ -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";
@@ -11,7 +11,7 @@ import {
11
11
  createNavigationTransaction,
12
12
  resolveNavigationState,
13
13
  } from "./navigation-transaction.js";
14
- import { buildHistoryState } from "./history-state.js";
14
+ import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
15
15
  import {
16
16
  handleNavigationStart,
17
17
  handleNavigationEnd,
@@ -48,7 +48,7 @@ export { createNavigationTransaction };
48
48
  */
49
49
  export interface NavigationBridgeConfigWithController extends NavigationBridgeConfig {
50
50
  eventController: EventController;
51
- /** RSC version from initial payload metadata */
51
+ /** RSC version from initial payload metadata. */
52
52
  version?: string;
53
53
  }
54
54
 
@@ -159,11 +159,7 @@ export function createNavigationBridge(
159
159
  },
160
160
  {},
161
161
  );
162
- if (options.replace) {
163
- window.history.replaceState(historyState, "", url);
164
- } else {
165
- window.history.pushState(historyState, "", url);
166
- }
162
+ pushHistoryWithIdx(historyState, url, options?.replace ?? false);
167
163
 
168
164
  // Ensure new history entry has a scroll restoration key
169
165
  ensureHistoryKey();
@@ -418,6 +414,15 @@ export function createNavigationBridge(
418
414
  eventController.abortAllActions();
419
415
  }
420
416
 
417
+ // Popstate that exits an intercept to a non-intercept destination. The
418
+ // fallback fetch path below needs `leave-intercept` mode so it filters
419
+ // the cached @modal segment from the request and forces a re-render —
420
+ // otherwise a cache-miss popstate whose server response has an empty
421
+ // diff hits the "no changes" branch in partial-update and the modal
422
+ // stays on screen.
423
+ const isLeavingIntercept =
424
+ !isIntercept && currentInterceptSource !== null;
425
+
421
426
  // Compute history key from URL (with intercept suffix if applicable)
422
427
  const historyKey = generateHistoryKey(url, { intercept: isIntercept });
423
428
 
@@ -487,7 +492,14 @@ export function createNavigationBridge(
487
492
  },
488
493
  scroll: { restore: true, isStreaming },
489
494
  };
490
- const hasTransition = cachedSegments.some((s) => s.transition);
495
+ // Intercept-driven popstate (entering OR leaving an intercept) only
496
+ // mutates the parallel slot; the main outlet shows the same content.
497
+ // Skip startViewTransition in those cases — same rationale as the
498
+ // intercept guard in partial-update.ts's hasTransition computation.
499
+ const hasTransition =
500
+ !isIntercept &&
501
+ !isLeavingIntercept &&
502
+ cachedSegments.some((s) => s.transition);
491
503
  if (hasTransition) {
492
504
  startTransition(() => {
493
505
  if (addTransitionType) {
@@ -568,7 +580,11 @@ export function createNavigationBridge(
568
580
  intercept: isIntercept,
569
581
  interceptSourceUrl,
570
582
  }),
571
- isIntercept ? { type: "navigate", interceptSourceUrl } : undefined,
583
+ isIntercept
584
+ ? { type: "navigate", interceptSourceUrl }
585
+ : isLeavingIntercept
586
+ ? { type: "leave-intercept" }
587
+ : undefined,
572
588
  );
573
589
  // Restore scroll position after fetch completes
574
590
  handleNavigationEnd({ restore: true, isStreaming });
@@ -646,6 +662,10 @@ export function createNavigationBridge(
646
662
  };
647
663
  },
648
664
 
665
+ getVersion(): string | undefined {
666
+ return version;
667
+ },
668
+
649
669
  updateVersion(newVersion: string): void {
650
670
  version = newVersion;
651
671
  setAppVersion(newVersion);
@@ -15,12 +15,16 @@ import { getRangoState } from "./rango-state.js";
15
15
  import {
16
16
  extractRscHeaderUrl,
17
17
  emptyResponse,
18
+ handleReloadHeader,
18
19
  teeWithCompletion,
20
+ isForeignRouterId,
19
21
  } from "./response-adapter.js";
20
22
  import {
21
23
  buildPrefetchKey,
24
+ buildSourceKey,
22
25
  consumeInflightPrefetch,
23
26
  consumePrefetch,
27
+ type DecodedPrefetch,
24
28
  } from "./prefetch/cache.js";
25
29
 
26
30
  /**
@@ -30,8 +34,10 @@ import {
30
34
  * deserializing the response using the RSC runtime.
31
35
  *
32
36
  * 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.
37
+ * Tries the source-scoped key first (populated when the server tagged
38
+ * the response as source-sensitive via `X-RSC-Prefetch-Scope: source`)
39
+ * and falls back to the Rango-state-keyed wildcard slot used for the
40
+ * common source-agnostic case.
35
41
  *
36
42
  * @param deps - RSC browser dependencies (createFromFetch)
37
43
  * @returns NavigationClient instance
@@ -93,38 +99,40 @@ export function createNavigationClient(
93
99
  fetchUrl.searchParams.set("_rsc_rid", routerId);
94
100
  }
95
101
 
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.
102
+ // Check completed in-memory prefetch cache before making a network
103
+ // request. Try the source-scoped key first (populated when the server
104
+ // tagged the prefetch response as source-sensitive, e.g. intercepts,
105
+ // or when a Link opted in with `prefetchKey=":source"`), then fall
106
+ // back to the wildcard slot shared across source pages.
107
+ // Both keys embed the Rango state, so state rotation (deploy or
108
+ // server-action invalidation) auto-invalidates both scopes.
99
109
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
100
110
  // fresh modules), and intercept contexts (source-dependent responses).
101
- //
102
111
  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;
112
+ const rangoState = getRangoState();
113
+ const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
114
+ const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
107
115
 
108
- let cachedResponse: Response | null = null;
116
+ let cachedEntry: DecodedPrefetch | null = null;
109
117
  let hitKey: string | null = null;
110
118
  if (canUsePrefetch) {
111
- cachedResponse = consumePrefetch(cacheKey);
112
- if (cachedResponse) {
119
+ cachedEntry = consumePrefetch(cacheKey);
120
+ if (cachedEntry) {
113
121
  hitKey = cacheKey;
114
122
  } else {
115
- cachedResponse = consumePrefetch(wildcardKey);
116
- if (cachedResponse) hitKey = wildcardKey;
123
+ cachedEntry = consumePrefetch(wildcardKey);
124
+ if (cachedEntry) hitKey = wildcardKey;
117
125
  }
118
126
  }
119
127
 
120
- let inflightResponsePromise: Promise<Response | null> | null = null;
121
- if (canUsePrefetch && !cachedResponse) {
122
- inflightResponsePromise = consumeInflightPrefetch(cacheKey);
123
- if (inflightResponsePromise) {
128
+ let inflightEntryPromise: Promise<DecodedPrefetch | null> | null = null;
129
+ if (canUsePrefetch && !cachedEntry) {
130
+ inflightEntryPromise = consumeInflightPrefetch(cacheKey);
131
+ if (inflightEntryPromise) {
124
132
  hitKey = cacheKey;
125
133
  } else {
126
- inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
127
- if (inflightResponsePromise) hitKey = wildcardKey;
134
+ inflightEntryPromise = consumeInflightPrefetch(wildcardKey);
135
+ if (inflightEntryPromise) hitKey = wildcardKey;
128
136
  }
129
137
  }
130
138
  // Track when the stream completes
@@ -143,21 +151,17 @@ export function createNavigationClient(
143
151
  source: string,
144
152
  ): Response | Promise<Response> => {
145
153
  // 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
- }
154
+ const reloadResult = handleReloadHeader(response, {
155
+ onBlocked: resolveStreamComplete,
156
+ onReload: (url) => {
157
+ if (tx) {
158
+ browserDebugLog(tx, `version mismatch, reloading (${source})`, {
159
+ reloadUrl: url,
160
+ });
161
+ }
162
+ },
163
+ });
164
+ if (reloadResult) return reloadResult;
161
165
 
162
166
  // Server-side redirect without state: the server returned 204 with
163
167
  // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
@@ -178,6 +182,19 @@ export function createNavigationClient(
178
182
  throw new ServerRedirect(redirect.url, undefined);
179
183
  }
180
184
 
185
+ // Integrity check (pre-decode): refuse a foreign app's content response
186
+ // before createFromFetch imports its chunks. Ordered AFTER the reload
187
+ // and redirect handlers — control responses are never stamped with
188
+ // X-RSC-Router-Id, so they are steered first and never reach here.
189
+ if (isForeignRouterId(response, routerId)) {
190
+ if (tx) {
191
+ browserDebugLog(tx, `router id mismatch, reloading (${source})`);
192
+ }
193
+ resolveStreamComplete();
194
+ window.location.href = targetUrl;
195
+ return new Promise<Response>(() => {});
196
+ }
197
+
181
198
  return response;
182
199
  };
183
200
 
@@ -215,63 +232,68 @@ export function createNavigationClient(
215
232
  });
216
233
  };
217
234
 
218
- let responsePromise: Promise<Response>;
235
+ // A warm prefetch hit returns its eagerly-decoded payload directly: the
236
+ // route's chunks were imported during the prefetch, so this click runs
237
+ // no decode and no network. Only the fresh path runs createFromFetch and
238
+ // resolves the local streamComplete (via doFreshFetch's teeWithCompletion
239
+ // and the control-header short-circuits in validateRscHeaders).
240
+ const freshResult = (): {
241
+ payload: Promise<RscPayload>;
242
+ streamComplete: Promise<void>;
243
+ } => ({
244
+ payload: deps.createFromFetch<RscPayload>(doFreshFetch()),
245
+ streamComplete,
246
+ });
247
+
248
+ let payloadPromise: Promise<RscPayload>;
249
+ let streamCompletePromise: Promise<void>;
219
250
 
220
- if (cachedResponse) {
251
+ if (cachedEntry) {
221
252
  if (tx) {
222
- browserDebugLog(tx, "prefetch cache hit", {
253
+ browserDebugLog(tx, "prefetch cache hit (warm)", {
223
254
  key: hitKey,
224
255
  wildcard: hitKey === wildcardKey,
225
256
  });
226
257
  }
227
- responsePromise = Promise.resolve(cachedResponse).then((response) => {
228
- const validated = validateRscHeaders(response, "prefetch cache");
229
- if (validated instanceof Promise) return validated;
230
-
231
- return teeWithCompletion(
232
- validated,
233
- () => {
234
- if (tx) browserDebugLog(tx, "stream complete (from cache)");
235
- resolveStreamComplete();
236
- },
237
- signal,
238
- );
239
- });
240
- } else if (inflightResponsePromise) {
258
+ payloadPromise = cachedEntry.payload;
259
+ streamCompletePromise = cachedEntry.streamComplete;
260
+ } else if (inflightEntryPromise) {
241
261
  if (tx) {
242
262
  browserDebugLog(tx, "reusing inflight prefetch", {
243
263
  key: hitKey,
244
264
  wildcard: hitKey === wildcardKey,
245
265
  });
246
266
  }
247
- responsePromise = inflightResponsePromise.then(async (response) => {
248
- if (!response) {
249
- if (tx) {
250
- browserDebugLog(tx, "inflight prefetch unavailable, refetching");
251
- }
252
- return doFreshFetch();
267
+ const adoptedViaWildcard = hitKey === wildcardKey;
268
+ const entry = await inflightEntryPromise;
269
+ if (!entry) {
270
+ if (tx) {
271
+ browserDebugLog(tx, "inflight prefetch unavailable, refetching");
253
272
  }
254
-
255
- const validated = validateRscHeaders(response, "inflight prefetch");
256
- if (validated instanceof Promise) return validated;
257
-
258
- return teeWithCompletion(
259
- validated,
260
- () => {
261
- if (tx) {
262
- browserDebugLog(tx, "stream complete (from inflight prefetch)");
263
- }
264
- resolveStreamComplete();
265
- },
266
- signal,
267
- );
268
- });
273
+ ({ payload: payloadPromise, streamComplete: streamCompletePromise } =
274
+ freshResult());
275
+ } else if (adoptedViaWildcard && entry.scope === "source") {
276
+ // A wildcard-adopted inflight that turned out source-scoped was
277
+ // built for a different source page. Discard and refetch.
278
+ if (tx) {
279
+ browserDebugLog(
280
+ tx,
281
+ "wildcard inflight turned out source-scoped, refetching",
282
+ );
283
+ }
284
+ ({ payload: payloadPromise, streamComplete: streamCompletePromise } =
285
+ freshResult());
286
+ } else {
287
+ payloadPromise = entry.payload;
288
+ streamCompletePromise = entry.streamComplete;
289
+ }
269
290
  } else {
270
- responsePromise = doFreshFetch();
291
+ ({ payload: payloadPromise, streamComplete: streamCompletePromise } =
292
+ freshResult());
271
293
  }
272
294
 
273
295
  try {
274
- const payload = await deps.createFromFetch<RscPayload>(responsePromise);
296
+ const payload = await payloadPromise;
275
297
 
276
298
  if (tx) {
277
299
  browserDebugLog(tx, "response received", {
@@ -280,7 +302,7 @@ export function createNavigationClient(
280
302
  diffCount: payload.metadata?.diff?.length ?? 0,
281
303
  });
282
304
  }
283
- return { payload, streamComplete };
305
+ return { payload, streamComplete: streamCompletePromise };
284
306
  } catch (error) {
285
307
  // Convert network-level errors to NetworkError for proper handling
286
308
  if (isNetworkError(error)) {
@@ -280,18 +280,17 @@ export function createNavigationStore(
280
280
  /**
281
281
  * Create a debounced function that batches rapid calls
282
282
  */
283
+ // A non-keyed notifier is the keyed one restricted to a single constant key;
284
+ // its own keyed instance means the "" key never collides with action keys.
283
285
  function createDebouncedNotifier<T extends (...args: any[]) => void>(
284
286
  fn: T,
285
287
  ms: number = 20,
286
288
  ): 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;
289
+ const keyed = createKeyedDebouncedNotifier(
290
+ (_key: string, ...args: any[]) => fn(...args),
291
+ ms,
292
+ );
293
+ return ((...args: Parameters<T>) => keyed("", ...args)) as T;
295
294
  }
296
295
 
297
296
  /**