@rangojs/router 0.0.0-experimental.b9cb8739 → 0.0.0-experimental.bd6e11bc

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 (285) hide show
  1. package/README.md +196 -43
  2. package/dist/bin/rango.js +277 -99
  3. package/dist/testing/vitest.js +48 -0
  4. package/dist/vite/index.js +2779 -1064
  5. package/dist/vite/index.js.bak +5448 -0
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +57 -11
  8. package/skills/breadcrumbs/SKILL.md +3 -1
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +243 -21
  11. package/skills/caching/SKILL.md +155 -6
  12. package/skills/composability/SKILL.md +27 -2
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +45 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +46 -4
  19. package/skills/layout/SKILL.md +28 -7
  20. package/skills/links/SKILL.md +249 -17
  21. package/skills/loader/SKILL.md +273 -53
  22. package/skills/middleware/SKILL.md +49 -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 +197 -6
  28. package/skills/prerender/SKILL.md +123 -100
  29. package/skills/rango/SKILL.md +242 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +66 -9
  32. package/skills/route/SKILL.md +88 -4
  33. package/skills/router-setup/SKILL.md +90 -5
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/testing/SKILL.md +716 -0
  37. package/skills/typesafety/SKILL.md +329 -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 +117 -0
  42. package/src/__internal.ts +1 -1
  43. package/src/browser/action-coordinator.ts +53 -36
  44. package/src/browser/app-shell.ts +52 -0
  45. package/src/browser/app-version.ts +14 -0
  46. package/src/browser/event-controller.ts +91 -70
  47. package/src/browser/history-state.ts +21 -0
  48. package/src/browser/index.ts +3 -3
  49. package/src/browser/navigation-bridge.ts +102 -16
  50. package/src/browser/navigation-client.ts +164 -59
  51. package/src/browser/navigation-store.ts +75 -17
  52. package/src/browser/navigation-transaction.ts +21 -37
  53. package/src/browser/partial-update.ts +139 -38
  54. package/src/browser/prefetch/cache.ts +175 -15
  55. package/src/browser/prefetch/fetch.ts +180 -33
  56. package/src/browser/prefetch/queue.ts +123 -20
  57. package/src/browser/prefetch/resource-ready.ts +77 -0
  58. package/src/browser/rango-state.ts +53 -13
  59. package/src/browser/react/Link.tsx +81 -9
  60. package/src/browser/react/NavigationProvider.tsx +110 -33
  61. package/src/browser/react/context.ts +7 -2
  62. package/src/browser/react/filter-segment-order.ts +51 -7
  63. package/src/browser/react/index.ts +3 -0
  64. package/src/browser/react/location-state-shared.ts +175 -4
  65. package/src/browser/react/location-state.ts +39 -13
  66. package/src/browser/react/use-handle.ts +23 -64
  67. package/src/browser/react/use-navigation.ts +22 -2
  68. package/src/browser/react/use-params.ts +20 -8
  69. package/src/browser/react/use-reverse.ts +106 -0
  70. package/src/browser/react/use-router.ts +43 -10
  71. package/src/browser/react/use-segments.ts +11 -8
  72. package/src/browser/response-adapter.ts +25 -0
  73. package/src/browser/rsc-router.tsx +191 -74
  74. package/src/browser/scroll-restoration.ts +41 -14
  75. package/src/browser/segment-reconciler.ts +36 -9
  76. package/src/browser/segment-structure-assert.ts +2 -2
  77. package/src/browser/server-action-bridge.ts +31 -36
  78. package/src/browser/types.ts +57 -5
  79. package/src/build/collect-fallback-refs.ts +107 -0
  80. package/src/build/generate-manifest.ts +65 -40
  81. package/src/build/generate-route-types.ts +5 -0
  82. package/src/build/index.ts +2 -0
  83. package/src/build/route-trie.ts +52 -25
  84. package/src/build/route-types/codegen.ts +4 -4
  85. package/src/build/route-types/include-resolution.ts +9 -2
  86. package/src/build/route-types/per-module-writer.ts +7 -4
  87. package/src/build/route-types/router-processing.ts +278 -88
  88. package/src/build/route-types/scan-filter.ts +9 -2
  89. package/src/build/route-types/source-scan.ts +118 -0
  90. package/src/build/runtime-discovery.ts +9 -20
  91. package/src/cache/cache-runtime.ts +15 -11
  92. package/src/cache/cache-scope.ts +76 -49
  93. package/src/cache/cf/cf-cache-store.ts +501 -18
  94. package/src/cache/cf/index.ts +5 -1
  95. package/src/cache/document-cache.ts +17 -7
  96. package/src/cache/index.ts +1 -0
  97. package/src/cache/taint.ts +55 -0
  98. package/src/client.rsc.tsx +3 -0
  99. package/src/client.tsx +94 -238
  100. package/src/context-var.ts +72 -2
  101. package/src/debug.ts +2 -2
  102. package/src/decode-loader-results.ts +36 -0
  103. package/src/errors.ts +30 -1
  104. package/src/handle.ts +65 -12
  105. package/src/host/index.ts +2 -2
  106. package/src/host/router.ts +129 -57
  107. package/src/host/types.ts +31 -2
  108. package/src/host/utils.ts +1 -1
  109. package/src/href-client.ts +140 -20
  110. package/src/index.rsc.ts +12 -5
  111. package/src/index.ts +61 -11
  112. package/src/loader-store.ts +500 -0
  113. package/src/loader.rsc.ts +2 -5
  114. package/src/loader.ts +3 -10
  115. package/src/missing-id-error.ts +68 -0
  116. package/src/outlet-context.ts +1 -1
  117. package/src/prerender/store.ts +5 -4
  118. package/src/prerender.ts +141 -80
  119. package/src/response-utils.ts +37 -0
  120. package/src/reverse.ts +65 -15
  121. package/src/route-content-wrapper.tsx +6 -28
  122. package/src/route-definition/dsl-helpers.ts +435 -260
  123. package/src/route-definition/helper-factories.ts +29 -139
  124. package/src/route-definition/helpers-types.ts +110 -34
  125. package/src/route-definition/index.ts +3 -0
  126. package/src/route-definition/redirect.ts +11 -3
  127. package/src/route-definition/resolve-handler-use.ts +155 -0
  128. package/src/route-definition/use-item-types.ts +32 -0
  129. package/src/route-map-builder.ts +7 -1
  130. package/src/route-types.ts +37 -41
  131. package/src/router/basename.ts +14 -0
  132. package/src/router/content-negotiation.ts +113 -1
  133. package/src/router/error-handling.ts +1 -1
  134. package/src/router/find-match.ts +4 -2
  135. package/src/router/handler-context.ts +77 -38
  136. package/src/router/intercept-resolution.ts +15 -22
  137. package/src/router/lazy-includes.ts +12 -9
  138. package/src/router/loader-resolution.ts +174 -22
  139. package/src/router/logging.ts +5 -2
  140. package/src/router/manifest.ts +31 -16
  141. package/src/router/match-api.ts +128 -192
  142. package/src/router/match-handlers.ts +63 -20
  143. package/src/router/match-middleware/background-revalidation.ts +30 -2
  144. package/src/router/match-middleware/cache-lookup.ts +136 -106
  145. package/src/router/match-middleware/cache-store.ts +54 -10
  146. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  147. package/src/router/match-middleware/segment-resolution.ts +61 -5
  148. package/src/router/match-result.ts +125 -10
  149. package/src/router/metrics.ts +7 -2
  150. package/src/router/middleware-types.ts +21 -34
  151. package/src/router/middleware.ts +103 -90
  152. package/src/router/navigation-snapshot.ts +182 -0
  153. package/src/router/pattern-matching.ts +101 -17
  154. package/src/router/prerender-match.ts +110 -10
  155. package/src/router/preview-match.ts +32 -102
  156. package/src/router/request-classification.ts +286 -0
  157. package/src/router/revalidation.ts +58 -2
  158. package/src/router/route-snapshot.ts +245 -0
  159. package/src/router/router-context.ts +6 -1
  160. package/src/router/router-interfaces.ts +77 -28
  161. package/src/router/router-options.ts +76 -11
  162. package/src/router/router-registry.ts +2 -5
  163. package/src/router/segment-resolution/fresh.ts +223 -24
  164. package/src/router/segment-resolution/helpers.ts +29 -24
  165. package/src/router/segment-resolution/loader-cache.ts +1 -0
  166. package/src/router/segment-resolution/revalidation.ts +466 -285
  167. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  168. package/src/router/segment-wrappers.ts +2 -0
  169. package/src/router/substitute-pattern-params.ts +56 -0
  170. package/src/router/telemetry.ts +99 -0
  171. package/src/router/trie-matching.ts +18 -13
  172. package/src/router/types.ts +9 -0
  173. package/src/router/url-params.ts +49 -0
  174. package/src/router.ts +91 -23
  175. package/src/rsc/handler-context.ts +2 -2
  176. package/src/rsc/handler.ts +440 -381
  177. package/src/rsc/helpers.ts +91 -43
  178. package/src/rsc/index.ts +1 -1
  179. package/src/rsc/loader-fetch.ts +23 -3
  180. package/src/rsc/manifest-init.ts +5 -1
  181. package/src/rsc/origin-guard.ts +28 -10
  182. package/src/rsc/progressive-enhancement.ts +18 -2
  183. package/src/rsc/response-route-handler.ts +46 -53
  184. package/src/rsc/rsc-rendering.ts +41 -48
  185. package/src/rsc/runtime-warnings.ts +9 -10
  186. package/src/rsc/server-action.ts +25 -37
  187. package/src/rsc/ssr-setup.ts +18 -2
  188. package/src/rsc/types.ts +17 -3
  189. package/src/search-params.ts +4 -4
  190. package/src/segment-content-promise.ts +67 -0
  191. package/src/segment-loader-promise.ts +122 -0
  192. package/src/segment-system.tsx +219 -67
  193. package/src/serialize.ts +243 -0
  194. package/src/server/context.ts +277 -61
  195. package/src/server/cookie-store.ts +28 -4
  196. package/src/server/handle-store.ts +19 -0
  197. package/src/server/loader-registry.ts +9 -8
  198. package/src/server/request-context.ts +204 -60
  199. package/src/ssr/index.tsx +9 -1
  200. package/src/static-handler.ts +19 -7
  201. package/src/testing/cache-status.ts +166 -0
  202. package/src/testing/collect-handle.ts +63 -0
  203. package/src/testing/dispatch.ts +440 -0
  204. package/src/testing/dom.entry.ts +22 -0
  205. package/src/testing/e2e/fixture.ts +154 -0
  206. package/src/testing/e2e/index.ts +149 -0
  207. package/src/testing/e2e/matchers.ts +51 -0
  208. package/src/testing/e2e/page-helpers.ts +272 -0
  209. package/src/testing/e2e/parity.ts +306 -0
  210. package/src/testing/e2e/server.ts +183 -0
  211. package/src/testing/flight-matchers.ts +104 -0
  212. package/src/testing/flight-runtime.d.ts +21 -0
  213. package/src/testing/flight.entry.ts +22 -0
  214. package/src/testing/flight.ts +182 -0
  215. package/src/testing/generated-routes.ts +223 -0
  216. package/src/testing/index.ts +106 -0
  217. package/src/testing/internal/context.ts +255 -0
  218. package/src/testing/render-route.tsx +565 -0
  219. package/src/testing/run-loader.ts +296 -0
  220. package/src/testing/run-middleware.ts +179 -0
  221. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  222. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  223. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  224. package/src/testing/vitest-stubs/version.ts +5 -0
  225. package/src/testing/vitest.ts +183 -0
  226. package/src/types/cache-types.ts +4 -4
  227. package/src/types/global-namespace.ts +39 -26
  228. package/src/types/handler-context.ts +194 -72
  229. package/src/types/index.ts +1 -0
  230. package/src/types/loader-types.ts +41 -15
  231. package/src/types/request-scope.ts +126 -0
  232. package/src/types/route-entry.ts +19 -1
  233. package/src/types/segments.ts +37 -1
  234. package/src/urls/include-helper.ts +34 -67
  235. package/src/urls/index.ts +0 -3
  236. package/src/urls/path-helper-types.ts +50 -9
  237. package/src/urls/path-helper.ts +63 -63
  238. package/src/urls/pattern-types.ts +48 -19
  239. package/src/urls/response-types.ts +25 -22
  240. package/src/urls/type-extraction.ts +26 -116
  241. package/src/urls/urls-function.ts +1 -5
  242. package/src/use-loader.tsx +487 -44
  243. package/src/vite/debug.ts +185 -0
  244. package/src/vite/discovery/bundle-postprocess.ts +34 -37
  245. package/src/vite/discovery/discover-routers.ts +105 -51
  246. package/src/vite/discovery/discovery-errors.ts +194 -0
  247. package/src/vite/discovery/gate-state.ts +171 -0
  248. package/src/vite/discovery/prerender-collection.ts +188 -93
  249. package/src/vite/discovery/route-types-writer.ts +40 -84
  250. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  251. package/src/vite/discovery/state.ts +46 -6
  252. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  253. package/src/vite/index.ts +6 -0
  254. package/src/vite/plugin-types.ts +111 -72
  255. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  256. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  257. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  258. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  259. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  260. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  261. package/src/vite/plugins/expose-action-id.ts +55 -33
  262. package/src/vite/plugins/expose-id-utils.ts +24 -8
  263. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  264. package/src/vite/plugins/expose-ids/handler-transform.ts +12 -35
  265. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  266. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  267. package/src/vite/plugins/expose-internal-ids.ts +544 -317
  268. package/src/vite/plugins/performance-tracks.ts +92 -0
  269. package/src/vite/plugins/refresh-cmd.ts +88 -26
  270. package/src/vite/plugins/use-cache-transform.ts +65 -50
  271. package/src/vite/plugins/version-injector.ts +39 -23
  272. package/src/vite/plugins/version-plugin.ts +72 -3
  273. package/src/vite/plugins/virtual-entries.ts +2 -2
  274. package/src/vite/rango.ts +265 -226
  275. package/src/vite/router-discovery.ts +920 -137
  276. package/src/vite/utils/ast-handler-extract.ts +15 -15
  277. package/src/vite/utils/banner.ts +4 -4
  278. package/src/vite/utils/bundle-analysis.ts +4 -2
  279. package/src/vite/utils/client-chunks.ts +190 -0
  280. package/src/vite/utils/forward-user-plugins.ts +193 -0
  281. package/src/vite/utils/manifest-utils.ts +21 -5
  282. package/src/vite/utils/package-resolution.ts +41 -1
  283. package/src/vite/utils/prerender-utils.ts +38 -5
  284. package/src/vite/utils/shared-utils.ts +109 -27
  285. package/src/browser/action-response-classifier.ts +0 -99
