@rangojs/router 0.17.0 → 0.19.0

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 (293) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/event-controller.d.ts +3 -3
  4. package/dist/types/browser/history-state.d.ts +18 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  6. package/dist/types/browser/partial-update.d.ts +16 -1
  7. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  8. package/dist/types/browser/react/Link.d.ts +19 -9
  9. package/dist/types/browser/react/context.d.ts +2 -2
  10. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  11. package/dist/types/browser/react/location-state.d.ts +1 -1
  12. package/dist/types/browser/transition-when.d.ts +80 -0
  13. package/dist/types/browser/types.d.ts +104 -9
  14. package/dist/types/build/generate-manifest.d.ts +8 -0
  15. package/dist/types/cache/background-task.d.ts +24 -2
  16. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +48 -2
  18. package/dist/types/cache/cache-policy.d.ts +18 -1
  19. package/dist/types/cache/cache-runtime.d.ts +7 -0
  20. package/dist/types/cache/cache-scope.d.ts +192 -26
  21. package/dist/types/cache/cache-tag.d.ts +116 -11
  22. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  23. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  24. package/dist/types/cache/cf/cf-cache-store.d.ts +224 -72
  25. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  26. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  27. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +19 -0
  28. package/dist/types/cache/document-cache.d.ts +10 -0
  29. package/dist/types/cache/handle-snapshot.d.ts +41 -8
  30. package/dist/types/cache/index.d.ts +1 -0
  31. package/dist/types/cache/invalidation-order.d.ts +23 -0
  32. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  33. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  34. package/dist/types/cache/shell-memo.d.ts +178 -0
  35. package/dist/types/cache/shell-snapshot.d.ts +141 -105
  36. package/dist/types/cache/tag-invalidation.d.ts +54 -3
  37. package/dist/types/cache/types.d.ts +267 -96
  38. package/dist/types/cache/vercel/vercel-cache-store.d.ts +123 -18
  39. package/dist/types/client-urls/navigation.d.ts +28 -2
  40. package/dist/types/client-urls/server-projection.d.ts +5 -2
  41. package/dist/types/client-urls/types.d.ts +5 -5
  42. package/dist/types/client.d.ts +1 -1
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +1 -1
  45. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  46. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  47. package/dist/types/route-name.d.ts +7 -0
  48. package/dist/types/router/error-handling.d.ts +1 -1
  49. package/dist/types/router/instrument.d.ts +8 -0
  50. package/dist/types/router/intercept-resolution.d.ts +2 -0
  51. package/dist/types/router/match-context.d.ts +5 -0
  52. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  53. package/dist/types/router/middleware-types.d.ts +6 -1
  54. package/dist/types/router/router-options.d.ts +4 -1
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +58 -12
  56. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  57. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +33 -48
  58. package/dist/types/router/segment-resolution/mask-nested.d.ts +73 -24
  59. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  60. package/dist/types/router/transition-when.d.ts +24 -10
  61. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  62. package/dist/types/rsc/full-payload.d.ts +21 -0
  63. package/dist/types/rsc/handler.d.ts +1 -1
  64. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  65. package/dist/types/rsc/shell-build-manifest.d.ts +12 -3
  66. package/dist/types/rsc/shell-capture-constants.d.ts +116 -12
  67. package/dist/types/rsc/shell-capture.d.ts +202 -74
  68. package/dist/types/rsc/shell-serve.d.ts +144 -50
  69. package/dist/types/rsc/types.d.ts +9 -0
  70. package/dist/types/segment-system.d.ts +7 -0
  71. package/dist/types/server/context.d.ts +155 -33
  72. package/dist/types/server/cookie-store.d.ts +33 -0
  73. package/dist/types/server/handle-store.d.ts +52 -1
  74. package/dist/types/server/request-context.d.ts +210 -63
  75. package/dist/types/server/request-headers.d.ts +44 -0
  76. package/dist/types/server.d.ts +1 -0
  77. package/dist/types/ssr/index.d.ts +9 -0
  78. package/dist/types/ssr/ssr-root.d.ts +9 -8
  79. package/dist/types/testing/e2e/index.d.ts +1 -1
  80. package/dist/types/testing/flight.entry.d.ts +7 -0
  81. package/dist/types/testing/index.d.ts +3 -2
  82. package/dist/types/testing/internal/action-id.d.ts +7 -0
  83. package/dist/types/testing/location-state-key.d.ts +24 -0
  84. package/dist/types/testing/render-route.d.ts +17 -1
  85. package/dist/types/testing/run-loader.d.ts +18 -3
  86. package/dist/types/testing/run-transition-when.d.ts +44 -69
  87. package/dist/types/testing/serve-shell-request.d.ts +133 -0
  88. package/dist/types/testing/shell-status.d.ts +44 -19
  89. package/dist/types/transition-when-ref.d.ts +79 -0
  90. package/dist/types/types/cache-types.d.ts +15 -3
  91. package/dist/types/types/handler-context.d.ts +14 -1
  92. package/dist/types/types/index.d.ts +1 -1
  93. package/dist/types/types/segments.d.ts +86 -71
  94. package/dist/types/urls/path-helper-types.d.ts +3 -4
  95. package/dist/types/urls/pattern-types.d.ts +21 -18
  96. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  97. package/dist/types/vite/router-discovery.d.ts +29 -2
  98. package/dist/vite/index.js +1005 -105
  99. package/package.json +1 -1
  100. package/skills/breadcrumbs/SKILL.md +17 -7
  101. package/skills/cache-guide/SKILL.md +175 -76
  102. package/skills/caching/SKILL.md +361 -33
  103. package/skills/catalog.json +1 -1
  104. package/skills/client-urls/SKILL.md +12 -7
  105. package/skills/cloudflare/SKILL.md +15 -0
  106. package/skills/comparison/references/framework-comparison.md +52 -37
  107. package/skills/defer-hydration/SKILL.md +50 -30
  108. package/skills/deployment-caching/SKILL.md +5 -2
  109. package/skills/document-cache/SKILL.md +32 -2
  110. package/skills/handler-use/SKILL.md +2 -0
  111. package/skills/hooks/SKILL.md +3 -1
  112. package/skills/hooks/handle-and-actions.md +3 -1
  113. package/skills/hooks/navigation.md +23 -2
  114. package/skills/hooks/state.md +101 -7
  115. package/skills/intercept/SKILL.md +29 -12
  116. package/skills/loader/SKILL.md +197 -44
  117. package/skills/middleware/SKILL.md +34 -9
  118. package/skills/migrate-nextjs/SKILL.md +68 -37
  119. package/skills/migrate-react-router/SKILL.md +2 -1
  120. package/skills/migrate-react-router/component-migration.md +3 -1
  121. package/skills/migrate-react-router/data-and-actions.md +3 -1
  122. package/skills/migrate-react-router/route-mapping.md +7 -4
  123. package/skills/observability/SKILL.md +65 -0
  124. package/skills/parallel/SKILL.md +13 -8
  125. package/skills/ppr/SKILL.md +715 -371
  126. package/skills/prerender/SKILL.md +30 -17
  127. package/skills/rango/SKILL.md +72 -34
  128. package/skills/route/SKILL.md +14 -7
  129. package/skills/router-setup/SKILL.md +4 -2
  130. package/skills/scripts/SKILL.md +8 -1
  131. package/skills/server-actions/SKILL.md +11 -0
  132. package/skills/shell-manifest/SKILL.md +21 -12
  133. package/skills/testing/SKILL.md +49 -43
  134. package/skills/testing/cache-prerender.md +15 -14
  135. package/skills/testing/client-components.md +45 -3
  136. package/skills/testing/loader.md +19 -1
  137. package/skills/testing/middleware.md +1 -0
  138. package/skills/testing/predicates.md +49 -49
  139. package/skills/testing/render-handler.md +1 -0
  140. package/skills/testing/serve-shell-request.md +202 -0
  141. package/skills/testing/server-actions.md +9 -3
  142. package/skills/theme/SKILL.md +56 -4
  143. package/skills/typesafety/env-and-bindings.md +18 -2
  144. package/skills/use-cache/SKILL.md +91 -23
  145. package/skills/vercel/SKILL.md +14 -2
  146. package/skills/view-transitions/SKILL.md +150 -29
  147. package/src/__augment-tests__/augmented.check.ts +23 -0
  148. package/src/browser/cookie-name.ts +23 -0
  149. package/src/browser/event-controller.ts +4 -4
  150. package/src/browser/history-state.ts +75 -1
  151. package/src/browser/link-interceptor.ts +4 -0
  152. package/src/browser/navigation-bridge.ts +77 -18
  153. package/src/browser/navigation-client.ts +4 -3
  154. package/src/browser/navigation-store.ts +43 -1
  155. package/src/browser/navigation-transaction.ts +31 -11
  156. package/src/browser/partial-update.ts +146 -35
  157. package/src/browser/prefetch/cache.ts +6 -6
  158. package/src/browser/prefetch/fetch.ts +2 -2
  159. package/src/browser/react/Link.tsx +38 -33
  160. package/src/browser/react/NavigationProvider.tsx +2 -2
  161. package/src/browser/react/context.ts +2 -2
  162. package/src/browser/react/location-state-shared.ts +241 -49
  163. package/src/browser/react/location-state.ts +5 -2
  164. package/src/browser/rsc-router.tsx +9 -0
  165. package/src/browser/server-action-bridge.ts +84 -15
  166. package/src/browser/transition-when.ts +205 -0
  167. package/src/browser/types.ts +124 -10
  168. package/src/build/generate-manifest.ts +77 -1
  169. package/src/cache/background-task.ts +55 -2
  170. package/src/cache/cache-exec-scope.ts +48 -1
  171. package/src/cache/cache-key-utils.ts +62 -2
  172. package/src/cache/cache-policy.ts +31 -4
  173. package/src/cache/cache-runtime.ts +209 -129
  174. package/src/cache/cache-scope.ts +573 -69
  175. package/src/cache/cache-tag.ts +475 -19
  176. package/src/cache/cf/cf-base64.ts +36 -4
  177. package/src/cache/cf/cf-cache-constants.ts +12 -0
  178. package/src/cache/cf/cf-cache-store.ts +1094 -376
  179. package/src/cache/cf/cf-cache-types.ts +32 -4
  180. package/src/cache/cf/cf-shell-frame.ts +258 -0
  181. package/src/cache/cf/cf-tag-marker-memo.ts +55 -40
  182. package/src/cache/document-cache.ts +53 -7
  183. package/src/cache/handle-snapshot.ts +53 -9
  184. package/src/cache/index.ts +2 -0
  185. package/src/cache/invalidation-order.ts +84 -0
  186. package/src/cache/isolate-tag-memo.ts +308 -0
  187. package/src/cache/request-tag-mask.ts +147 -0
  188. package/src/cache/shell-memo.ts +341 -0
  189. package/src/cache/shell-snapshot.ts +249 -277
  190. package/src/cache/tag-invalidation.ts +109 -18
  191. package/src/cache/types.ts +274 -102
  192. package/src/cache/vercel/vercel-cache-store.ts +561 -74
  193. package/src/client-urls/client-root.tsx +8 -4
  194. package/src/client-urls/client-urls.ts +8 -6
  195. package/src/client-urls/navigation.ts +55 -4
  196. package/src/client-urls/server-projection.ts +31 -10
  197. package/src/client-urls/types.ts +12 -5
  198. package/src/client.tsx +2 -0
  199. package/src/host/cookie-handler.ts +2 -1
  200. package/src/index.rsc.ts +3 -0
  201. package/src/index.ts +3 -0
  202. package/src/loader-redirect.tsx +7 -3
  203. package/src/prerender/build-shell-capture.ts +173 -110
  204. package/src/route-definition/dsl-helpers.ts +6 -3
  205. package/src/route-definition/helpers-types.ts +11 -11
  206. package/src/route-name.ts +12 -0
  207. package/src/router/content-negotiation.ts +4 -1
  208. package/src/router/error-handling.ts +5 -2
  209. package/src/router/handler-context.ts +42 -5
  210. package/src/router/instrument.ts +54 -2
  211. package/src/router/intercept-resolution.ts +30 -8
  212. package/src/router/loader-resolution.ts +89 -33
  213. package/src/router/logging.ts +5 -3
  214. package/src/router/match-api.ts +89 -42
  215. package/src/router/match-context.ts +5 -0
  216. package/src/router/match-handlers.ts +14 -29
  217. package/src/router/match-middleware/background-revalidation.ts +3 -5
  218. package/src/router/match-middleware/cache-lookup.ts +52 -19
  219. package/src/router/match-middleware/cache-store.ts +36 -10
  220. package/src/router/match-result.ts +1 -0
  221. package/src/router/middleware-types.ts +6 -1
  222. package/src/router/middleware.ts +20 -4
  223. package/src/router/navigation-snapshot.ts +6 -5
  224. package/src/router/prerender-match.ts +10 -0
  225. package/src/router/request-classification.ts +7 -4
  226. package/src/router/router-options.ts +4 -1
  227. package/src/router/segment-resolution/fresh.ts +18 -13
  228. package/src/router/segment-resolution/helpers.ts +4 -2
  229. package/src/router/segment-resolution/loader-cache.ts +366 -70
  230. package/src/router/segment-resolution/loader-mask.ts +3 -4
  231. package/src/router/segment-resolution/loader-snapshot.ts +215 -126
  232. package/src/router/segment-resolution/mask-nested.ts +161 -42
  233. package/src/router/segment-resolution/revalidation.ts +27 -13
  234. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  235. package/src/router/telemetry.ts +6 -3
  236. package/src/router/transition-when.ts +34 -52
  237. package/src/router.ts +2 -1
  238. package/src/rsc/attach-transition-when.ts +47 -0
  239. package/src/rsc/full-payload.ts +47 -3
  240. package/src/rsc/handler.ts +24 -10
  241. package/src/rsc/loader-fetch.ts +2 -1
  242. package/src/rsc/manifest-init.ts +4 -0
  243. package/src/rsc/origin-guard.ts +8 -5
  244. package/src/rsc/progressive-enhancement.ts +10 -32
  245. package/src/rsc/response-cache-serve.ts +69 -78
  246. package/src/rsc/response-route-handler.ts +2 -2
  247. package/src/rsc/rsc-rendering.ts +712 -338
  248. package/src/rsc/server-action.ts +8 -20
  249. package/src/rsc/shell-build-manifest.ts +49 -29
  250. package/src/rsc/shell-capture-constants.ts +182 -12
  251. package/src/rsc/shell-capture.ts +1126 -612
  252. package/src/rsc/shell-serve.ts +331 -85
  253. package/src/rsc/ssr-setup.ts +3 -2
  254. package/src/rsc/types.ts +9 -0
  255. package/src/segment-system.tsx +40 -7
  256. package/src/server/context.ts +328 -72
  257. package/src/server/cookie-store.ts +184 -133
  258. package/src/server/handle-store.ts +245 -27
  259. package/src/server/request-context.ts +364 -100
  260. package/src/server/request-headers.ts +134 -0
  261. package/src/server.ts +4 -0
  262. package/src/ssr/index.tsx +96 -37
  263. package/src/ssr/ssr-root.tsx +9 -8
  264. package/src/testing/dispatch.ts +4 -7
  265. package/src/testing/e2e/index.ts +1 -0
  266. package/src/testing/flight.entry.ts +14 -0
  267. package/src/testing/index.ts +5 -0
  268. package/src/testing/internal/action-id.ts +25 -0
  269. package/src/testing/internal/context.ts +1 -1
  270. package/src/testing/location-state-key.ts +45 -0
  271. package/src/testing/render-handler.ts +1 -1
  272. package/src/testing/render-route.tsx +109 -16
  273. package/src/testing/run-client-revalidate.ts +3 -19
  274. package/src/testing/run-loader.ts +67 -5
  275. package/src/testing/run-transition-when.ts +109 -172
  276. package/src/testing/serve-shell-request.ts +381 -0
  277. package/src/testing/shell-status.ts +73 -47
  278. package/src/theme/ThemeProvider.tsx +7 -6
  279. package/src/transition-when-ref.ts +220 -0
  280. package/src/types/cache-types.ts +15 -3
  281. package/src/types/handler-context.ts +14 -1
  282. package/src/types/index.ts +3 -0
  283. package/src/types/segments.ts +94 -91
  284. package/src/urls/path-helper-types.ts +3 -4
  285. package/src/urls/pattern-types.ts +21 -18
  286. package/src/vite/discovery/discover-routers.ts +3 -0
  287. package/src/vite/discovery/shell-prerender-phase.ts +9 -2
  288. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  289. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  290. package/src/vite/rango.ts +2 -0
  291. package/src/vite/router-discovery.ts +89 -9
  292. package/dist/types/rsc/transition-gate.d.ts +0 -23
  293. package/src/rsc/transition-gate.ts +0 -86