@@ -6,21 +6,37 @@
6
6
  * navigation requests. The server responds with `Vary: X-Rango-State`,
7
7
  * so the browser HTTP cache keys responses by (URL, X-Rango-State value).
8
8
  *
9
- * Format: `{buildVersion}:{invalidationTimestamp}`
9
+ * Value format: `{buildVersion}:{invalidationTimestamp}`
10
10
  * - Build version changes on deploy, busting all cached prefetches.
11
11
  * - Timestamp changes on server action invalidation.
12
12
  *
13
- * localStorage is cross-tab and survives page refresh, so:
14
- * - One tab's prefetch warms the cache for all tabs.
15
- * - Invalidation in one tab is picked up by other tabs on next fetch.
13
+ * Storage key is namespaced per routerId (`rango-state:{routerId}`) so
14
+ * tabs in different apps on the same origin do not collide. Two tabs in
15
+ * the same app share a key → one tab's invalidation is picked up by the
16
+ * other via the `storage` event. A smooth cross-app transition in this
17
+ * tab rebinds to the target app's key; other tabs still in the old app
18
+ * keep their own key intact.
19
+ *
20
+ * If no routerId is supplied, falls back to a single legacy key for
21
+ * backward compatibility (single-app deployments unaffected).
16
22
  */
17
23
 
18
- const STORAGE_KEY = "rango-state";
24
+ const LEGACY_STORAGE_KEY = "rango-state";
25
+
26
+ function buildStorageKey(routerId: string | undefined): string {
27
+ return routerId ? `${LEGACY_STORAGE_KEY}:${routerId}` : LEGACY_STORAGE_KEY;
28
+ }
19
29
 
20
30
  // Module-level cache avoids hitting localStorage on every getRangoState() call.
21
31
  // Initialized from localStorage on first access or by initRangoState().
22
32
  let cachedState: string | null = null;
23
33
 
34
+ // The localStorage key this tab is currently bound to. Rebinds on
35
+ // initRangoState (document boot) and setRangoStateLocal (smooth app
36
+ // switch). The storage listener filters cross-tab events by this key so
37
+ // events from tabs in a different app are ignored.
38
+ let currentStorageKey: string = LEGACY_STORAGE_KEY;
39
+
24
40
  // Cross-tab sync: the `storage` event fires in OTHER tabs when one tab writes
25
41
  // to localStorage, keeping cachedState fresh without polling.
26
42
  let storageListenerAttached = false;
@@ -28,7 +44,10 @@ let storageListenerAttached = false;
28
44
  function attachStorageListener(): void {
29
45
  if (storageListenerAttached || typeof window === "undefined") return;
30
46
  window.addEventListener("storage", (e) => {
31
- if (e.key !== STORAGE_KEY) return;
47
+ // Only react to events for this tab's current app namespace. Events
48
+ // under other routerId-scoped keys belong to other apps and must not
49
+ // clobber this tab's state.
50
+ if (e.key !== currentStorageKey) return;
32
51
  cachedState = e.newValue;
33
52
  });
34
53
  storageListenerAttached = true;
@@ -37,16 +56,22 @@ function attachStorageListener(): void {
37
56
  /**
38
57
  * Initialize the Rango state key in localStorage.
39
58
  * Called once at app startup with the build version from the server.
40
- * If localStorage already has a key with matching version prefix, keeps it
41
- * (preserves invalidation state across refresh). Otherwise writes a new key.
59
+ * The routerId scopes the storage key to this app; in multi-app setups
60
+ * each app owns its own `rango-state:{routerId}` key and cannot observe
61
+ * invalidations from sibling apps on the same origin.
62
+ *
63
+ * If localStorage already has a matching-version entry under the key,
64
+ * keeps it (preserves invalidation state across refresh). Otherwise
65
+ * writes a new value.
42
66
  */
43
- export function initRangoState(version: string): void {
67
+ export function initRangoState(version: string, routerId?: string): void {
68
+ currentStorageKey = buildStorageKey(routerId);
44
69
  if (typeof window === "undefined") return;
45
70
 
46
71
  attachStorageListener();
47
72
 
48
73
  try {
49
- const existing = localStorage.getItem(STORAGE_KEY);
74
+ const existing = localStorage.getItem(currentStorageKey);
50
75
  if (existing) {
51
76
  const colonIdx = existing.indexOf(":");
52
77
  if (colonIdx > 0) {
@@ -59,7 +84,7 @@ export function initRangoState(version: string): void {
59
84
  }
60
85
  // New version or first load
61
86
  const newState = `${version}:${Date.now()}`;
62
- localStorage.setItem(STORAGE_KEY, newState);
87
+ localStorage.setItem(currentStorageKey, newState);
63
88
  cachedState = newState;
64
89
  } catch {
65
90
  // localStorage may be unavailable (private browsing in some browsers)
@@ -77,7 +102,7 @@ export function getRangoState(): string {
77
102
  if (typeof window === "undefined") return "0:0";
78
103
 
79
104
  try {
80
- const stored = localStorage.getItem(STORAGE_KEY);
105
+ const stored = localStorage.getItem(currentStorageKey);
81
106
  if (stored) {
82
107
  cachedState = stored;
83
108
  return stored;
@@ -89,6 +114,21 @@ export function getRangoState(): string {
89
114
  return "0:0";
90
115
  }
91
116
 
117
+ /**
118
+ * Update the in-memory rango-state to a new version WITHOUT writing
119
+ * localStorage. Intended for smooth cross-app transitions in this tab only:
120
+ * subsequent requests from this tab send the new token, but other tabs
121
+ * still in the previous app do not observe a storage event. Rebinds this
122
+ * tab's storage key to the target app's namespace (`rango-state:{routerId}`)
123
+ * so subsequent storage events only reflect the new app. On the next hard
124
+ * reload, initRangoState reconciles localStorage from the server's
125
+ * authoritative version.
126
+ */
127
+ export function setRangoStateLocal(version: string, routerId?: string): void {
128
+ currentStorageKey = buildStorageKey(routerId);
129
+ cachedState = `${version}:${Date.now()}`;
130
+ }
131
+
92
132
  /**
93
133
  * Invalidate the Rango state key. Called when server actions mutate data.
94
134
  * Updates the timestamp portion while keeping the version prefix.
@@ -105,7 +145,7 @@ export function invalidateRangoState(): void {
105
145
  if (typeof window === "undefined") return;
106
146
 
107
147
  try {
108
- localStorage.setItem(STORAGE_KEY, newState);
148
+ localStorage.setItem(currentStorageKey, newState);
109
149
  } catch {
110
150
  // Silently handle localStorage errors
111
151
  }
@@ -5,6 +5,7 @@ import React, {
5
5
  useCallback,
6
6
  useContext,
7
7
  useEffect,
8
+ useMemo,
8
9
  useRef,
9
10
  type ForwardRefExoticComponent,
10
11
  type RefAttributes,
@@ -32,6 +33,7 @@ export type LinkState =
32
33
  | StateOrGetter<Record<string, unknown>>;
33
34
 
34
35
  import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js";
36
+ import { getAppVersion } from "../app-version.js";
35
37
  import {
36
38
  observeForPrefetch,
37
39
  unobserveForPrefetch,
@@ -95,6 +97,31 @@ export interface LinkProps extends Omit<
95
97
  * @default "none"
96
98
  */
97
99
  prefetch?: PrefetchStrategy;
100
+ /**
101
+ * Opt-in override for the prefetch cache scope.
102
+ *
103
+ * The default cache is source-agnostic: one shared entry per target,
104
+ * keyed on Rango state + target URL. This is correct for routes whose
105
+ * response shape doesn't depend on where the user navigates from.
106
+ *
107
+ * Set `":source"` when this Link's response would legitimately differ
108
+ * based on the source page — typically when the target route (or one
109
+ * of its layouts) uses a custom `revalidate()` handler that reads
110
+ * `currentUrl` / `currentParams`, and the wildcard entry would
111
+ * therefore serve the wrong diff to a navigation from a different
112
+ * source.
113
+ *
114
+ * Intercept responses are auto-scoped to the source via a server-side
115
+ * tag, so `":source"` is only needed for custom revalidation logic.
116
+ *
117
+ * @example
118
+ * ```tsx
119
+ * // Route uses a `revalidate()` that branches on currentUrl — opt in
120
+ * // so prefetches don't bleed across source pages.
121
+ * <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
122
+ * ```
123
+ */
124
+ prefetchKey?: ":source";
98
125
  /**
99
126
  * State to pass to history.pushState/replaceState.
100
127
  * Accessible via useLocationState() hook.
@@ -182,6 +209,7 @@ export const Link: ForwardRefExoticComponent<
182
209
  reloadDocument = false,
183
210
  revalidate,
184
211
  prefetch = "none",
212
+ prefetchKey,
185
213
  state,
186
214
  children,
187
215
  onClick,
@@ -192,6 +220,16 @@ export const Link: ForwardRefExoticComponent<
192
220
  const ctx = useContext(NavigationStoreContext);
193
221
  const isExternal = isExternalUrl(to);
194
222
 
223
+ // Auto-prefix with basename for app-local paths.
224
+ // Skip if external, already prefixed, or not a root-relative path.
225
+ const resolvedTo = useMemo(() => {
226
+ if (isExternal) return to;
227
+ const bn = ctx?.basename;
228
+ if (!bn || !to.startsWith("/") || to.startsWith(bn + "/") || to === bn)
229
+ return to;
230
+ return to === "/" ? bn : bn + to;
231
+ }, [to, isExternal, ctx?.basename]);
232
+
195
233
  // Resolve adaptive: viewport on touch devices, hover on pointer devices
196
234
  const resolvedStrategy =
197
235
  prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
@@ -273,17 +311,45 @@ export const Link: ForwardRefExoticComponent<
273
311
  resolvedState = currentState;
274
312
  }
275
313
 
276
- ctx.navigate(to, { replace, scroll, state: resolvedState, revalidate });
314
+ ctx.navigate(resolvedTo, {
315
+ replace,
316
+ scroll,
317
+ state: resolvedState,
318
+ revalidate,
319
+ });
277
320
  },
278
- [to, isExternal, reloadDocument, replace, scroll, revalidate, ctx, onClick],
321
+ [
322
+ resolvedTo,
323
+ isExternal,
324
+ reloadDocument,
325
+ replace,
326
+ scroll,
327
+ revalidate,
328
+ ctx,
329
+ onClick,
330
+ ],
279
331
  );
280
332
 
281
333
  const handleMouseEnter = useCallback(() => {
282
- if (resolvedStrategy === "hover" && !isExternal && ctx?.store) {
334
+ if (
335
+ (resolvedStrategy === "hover" || resolvedStrategy === "viewport") &&
336
+ !isExternal &&
337
+ ctx?.store
338
+ ) {
339
+ // For "hover", this is the primary prefetch trigger.
340
+ // For "viewport", this upgrades/prioritizes a potentially queued
341
+ // prefetch — prefetchDirect bypasses the queue, and hasPrefetch
342
+ // deduplicates if the viewport prefetch already completed.
283
343
  const segmentState = ctx.store.getSegmentState();
284
- prefetchDirect(to, segmentState.currentSegmentIds, ctx.version);
344
+ prefetchDirect(
345
+ resolvedTo,
346
+ segmentState.currentSegmentIds,
347
+ getAppVersion(),
348
+ ctx.store.getRouterId?.(),
349
+ prefetchKey,
350
+ );
285
351
  }
286
- }, [resolvedStrategy, to, isExternal, ctx]);
352
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
287
353
 
288
354
  // Viewport/render prefetch: waits for idle before starting,
289
355
  // uses concurrency-limited queue to avoid flooding.
@@ -300,7 +366,13 @@ export const Link: ForwardRefExoticComponent<
300
366
  const triggerPrefetch = () => {
301
367
  if (cancelled) return;
302
368
  const segmentState = ctx.store.getSegmentState();
303
- prefetchQueued(to, segmentState.currentSegmentIds, ctx.version);
369
+ prefetchQueued(
370
+ resolvedTo,
371
+ segmentState.currentSegmentIds,
372
+ getAppVersion(),
373
+ ctx.store.getRouterId?.(),
374
+ prefetchKey,
375
+ );
304
376
  };
305
377
 
306
378
  // Schedule prefetch only when the app is idle (no navigation/streaming).
@@ -339,12 +411,12 @@ export const Link: ForwardRefExoticComponent<
339
411
  unobserveForPrefetch(observedElement);
340
412
  }
341
413
  };
342
- }, [resolvedStrategy, to, isExternal, ctx]);
414
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
343
415
 
344
416
  return (
345
417
  <a
346
418
  ref={setRef}
347
- href={to}
419
+ href={resolvedTo}
348
420
  onClick={handleClick}
349
421
  onMouseEnter={handleMouseEnter}
350
422
  data-link-component
@@ -354,7 +426,7 @@ export const Link: ForwardRefExoticComponent<
354
426
  data-revalidate={revalidate === false ? "false" : undefined}
355
427
  {...props}
356
428
  >
357
- <LinkContext.Provider value={to}>{children}</LinkContext.Provider>
429
+ <LinkContext.Provider value={resolvedTo}>{children}</LinkContext.Provider>
358
430
  </a>
359
431
  );
360
432
  });
@@ -3,8 +3,10 @@
3
3
  import React, {
4
4
  useState,
5
5
  useEffect,
6
+ useLayoutEffect,
6
7
  useCallback,
7
8
  useMemo,
9
+ useRef,
8
10
  use,
9
11
  type ReactNode,
10
12
  } from "react";
@@ -25,6 +27,8 @@ import { ThemeProvider } from "../../theme/ThemeProvider.js";
25
27
  import { NonceContext } from "./nonce-context.js";
26
28
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
27
29
  import { cancelAllPrefetches } from "../prefetch/queue.js";
30
+ import { handleNavigationEnd } from "../scroll-restoration.js";
31
+ import { createAppShellRef, type AppShellRef } from "../app-shell.js";
28
32
 
29
33
  /**
30
34
  * Process handles from an async generator, updating the event controller
@@ -43,10 +47,22 @@ async function processHandles(
43
47
  store: NavigationStore;
44
48
  matched?: string[];
45
49
  isPartial?: boolean;
50
+ /** Server's `resolvedIds`: every segment re-resolved this request,
51
+ * including null-component ones excluded from `diff`/`segments`.
52
+ * Drives cleanup of stale handle buckets when a re-resolved segment
53
+ * pushed nothing. */
54
+ resolvedIds?: string[];
46
55
  historyKey: string;
47
56
  },
48
57
  ): Promise<void> {
49
- const { eventController, store, matched, isPartial, historyKey } = opts;
58
+ const {
59
+ eventController,
60
+ store,
61
+ matched,
62
+ isPartial,
63
+ resolvedIds,
64
+ historyKey,
65
+ } = opts;
50
66
 
51
67
  let yieldCount = 0;
52
68
  for await (const handleData of handlesGenerator) {
@@ -61,7 +77,7 @@ async function processHandles(
61
77
  }
62
78
 
63
79
  yieldCount++;
64
- eventController.setHandleData(handleData, matched, isPartial);
80
+ eventController.setHandleData(handleData, matched, isPartial, resolvedIds);
65
81
  }
66
82
 
67
83
  // Check again before final updates
@@ -69,12 +85,11 @@ async function processHandles(
69
85
  return;
70
86
  }
71
87
 
72
- // For partial updates where the generator yielded nothing (cached handlers),
73
- // we still need to update the segment order to clean up stale handle data.
74
- // This happens when navigating away from a route - the handlers for the new
75
- // route might not push any breadcrumbs, but we still need to remove the old ones.
88
+ // For partial updates where the generator yielded nothing (every
89
+ // re-resolved handler pushed nothing), still call setHandleData so the
90
+ // cleanup pass can clear out stale buckets for those segments.
76
91
  if (yieldCount === 0 && matched) {
77
- eventController.setHandleData({}, matched, true);
92
+ eventController.setHandleData({}, matched, true, resolvedIds);
78
93
  }
79
94
 
80
95
  // After handles processing completes, update the cache's handleData.
@@ -130,10 +145,23 @@ export interface NavigationProviderProps {
130
145
  warmupEnabled?: boolean;
131
146
 
132
147
  /**
133
- * App version from server payload (stable, immutable).
134
- * Forwarded to prefetch requests for version mismatch detection.
148
+ * App version from server payload.
149
+ * Used only as a fallback when `appShellRef` is not supplied.
135
150
  */
136
151
  version?: string;
152
+
153
+ /**
154
+ * URL prefix for all routes (from createRouter({ basename })).
155
+ * Used only as a fallback when `appShellRef` is not supplied.
156
+ */
157
+ basename?: string;
158
+
159
+ /**
160
+ * Live app-shell ref. When provided, the context's `basename` and `version`
161
+ * properties become live getters that track app-switch updates without
162
+ * invalidating the memoized context value.
163
+ */
164
+ appShellRef?: AppShellRef;
137
165
  }
138
166
 
139
167
  /**
@@ -166,6 +194,8 @@ export function NavigationProvider({
166
194
  initialTheme,
167
195
  warmupEnabled,
168
196
  version,
197
+ basename,
198
+ appShellRef,
169
199
  }: NavigationProviderProps): ReactNode {
170
200
  // Track current payload for rendering (this triggers re-renders)
171
201
  const [payload, setPayload] = useState(initialPayload);
@@ -187,17 +217,34 @@ export function NavigationProvider({
187
217
  await bridge.refresh();
188
218
  }, []);
189
219
 
190
- // Context value is stable (store, eventController, navigate, refresh never change)
191
- const contextValue = useMemo<NavigationStoreContextValue>(
192
- () => ({
220
+ // basename/version are always read through a shell ref so the context value
221
+ // has a single shape: a supplied appShellRef stays live (app-switch updates
222
+ // it), the standalone fallback is a frozen ref over the mount-time props.
223
+ const fallbackShellRef = useRef<AppShellRef | null>(null);
224
+ if (!fallbackShellRef.current) {
225
+ fallbackShellRef.current = createAppShellRef({ basename, version });
226
+ }
227
+ const shellRef = appShellRef ?? fallbackShellRef.current;
228
+
229
+ const contextValue = useMemo<NavigationStoreContextValue>(() => {
230
+ const value = {
193
231
  store,
194
232
  eventController,
195
233
  navigate,
196
234
  refresh,
197
- version,
198
- }),
199
- [],
200
- );
235
+ } as NavigationStoreContextValue;
236
+ Object.defineProperty(value, "basename", {
237
+ configurable: true,
238
+ enumerable: true,
239
+ get: () => shellRef.get().basename,
240
+ });
241
+ Object.defineProperty(value, "version", {
242
+ configurable: true,
243
+ enumerable: true,
244
+ get: () => shellRef.get().version,
245
+ });
246
+ return value;
247
+ }, []);
201
248
 
202
249
  // Connection warmup: keep TLS alive after idle periods.
203
250
  // After 60s of no user interaction, marks connection as "cold".
@@ -286,31 +333,61 @@ export function NavigationProvider({
286
333
  };
287
334
  }, [warmupEnabled]);
288
335
 
289
- // Cancel speculative prefetches when navigation starts.
290
- // Viewport/render prefetches should not compete with navigation fetches.
336
+ // Cancel non-matching prefetches when navigation starts.
337
+ // Frees connections so the navigation fetch isn't competing with
338
+ // speculative prefetches. The prefetch matching the navigation target
339
+ // is kept alive so it can be reused via consumeInflightPrefetch.
291
340
  useEffect(() => {
292
341
  let wasIdle = true;
293
342
  const unsub = eventController.subscribe(() => {
294
343
  const state = eventController.getState();
295
344
  const isIdle = state.state === "idle" && !state.isStreaming;
296
345
  if (wasIdle && !isIdle) {
297
- cancelAllPrefetches();
346
+ cancelAllPrefetches(state.pendingUrl);
298
347
  }
299
348
  wasIdle = isIdle;
300
349
  });
301
350
  return unsub;
302
351
  }, [eventController]);
303
352
 
353
+ // Pending scroll action to apply after React commits
354
+ const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
355
+
356
+ // Apply scroll after React commits the new content to the DOM
357
+ useLayoutEffect(() => {
358
+ const scrollAction = pendingScrollRef.current;
359
+ if (!scrollAction) return;
360
+ pendingScrollRef.current = undefined;
361
+
362
+ if (scrollAction.enabled === false) return;
363
+
364
+ handleNavigationEnd({
365
+ restore: scrollAction.restore,
366
+ scroll: scrollAction.enabled,
367
+ isStreaming: scrollAction.isStreaming,
368
+ });
369
+ });
370
+
304
371
  // Subscribe to UI updates (for re-rendering the tree)
305
372
  useEffect(() => {
306
373
  const unsubscribe = store.onUpdate((update) => {
374
+ // Capture scroll intent — it will be applied in useLayoutEffect
375
+ // after React commits this state update to the DOM.
376
+ // Always assign (even undefined) to clear stale scroll from prior navigations,
377
+ // so server actions or error updates don't accidentally replay old scroll.
378
+ pendingScrollRef.current = update.scroll;
379
+
307
380
  setPayload({
308
381
  root: update.root,
309
382
  metadata: update.metadata,
310
383
  });
311
384
 
312
- // Update route params
313
- eventController.setParams(update.metadata.params ?? {});
385
+ // Update route params. Only reset when the server actually sends a params
386
+ // map — an absent `params` field means "no change" (e.g., legacy action
387
+ // responses that omitted params). Explicit `{}` still clears correctly.
388
+ if (update.metadata.params !== undefined) {
389
+ eventController.setParams(update.metadata.params);
390
+ }
314
391
 
315
392
  // Update handle data progressively as it streams in
316
393
  if (update.metadata.handles) {
@@ -323,24 +400,20 @@ export function NavigationProvider({
323
400
  store,
324
401
  matched: update.metadata.matched,
325
402
  isPartial: update.metadata.isPartial,
403
+ resolvedIds: update.metadata.resolvedIds,
326
404
  historyKey,
327
405
  }).catch((err) =>
328
406
  console.error("[NavigationProvider] Error consuming handles:", err),
329
407
  );
330
- } else if (update.metadata.cachedHandleData) {
331
- // For back/forward navigation from cache, restore the cached handleData
332
- // This restores breadcrumbs to the exact state they were when the page was cached
333
- eventController.setHandleData(
334
- update.metadata.cachedHandleData,
335
- update.metadata.matched,
336
- false, // full replace - restore entire cached state
337
- );
338
408
  } else if (update.metadata.matched) {
339
- // For cached navigations without handleData, update segmentOrder to clean up stale data
409
+ // cachedHandleData present -> full restore (back/forward); absent ->
410
+ // partial cleanup of segments no longer matched.
411
+ const cached = update.metadata.cachedHandleData;
340
412
  eventController.setHandleData(
341
- {}, // Empty data - all existing data not in matched will be cleaned up
413
+ cached ?? {},
342
414
  update.metadata.matched,
343
- true, // partial update - will clean up segments not in matched
415
+ cached === undefined,
416
+ cached === undefined ? update.metadata.resolvedIds : undefined,
344
417
  );
345
418
  }
346
419
  });
@@ -362,7 +435,11 @@ export function NavigationProvider({
362
435
  // Build the content tree
363
436
  let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
364
437
 
365
- // Wrap with ThemeProvider when theme is enabled
438
+ // Wrap with ThemeProvider when theme is enabled. The ThemeProvider is
439
+ // document-lifetime: its config comes from the initial load and does NOT
440
+ // swap on cross-app transitions, because the ThemeProvider sits above the
441
+ // segment tree and a smooth (no-reload) app switch cannot safely remount
442
+ // it. A new theme config only takes effect on a full document load.
366
443
  if (themeConfig) {
367
444
  content = (
368
445
  <ThemeProvider config={themeConfig} initialTheme={initialTheme}>
@@ -43,10 +43,15 @@ export interface NavigationStoreContextValue {
43
43
  refresh: () => Promise<void>;
44
44
 
45
45
  /**
46
- * App version from server payload (stable, immutable).
47
- * Used in prefetch requests for version mismatch detection.
46
+ * App version from the initial server payload.
48
47
  */
49
48
  version: string | undefined;
49
+
50
+ /**
51
+ * URL prefix for all routes (from createRouter({ basename })).
52
+ * Used by Link and useRouter() to auto-prefix app-local paths.
53
+ */
54
+ basename: string | undefined;
50
55
  }
51
56
 
52
57
  /**
@@ -1,11 +1,55 @@
1
1
  /**
2
- * Filter segment IDs to only include routes and layouts.
3
- * Excludes parallels (contain .@) and loaders (contain D followed by digit).
2
+ * Build the handle-collection segment order from a raw `matched` list.
3
+ *
4
+ * Two responsibilities:
5
+ *
6
+ * 1. Drop loader sub-ids ("D" followed by a digit, e.g. "M0L0D1.user") —
7
+ * loaders never push handles.
8
+ *
9
+ * 2. Place each parallel slot id (contains ".@") immediately after its
10
+ * parent layout/route id. Raw segment-resolution emission order does NOT
11
+ * guarantee this: route-mounted parallels are resolved/pushed BEFORE the
12
+ * route handler's segment is appended (see fresh.ts:resolveSegment for
13
+ * routes, and revalidation.ts ~915-919), so matched can read
14
+ * `[..., R0.@panel, R0]`. collectHandleData consumes segmentOrder verbatim
15
+ * with later-wins semantics, so without normalization the route handler's
16
+ * Meta would override the slot's more-specific Meta — backwards.
17
+ *
18
+ * Slot-id format is `<parentShortCode>.@<slotName>`; `parentShortCode` never
19
+ * contains ".@", so splitting at the first ".@" reliably yields the parent.
4
20
  */
5
21
  export function filterSegmentOrder(matched: string[]): string[] {
6
- return matched.filter((id) => {
7
- if (id.includes(".@")) return false;
8
- if (/D\d+\./.test(id)) return false;
9
- return true;
10
- });
22
+ const slotsByParent = new Map<string, string[]>();
23
+ const nonSlots: string[] = [];
24
+ const nonSlotSet = new Set<string>();
25
+
26
+ for (const id of matched) {
27
+ if (/D\d+\./.test(id)) continue;
28
+ const slotIdx = id.indexOf(".@");
29
+ if (slotIdx >= 0) {
30
+ const parent = id.slice(0, slotIdx);
31
+ const list = slotsByParent.get(parent);
32
+ if (list) {
33
+ list.push(id);
34
+ } else {
35
+ slotsByParent.set(parent, [id]);
36
+ }
37
+ } else {
38
+ nonSlots.push(id);
39
+ nonSlotSet.add(id);
40
+ }
41
+ }
42
+
43
+ const result: string[] = [];
44
+ for (const id of nonSlots) {
45
+ result.push(id);
46
+ const slots = slotsByParent.get(id);
47
+ if (slots) result.push(...slots);
48
+ }
49
+ // Defensive: any slot whose parent is missing from the filtered list still
50
+ // gets included rather than silently dropped. Shouldn't happen in practice.
51
+ for (const [parent, slots] of slotsByParent) {
52
+ if (!nonSlotSet.has(parent)) result.push(...slots);
53
+ }
54
+ return result;
11
55
  }
@@ -20,6 +20,9 @@ export { useSegments, type SegmentsState } from "./use-segments.js";
20
20
  // Handle data hook
21
21
  export { useHandle } from "./use-handle.js";
22
22
 
23
+ // Mount-aware reverse hook
24
+ export { useReverse } from "./use-reverse.js";
25
+
23
26
  // Client cache controls hook
24
27
  export {
25
28
  useClientCache,