@@ -14,6 +14,29 @@
14
14
  /** Default prefix when `stateCookiePrefix` is unset or empty after sanitization. */
15
15
  export const DEFAULT_STATE_COOKIE_PREFIX = "rango-state";
16
16
 
17
+ /**
18
+ * The fresh-reads cookie: the state cookie PREFIX plus this suffix
19
+ * (`rango-state-fresh` by default). A response whose request ran
20
+ * updateTag()/revalidateTag() sets it (HttpOnly, SameSite=Lax, Path=/,
21
+ * Max-Age = the stores' longest memo staleness), and the same user's requests
22
+ * carrying it skip the stores' isolate memos (src/cache/isolate-tag-memo.ts).
23
+ * Keyed by prefix, not by router, so a mutation through one router also
24
+ * reaches the memos another router on the host reads (they often share a
25
+ * store); a router with its own `stateCookiePrefix` gets its own cookie.
26
+ */
27
+ const FRESH_READS_COOKIE_SUFFIX = "-fresh";
28
+
29
+ /**
30
+ * The fresh-reads cookie name for a router's resolved state cookie name
31
+ * (`{prefix}_{routerId}`; the prefix never contains `_`, see
32
+ * router/state-cookie-name.ts).
33
+ */
34
+ export function freshReadsCookieName(stateCookieName: string): string {
35
+ const cut = stateCookieName.indexOf("_");
36
+ const prefix = cut < 0 ? stateCookieName : stateCookieName.slice(0, cut);
37
+ return `${prefix}${FRESH_READS_COOKIE_SUFFIX}`;
38
+ }
39
+
17
40
  /** Internal response header carrying the keepClientCache() directive. */
18
41
  export const KEEP_CACHE_HEADER = "x-rango-keep-cache";
19
42
 
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type {
3
3
  NavigationLocation,
4
- NavigateOptions,
4
+ NavigateOptionsInternal,
5
5
  TrackedActionState,
6
6
  ActionLifecycleState,
7
7
  InflightAction,
@@ -46,7 +46,7 @@ export interface NavigationEntry {
46
46
  abort: AbortController;
47
47
  phase: NavigationPhase;
48
48
  startedAt: number;
49
- options?: NavigateOptions & { skipLoadingState?: boolean };
49
+ options?: NavigateOptionsInternal & { skipLoadingState?: boolean };
50
50
  }
51
51
 
52
52
  /**
@@ -206,7 +206,7 @@ export interface EventController {
206
206
  // Navigation operations
207
207
  startNavigation(
208
208
  url: string,
209
- options?: NavigateOptions & { skipLoadingState?: boolean },
209
+ options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
210
210
  ): NavigationHandle;
211
211
  abortNavigation(): void;
212
212
 
@@ -632,7 +632,7 @@ export function createEventController(
632
632
 
633
633
  function startNavigation(
634
634
  url: string,
635
- options?: NavigateOptions & { skipLoadingState?: boolean },
635
+ options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
636
636
  ): NavigationHandle {
637
637
  // Cancel existing navigation (switchMap semantics)
638
638
  if (currentNavigation) {
@@ -1,5 +1,8 @@
1
1
  import {
2
+ isLocationStateDefinition,
2
3
  isLocationStateEntry,
4
+ peekLocationStateKey,
5
+ replaceCurrentHistoryState,
3
6
  resolveLocationStateEntries,
4
7
  } from "./react/location-state-shared.js";
5
8
 
@@ -13,10 +16,49 @@ function isTypedLocationState(
13
16
  return Object.keys(state).some((key) => key.startsWith("__rsc_ls_"));
14
17
  }
15
18
 
19
+ /**
20
+ * Dev-only guard for the state mistakes `HistoryState` rejects at compile
21
+ * time, for untyped callers: a typed entry without its array (its `__rsc_ls_*`
22
+ * fields would be spread onto history.state, so useLocationState reads
23
+ * undefined), an uncalled definition (pushState throws DataCloneError), and an
24
+ * array mixing entries with plain values (only `state[0]` picks the format, so
25
+ * the rest land under history.state["undefined"] or are never read).
26
+ */
27
+ function assertNavigationState(state: unknown): void {
28
+ if (isLocationStateEntry(state)) {
29
+ throw new Error(
30
+ `[rango] navigation state is a single location-state entry (key "${state.__rsc_ls_key}"). ` +
31
+ "Wrap it in an array: { state: [MyState(value)] }. " +
32
+ "Without the array, useLocationState(MyState) reads undefined.",
33
+ );
34
+ }
35
+ const definition = (Array.isArray(state) ? state : [state]).find(
36
+ isLocationStateDefinition,
37
+ );
38
+ if (definition) {
39
+ throw new Error(
40
+ `[rango] navigation state contains a location-state definition (key "${peekLocationStateKey(definition) ?? "unset"}") instead of an entry. ` +
41
+ "Call it with the value: { state: [MyState(value)] }, not [MyState].",
42
+ );
43
+ }
44
+ if (Array.isArray(state) && state.some(isLocationStateEntry)) {
45
+ const index = state.findIndex((item) => !isLocationStateEntry(item));
46
+ if (index !== -1) {
47
+ throw new Error(
48
+ `[rango] navigation state mixes location-state entries with other values (index ${index}). ` +
49
+ "Pass only entries ({ state: [MyState(value), Other(value)] }); plain state cannot sit next to typed entries.",
50
+ );
51
+ }
52
+ }
53
+ }
54
+
16
55
  /**
17
56
  * Resolve navigation state - handles both LocationStateEntry[] and plain formats
18
57
  */
19
58
  export function resolveNavigationState(state: unknown): unknown {
59
+ if (process.env.NODE_ENV !== "production") {
60
+ assertNavigationState(state);
61
+ }
20
62
  if (
21
63
  Array.isArray(state) &&
22
64
  state.length > 0 &&
@@ -61,6 +103,15 @@ export function buildHistoryState(
61
103
  return Object.keys(result).length > 0 ? result : null;
62
104
  }
63
105
 
106
+ /** Check if a history state object contains location state keys. */
107
+ export function hasLocationState(state: unknown): boolean {
108
+ if (!state || typeof state !== "object") return false;
109
+ return (
110
+ "state" in state ||
111
+ Object.keys(state).some((k) => k.startsWith("__rsc_ls_"))
112
+ );
113
+ }
114
+
64
115
  /**
65
116
  * Stamp an `idx` on the next history entry's state and call push/replaceState.
66
117
  * Push increments the current idx; replace keeps it. Initial entry idx is 0.
@@ -82,6 +133,29 @@ export function pushHistoryWithIdx(
82
133
  }
83
134
  }
84
135
 
136
+ /**
137
+ * The forced-MISS reload marker (`SHELL_MISS_PARAM` in rsc/shell-serve.ts,
138
+ * which the browser bundle must not import): a degraded PPR shell HIT
139
+ * reloads the page with it, and the server renders a marked request like a
140
+ * cache miss.
141
+ */
142
+ export const SHELL_MISS_MARKER: string = "_rsc_shell";
143
+
144
+ /**
145
+ * Drop the forced-MISS marker from the address bar at boot, keeping the
146
+ * history entry's state (initBrowserApp calls it before any location read).
147
+ * Left in place, a refresh or a shared link would stay off the shell and the
148
+ * app's search params would carry it. The server strips the same marker from
149
+ * the request before rendering (shell-serve.ts withoutShellMissMarker), so
150
+ * the page was rendered for the clean URL and the hydrating client agrees.
151
+ */
152
+ export function stripShellMissMarker(): void {
153
+ const url = new URL(window.location.href);
154
+ if (!url.searchParams.has(SHELL_MISS_MARKER)) return;
155
+ url.searchParams.delete(SHELL_MISS_MARKER);
156
+ window.history.replaceState(window.history.state, "", url.href);
157
+ }
158
+
85
159
  /**
86
160
  * Merge server-set location state into the current history entry.
87
161
  * Replaces the current history state and dispatches notification event
@@ -94,7 +168,7 @@ export function mergeLocationState(
94
168
  ...window.history.state,
95
169
  ...locationState,
96
170
  };
97
- window.history.replaceState(merged, "", window.location.href);
171
+ replaceCurrentHistoryState(merged);
98
172
  if (Object.keys(locationState).some((k) => k.startsWith("__rsc_ls_"))) {
99
173
  window.dispatchEvent(new Event("__rsc_locationstate"));
100
174
  }
@@ -334,6 +334,7 @@ export function setupLinkInterception(
334
334
  const scrollAttr = link.getAttribute("data-scroll");
335
335
  const replaceAttr = link.getAttribute("data-replace");
336
336
  const revalidateAttr = link.getAttribute("data-revalidate");
337
+ const transitionAttr = link.getAttribute("data-transition");
337
338
 
338
339
  const navigateOptions: NavigateOptions = {};
339
340
  if (scrollAttr === "false") {
@@ -345,6 +346,9 @@ export function setupLinkInterception(
345
346
  if (revalidateAttr === "false") {
346
347
  navigateOptions.revalidate = false;
347
348
  }
349
+ if (transitionAttr === "false") {
350
+ navigateOptions.transition = false;
351
+ }
348
352
 
349
353
  onNavigate(href, navigateOptions);
350
354
  };
@@ -14,7 +14,11 @@ import {
14
14
  createNavigationTransaction,
15
15
  resolveNavigationState,
16
16
  } from "./navigation-transaction.js";
17
- import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
17
+ import {
18
+ buildHistoryState,
19
+ hasLocationState,
20
+ pushHistoryWithIdx,
21
+ } from "./history-state.js";
18
22
  import {
19
23
  handleNavigationStart,
20
24
  handleNavigationEnd,
@@ -30,7 +34,11 @@ import {
30
34
  setupDelegatedLinkPrefetch,
31
35
  setupLinkInterception,
32
36
  } from "./link-interceptor.js";
33
- import { createPartialUpdater } from "./partial-update.js";
37
+ import {
38
+ createPartialUpdater,
39
+ shouldStartViewTransition,
40
+ } from "./partial-update.js";
41
+ import { decideCommitGatedOff, mergeSegmentParams } from "./transition-when.js";
34
42
  import { generateHistoryKey } from "./navigation-store.js";
35
43
  import type { EventController } from "./event-controller.js";
36
44
  import { isInterceptOnlyCache } from "./intercept-utils.js";
@@ -214,6 +222,7 @@ export function createNavigationBridge(
214
222
 
215
223
  // Snapshot old state before pushState/replaceState overwrites it
216
224
  const oldState = window.history.state;
225
+ const shallowRouteName = store.getHistoryEntryMemory()?.routeName;
217
226
 
218
227
  // Update browser URL (carry intercept context into history state)
219
228
  const historyState = buildHistoryState(
@@ -228,18 +237,11 @@ export function createNavigationBridge(
228
237
 
229
238
  // Ensure new history entry has a scroll restoration key
230
239
  ensureHistoryKey();
240
+ // Same route, new entry (transition({ when }) back/forward source).
241
+ store.rememberDisplayedEntry(shallowRouteName);
231
242
 
232
243
  // Notify useLocationState() hooks when state changes
233
- const hasOldState =
234
- oldState &&
235
- typeof oldState === "object" &&
236
- ("state" in oldState ||
237
- Object.keys(oldState).some((k) => k.startsWith("__rsc_ls_")));
238
- const hasNewState =
239
- historyState &&
240
- ("state" in historyState ||
241
- Object.keys(historyState).some((k) => k.startsWith("__rsc_ls_")));
242
- if (hasOldState || hasNewState) {
244
+ if (hasLocationState(oldState) || hasLocationState(historyState)) {
243
245
  window.dispatchEvent(new Event("__rsc_locationstate"));
244
246
  }
245
247
 
@@ -344,9 +346,44 @@ export function createNavigationBridge(
344
346
  state: resolvedState,
345
347
  skipLoadingState: false,
346
348
  });
349
+ // A cross-route clientUrls() navigation presents its destination
350
+ // optimistically at once: transition({ when }) decides THERE (the first
351
+ // presentation), over the destination route's predicate and those of
352
+ // the segments the navigation keeps (everything outside the group's
353
+ // route segment), and the canonical commit reuses the decision.
354
+ // `transition: false` gates the navigation off without calling any
355
+ // predicate, on both the optimistic swap and the canonical commit.
356
+ const transitionOptOut = options?.transition === false;
347
357
  const clientUrlPresentation = beginClientUrlNavigation(
348
358
  targetUrl,
349
359
  tx.handle.signal,
360
+ transitionOptOut
361
+ ? () => true
362
+ : (destination) =>
363
+ decideCommitGatedOff(
364
+ store,
365
+ (
366
+ store.getCachedSegments(store.getHistoryKey())?.segments ?? []
367
+ ).filter((segment) => segment.clientGroup === undefined),
368
+ options?.replace ? "replace" : "push",
369
+ {
370
+ to: (from) => {
371
+ // Mount (include prefix) params carry over; the origin
372
+ // route's own params do not.
373
+ const params = { ...from.params };
374
+ for (const name of destination.originLocalParamNames) {
375
+ delete params[name];
376
+ }
377
+ return {
378
+ url: targetUrl,
379
+ params: { ...params, ...destination.params },
380
+ routeName: destination.routeName,
381
+ state: buildHistoryState(resolvedState),
382
+ };
383
+ },
384
+ extra: destination.when,
385
+ },
386
+ ),
350
387
  );
351
388
 
352
389
  // REVALIDATE: Fetch fresh data from server
@@ -365,6 +402,9 @@ export function createNavigationBridge(
365
402
  replace: options?.replace,
366
403
  scroll: options?.scroll,
367
404
  state: resolvedState,
405
+ transitionGatedOff: transitionOptOut
406
+ ? true
407
+ : clientUrlPresentation?.transitionGatedOff,
368
408
  }),
369
409
  isLeavingIntercept
370
410
  ? { type: "leave-intercept" as const }
@@ -394,6 +434,7 @@ export function createNavigationBridge(
394
434
  return this.navigate(redirectUrl, {
395
435
  state: error.state,
396
436
  replace: options?.replace,
437
+ transition: options?.transition,
397
438
  _skipCache: true,
398
439
  } as NavigateOptionsInternal);
399
440
  }
@@ -457,6 +498,7 @@ export function createNavigationBridge(
457
498
  false,
458
499
  tx.handle.signal,
459
500
  tx.with({ url: window.location.href, replace: true, scroll: false }),
501
+ { type: "navigate", refresh: true },
460
502
  );
461
503
  } catch (error) {
462
504
  // Aborted or superseded: bail without rendering a boundary (see navigate()).
@@ -567,6 +609,26 @@ export function createNavigationBridge(
567
609
  const isStale = (cached?.stale ?? false) || isActionFenceActive();
568
610
 
569
611
  if (cachedSegments && cachedSegments.length > 0) {
612
+ // transition({ when }) decides the restore (kind "pop") against the
613
+ // entry being left, so before the store moves to the restored entry.
614
+ // The decision the entry was committed with is not reused: it was
615
+ // made for a different source. A restore that opens or closes an
616
+ // intercept never holds or animates (hasTransition below), so it is
617
+ // not decided, like every intercept commit.
618
+ const cachedParams = mergeSegmentParams(cachedSegments);
619
+ const gatedOff =
620
+ !isIntercept &&
621
+ !isLeavingIntercept &&
622
+ decideCommitGatedOff(store, cachedSegments, "pop", {
623
+ to: () => ({
624
+ url,
625
+ params: cachedParams,
626
+ routeName: store.getHistoryEntryMemory(historyState?.key)
627
+ ?.routeName,
628
+ state: historyState,
629
+ }),
630
+ });
631
+
570
632
  // Update store to point to this history entry
571
633
  store.setHistoryKey(historyKey);
572
634
  store.setSegmentIds(cachedSegments.map((s) => s.id));
@@ -583,14 +645,11 @@ export function createNavigationBridge(
583
645
  try {
584
646
  const root = await renderSegments(cachedSegments, {
585
647
  forceAwait: true,
648
+ transitionGatedOff: gatedOff,
586
649
  });
587
- // Merge params from cached segments for useParams restoration.
650
+ store.rememberDisplayedEntry();
588
651
  // Set params on event controller before onUpdate so both location
589
652
  // and params are current when the debounced notify() fires.
590
- const cachedParams: Record<string, string> = {};
591
- for (const s of cachedSegments) {
592
- if (s.params) Object.assign(cachedParams, s.params);
593
- }
594
653
  eventController.setParams(cachedParams);
595
654
 
596
655
  const popstateUpdate = {
@@ -613,7 +672,7 @@ export function createNavigationBridge(
613
672
  const hasTransition =
614
673
  !isIntercept &&
615
674
  !isLeavingIntercept &&
616
- cachedSegments.some((s) => s.transition);
675
+ shouldStartViewTransition(cachedSegments, gatedOff);
617
676
  if (hasTransition) {
618
677
  startTransition(() => {
619
678
  if (addTransitionType) {
@@ -122,9 +122,10 @@ export function createNavigationClient(
122
122
 
123
123
  // Check completed in-memory prefetch cache before making a network
124
124
  // request. Try the source-scoped key first (populated when the server
125
- // tagged the prefetch response as source-sensitive, e.g. intercepts,
126
- // or when a Link opted in with `prefetchKey=":source"`), then fall
127
- // back to the wildcard slot shared across source pages.
125
+ // tagged the prefetch response as source-sensitive, e.g. a route an
126
+ // intercept targets, or when a Link opted in with
127
+ // `prefetchKey=":source"`), then fall back to the wildcard slot shared
128
+ // across source pages.
128
129
  // Both keys embed the Rango state, so state rotation (deploy or
129
130
  // server-action invalidation) auto-invalidates both scopes.
130
131
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ HistoryEntryMemory,
2
3
  NavigationLocation,
3
4
  SegmentState,
4
5
  NavigationStore,
@@ -13,10 +14,18 @@ import {
13
14
  getRangoState,
14
15
  getRangoStateCookieName,
15
16
  } from "./rango-state.js";
17
+ import { getHistoryStateKey } from "./scroll-restoration.js";
18
+ import { setCurrentHistoryStateListener } from "./react/location-state-shared.js";
16
19
 
17
20
  // Maximum number of history entries to cache (URLs visited)
18
21
  const HISTORY_CACHE_SIZE = 20;
19
22
 
23
+ // Bound on remembered history entries (transition({ when }) sources), matching
24
+ // scroll-restoration's MAX_SCROLL_ENTRIES: deep enough for any realistic
25
+ // back/forward run, bounded for long sessions. An evicted entry reads as
26
+ // unknown (undefined route name and state), never guessed.
27
+ const HISTORY_ENTRY_MEMORY_SIZE = 200;
28
+
20
29
  // Cache entry:
21
30
  // [url-key, segments, stale, handleData?, routerId?, navInstance?, handlesPending?]
22
31
  // stale=true means the data may be outdated and should be revalidated on access.
@@ -233,6 +242,11 @@ export function createNavigationStore(
233
242
  // Current history key (set on navigation, stored in history.state)
234
243
  let currentHistoryKey = config?.initialHistoryKey || generateHistoryKey();
235
244
 
245
+ // Per-history-entry memory (rememberDisplayedEntry), keyed by the entry's
246
+ // history.state.key — unique per entry, unlike the URL-derived history key.
247
+ const entryMemory = new Map<string, HistoryEntryMemory>();
248
+ let displayedEntryKey: string | undefined;
249
+
236
250
  // Store initial segments if provided (not stale)
237
251
  if (config?.initialHistoryKey && config?.initialSegments) {
238
252
  historyCache.push([
@@ -375,7 +389,7 @@ export function createNavigationStore(
375
389
  }
376
390
  }
377
391
 
378
- return {
392
+ const store: NavigationStore = {
379
393
  // ========================================================================
380
394
  // Internal Segment State (for bridges)
381
395
  // ========================================================================
@@ -426,6 +440,27 @@ export function createNavigationStore(
426
440
  currentHistoryKey = key;
427
441
  },
428
442
 
443
+ rememberDisplayedEntry(routeName?: string): void {
444
+ if (typeof window === "undefined") return;
445
+ const key = getHistoryStateKey();
446
+ const previous = entryMemory.get(key);
447
+ entryMemory.delete(key);
448
+ entryMemory.set(key, {
449
+ routeName: routeName ?? previous?.routeName,
450
+ state: window.history.state,
451
+ });
452
+ if (entryMemory.size > HISTORY_ENTRY_MEMORY_SIZE) {
453
+ const oldest = entryMemory.keys().next().value;
454
+ if (oldest !== undefined) entryMemory.delete(oldest);
455
+ }
456
+ displayedEntryKey = key;
457
+ },
458
+
459
+ getHistoryEntryMemory(entryKey?: string): HistoryEntryMemory | undefined {
460
+ const key = entryKey ?? displayedEntryKey;
461
+ return key === undefined ? undefined : entryMemory.get(key);
462
+ },
463
+
429
464
  /**
430
465
  * Current nav-instance token: the instance of the most recently committed
431
466
  * navigation (the value last written by cacheSegmentsForHistory). A late
@@ -682,4 +717,11 @@ export function createNavigationStore(
682
717
  });
683
718
  },
684
719
  };
720
+
721
+ // Writes to the displayed entry after its commit (Def.write()/delete(), a
722
+ // flash read, merged server state) refresh its memory.
723
+ if (typeof window !== "undefined") {
724
+ setCurrentHistoryStateListener(() => store.rememberDisplayedEntry());
725
+ }
726
+ return store;
685
727
  }
@@ -1,5 +1,5 @@
1
1
  import type {
2
- NavigateOptions,
2
+ NavigateOptionsInternal,
3
3
  NavigationStore,
4
4
  ResolvedSegment,
5
5
  StreamingToken,
@@ -13,21 +13,13 @@ import type { EventController, NavigationHandle } from "./event-controller.js";
13
13
  import { debugLog } from "./logging.js";
14
14
  import {
15
15
  buildHistoryState,
16
+ hasLocationState,
16
17
  mergeLocationState,
17
18
  pushHistoryWithIdx,
18
19
  } from "./history-state.js";
19
20
 
20
21
  export { resolveNavigationState } from "./history-state.js";
21
22
 
22
- /** Check if a history state object contains location state keys. */
23
- function hasLocationState(state: unknown): boolean {
24
- if (!state || typeof state !== "object") return false;
25
- return (
26
- "state" in state ||
27
- Object.keys(state).some((k) => k.startsWith("__rsc_ls_"))
28
- );
29
- }
30
-
31
23
  if (typeof Symbol.dispose === "undefined") {
32
24
  (Symbol as any).dispose = Symbol("Symbol.dispose");
33
25
  }
@@ -59,6 +51,14 @@ interface CommitOptions {
59
51
  * merged in. The page left was saved by handleTraversalStart.
60
52
  */
61
53
  traversal?: boolean;
54
+ /** The committed route's name (payload metadata), remembered per history entry. */
55
+ routeName?: string;
56
+ /**
57
+ * transition({ when }) decision already made at this navigation's first
58
+ * presentation (the optimistic clientUrls() swap). Not a commit input: the
59
+ * partial updater reuses it instead of deciding again.
60
+ */
61
+ transitionGatedOff?: boolean;
62
62
  }
63
63
 
64
64
  /**
@@ -79,6 +79,8 @@ interface BoundCommitOverrides {
79
79
  cacheOnly?: boolean;
80
80
  /** Server-set location state to merge into history.pushState */
81
81
  serverState?: Record<string, unknown>;
82
+ /** The committed route's name (payload metadata), remembered per history entry. */
83
+ routeName?: string;
82
84
  }
83
85
 
84
86
  /**
@@ -86,6 +88,14 @@ interface BoundCommitOverrides {
86
88
  */
87
89
  export interface BoundTransaction {
88
90
  readonly currentUrl: string;
91
+ /** Bound `replace` option (history replace instead of push). */
92
+ readonly replace?: boolean;
93
+ /** Bound `traversal` option: a back/forward navigation. */
94
+ readonly traversal?: boolean;
95
+ /** Bound user state (resolved), pushed with the entry at commit. */
96
+ readonly state?: unknown;
97
+ /** Bound `transitionGatedOff`: the decision made at first presentation. */
98
+ readonly transitionGatedOff?: boolean;
89
99
  /** Start streaming and get a token to end it when the stream completes */
90
100
  startStreaming(): StreamingToken;
91
101
  /** Commit the navigation. Returns the effective scroll option for the caller to handle. */
@@ -117,7 +127,7 @@ export function createNavigationTransaction(
117
127
  store: NavigationStore,
118
128
  eventController: EventController,
119
129
  url: string,
120
- options?: NavigateOptions & { skipLoadingState?: boolean },
130
+ options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
121
131
  ): NavigationTransaction {
122
132
  let committed = false;
123
133
  const currentUrl = window.location.href;
@@ -168,6 +178,9 @@ export function createNavigationTransaction(
168
178
  store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
169
179
 
170
180
  if (storeOnly) {
181
+ // Same entry (an action refetch): refresh its state, keep its name
182
+ // unless the payload names it.
183
+ store.rememberDisplayedEntry(opts.routeName);
171
184
  debugLog("[Browser] Store updated (action)");
172
185
  handle.complete(parsedUrl);
173
186
  return { scroll: false };
@@ -177,6 +190,7 @@ export function createNavigationTransaction(
177
190
  if (serverState && Object.keys(serverState).length > 0) {
178
191
  mergeLocationState(serverState);
179
192
  }
193
+ store.rememberDisplayedEntry(opts.routeName);
180
194
  handle.complete(parsedUrl);
181
195
  debugLog("[Browser] Traversal committed, historyKey:", historyKey);
182
196
  return { scroll };
@@ -192,6 +206,7 @@ export function createNavigationTransaction(
192
206
 
193
207
  pushHistoryWithIdx(historyState, url, replace ?? false);
194
208
  ensureHistoryKey();
209
+ store.rememberDisplayedEntry(opts.routeName);
195
210
 
196
211
  if (hasLocationState(oldState) || hasLocationState(historyState)) {
197
212
  window.dispatchEvent(new Event("__rsc_locationstate"));
@@ -219,6 +234,10 @@ export function createNavigationTransaction(
219
234
  get currentUrl() {
220
235
  return currentUrl;
221
236
  },
237
+ replace: opts.replace,
238
+ traversal: opts.traversal,
239
+ state: opts.state,
240
+ transitionGatedOff: opts.transitionGatedOff,
222
241
  startStreaming() {
223
242
  return handle.startStreaming();
224
243
  },
@@ -249,6 +268,7 @@ export function createNavigationTransaction(
249
268
  interceptSourceUrl,
250
269
  cacheOnly,
251
270
  serverState,
271
+ routeName: overrides?.routeName ?? opts.routeName,
252
272
  });
253
273
  },
254
274
  };