@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
@@ -15,9 +15,15 @@ import { getRangoState } from "./rango-state.js";
15
15
  import {
16
16
  extractRscHeaderUrl,
17
17
  emptyResponse,
18
+ handleReloadHeader,
18
19
  teeWithCompletion,
19
20
  } from "./response-adapter.js";
20
- import { buildPrefetchKey, consumePrefetch } from "./prefetch/cache.js";
21
+ import {
22
+ buildPrefetchKey,
23
+ buildSourceKey,
24
+ consumeInflightPrefetch,
25
+ consumePrefetch,
26
+ } from "./prefetch/cache.js";
21
27
 
22
28
  /**
23
29
  * Create a navigation client for fetching RSC payloads
@@ -26,8 +32,10 @@ import { buildPrefetchKey, consumePrefetch } from "./prefetch/cache.js";
26
32
  * deserializing the response using the RSC runtime.
27
33
  *
28
34
  * Checks the in-memory prefetch cache before making a network request.
29
- * The cache key is source-dependent (includes the previous URL) so
30
- * prefetch responses match the exact diff the server would produce.
35
+ * Tries the source-scoped key first (populated when the server tagged
36
+ * the response as source-sensitive via `X-RSC-Prefetch-Scope: source`)
37
+ * and falls back to the Rango-state-keyed wildcard slot used for the
38
+ * common source-agnostic case.
31
39
  *
32
40
  * @param deps - RSC browser dependencies (createFromFetch)
33
41
  * @returns NavigationClient instance
@@ -57,6 +65,7 @@ export function createNavigationClient(
57
65
  staleRevalidation,
58
66
  interceptSourceUrl,
59
67
  version,
68
+ routerId,
60
69
  hmr,
61
70
  } = options;
62
71
 
@@ -84,50 +93,105 @@ export function createNavigationClient(
84
93
  if (version) {
85
94
  fetchUrl.searchParams.set("_rsc_v", version);
86
95
  }
96
+ if (routerId) {
97
+ fetchUrl.searchParams.set("_rsc_rid", routerId);
98
+ }
87
99
 
88
- // Check in-memory prefetch cache before making a network request.
89
- // The cache key includes the source URL (previousUrl) because the
90
- // server's diff response depends on the source page context.
100
+ // Check completed in-memory prefetch cache before making a network
101
+ // request. Try the source-scoped key first (populated when the server
102
+ // tagged the prefetch response as source-sensitive, e.g. intercepts,
103
+ // or when a Link opted in with `prefetchKey=":source"`), then fall
104
+ // back to the wildcard slot shared across source pages.
105
+ // Both keys embed the Rango state, so state rotation (deploy or
106
+ // server-action invalidation) auto-invalidates both scopes.
91
107
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
92
108
  // fresh modules), and intercept contexts (source-dependent responses).
93
- const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
94
- const cachedResponse =
95
- !staleRevalidation && !hmr && !interceptSourceUrl
96
- ? consumePrefetch(cacheKey)
97
- : null;
109
+ const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
110
+ const rangoState = getRangoState();
111
+ const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
112
+ const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
113
+
114
+ let cachedResponse: Response | null = null;
115
+ let hitKey: string | null = null;
116
+ if (canUsePrefetch) {
117
+ cachedResponse = consumePrefetch(cacheKey);
118
+ if (cachedResponse) {
119
+ hitKey = cacheKey;
120
+ } else {
121
+ cachedResponse = consumePrefetch(wildcardKey);
122
+ if (cachedResponse) hitKey = wildcardKey;
123
+ }
124
+ }
98
125
 
126
+ let inflightResponsePromise: Promise<Response | null> | null = null;
127
+ if (canUsePrefetch && !cachedResponse) {
128
+ inflightResponsePromise = consumeInflightPrefetch(cacheKey);
129
+ if (inflightResponsePromise) {
130
+ hitKey = cacheKey;
131
+ } else {
132
+ inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
133
+ if (inflightResponsePromise) hitKey = wildcardKey;
134
+ }
135
+ }
99
136
  // Track when the stream completes
100
137
  let resolveStreamComplete: () => void;
101
138
  const streamComplete = new Promise<void>((resolve) => {
102
139
  resolveStreamComplete = resolve;
103
140
  });
104
141
 
105
- let responsePromise: Promise<Response>;
142
+ /**
143
+ * Validate RSC control headers on any response (fresh, cached, or
144
+ * in-flight). Handles version-mismatch reloads and server redirects.
145
+ * Returns the response unchanged when no control header is present.
146
+ */
147
+ const validateRscHeaders = (
148
+ response: Response,
149
+ source: string,
150
+ ): Response | Promise<Response> => {
151
+ // Version mismatch — server wants a full page reload
152
+ const reloadResult = handleReloadHeader(response, {
153
+ onBlocked: resolveStreamComplete,
154
+ onReload: (url) => {
155
+ if (tx) {
156
+ browserDebugLog(tx, `version mismatch, reloading (${source})`, {
157
+ reloadUrl: url,
158
+ });
159
+ }
160
+ },
161
+ });
162
+ if (reloadResult) return reloadResult;
106
163
 
107
- if (cachedResponse) {
108
- if (tx) {
109
- browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
164
+ // Server-side redirect without state: the server returned 204 with
165
+ // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
166
+ // to a URL rendering full HTML). Throw ServerRedirect so the
167
+ // navigation bridge catches it and re-navigates with _skipCache.
168
+ const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
169
+ if (redirect === "blocked") {
170
+ resolveStreamComplete();
171
+ return emptyResponse();
110
172
  }
111
- // Cached response body is already fully buffered (arrayBuffer),
112
- // so stream completion is immediate.
113
- responsePromise = Promise.resolve(cachedResponse).then((response) => {
114
- return teeWithCompletion(
115
- response,
116
- () => {
117
- if (tx) browserDebugLog(tx, "stream complete (from cache)");
118
- resolveStreamComplete();
119
- },
120
- signal,
121
- );
122
- });
123
- } else {
173
+ if (redirect) {
174
+ if (tx) {
175
+ browserDebugLog(tx, `server redirect (${source})`, {
176
+ redirectUrl: redirect.url,
177
+ });
178
+ }
179
+ resolveStreamComplete();
180
+ throw new ServerRedirect(redirect.url, undefined);
181
+ }
182
+
183
+ return response;
184
+ };
185
+
186
+ /** Start a fresh navigation fetch (no cache / inflight hit). */
187
+ const doFreshFetch = (): Promise<Response> => {
124
188
  if (tx) {
125
189
  browserDebugLog(tx, "fetching", {
126
190
  path: `${fetchUrl.pathname}${fetchUrl.search}`,
127
191
  });
128
192
  }
129
193
 
130
- responsePromise = fetch(fetchUrl, {
194
+ return fetch(fetchUrl, {
131
195
  headers: {
132
196
  "X-RSC-Router-Client-Path": previousUrl,
133
197
  "X-Rango-State": getRangoState(),
@@ -139,55 +203,96 @@ export function createNavigationClient(
139
203
  },
140
204
  signal,
141
205
  }).then((response) => {
142
- // Check for version mismatch - server wants us to reload
143
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
144
- if (reload === "blocked") {
145
- resolveStreamComplete();
146
- return emptyResponse();
147
- }
148
- if (reload) {
206
+ const validated = validateRscHeaders(response, "fetch");
207
+ if (validated instanceof Promise) return validated;
208
+
209
+ return teeWithCompletion(
210
+ validated,
211
+ () => {
212
+ if (tx) browserDebugLog(tx, "stream complete");
213
+ resolveStreamComplete();
214
+ },
215
+ signal,
216
+ );
217
+ });
218
+ };
219
+
220
+ let responsePromise: Promise<Response>;
221
+
222
+ if (cachedResponse) {
223
+ if (tx) {
224
+ browserDebugLog(tx, "prefetch cache hit", {
225
+ key: hitKey,
226
+ wildcard: hitKey === wildcardKey,
227
+ });
228
+ }
229
+ responsePromise = Promise.resolve(cachedResponse).then((response) => {
230
+ const validated = validateRscHeaders(response, "prefetch cache");
231
+ if (validated instanceof Promise) return validated;
232
+
233
+ return teeWithCompletion(
234
+ validated,
235
+ () => {
236
+ if (tx) browserDebugLog(tx, "stream complete (from cache)");
237
+ resolveStreamComplete();
238
+ },
239
+ signal,
240
+ );
241
+ });
242
+ } else if (inflightResponsePromise) {
243
+ if (tx) {
244
+ browserDebugLog(tx, "reusing inflight prefetch", {
245
+ key: hitKey,
246
+ wildcard: hitKey === wildcardKey,
247
+ });
248
+ }
249
+ const adoptedViaWildcard = hitKey === wildcardKey;
250
+ responsePromise = inflightResponsePromise.then(async (response) => {
251
+ if (!response) {
149
252
  if (tx) {
150
- browserDebugLog(tx, "version mismatch, reloading", {
151
- reloadUrl: reload.url,
152
- });
253
+ browserDebugLog(tx, "inflight prefetch unavailable, refetching");
153
254
  }
154
- window.location.href = reload.url;
155
- return new Promise<Response>(() => {});
255
+ return doFreshFetch();
156
256
  }
157
257
 
158
- // Server-side redirect without state: the server returned 204 with
159
- // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
160
- // to a URL rendering full HTML). Throw ServerRedirect so the
161
- // navigation bridge catches it and re-navigates with _skipCache.
162
- const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
163
- if (redirect === "blocked") {
164
- resolveStreamComplete();
165
- return emptyResponse();
166
- }
167
- if (redirect) {
258
+ // Cross-source safety: an inflight promise adopted via the
259
+ // wildcard key may turn out to be source-scoped (server emitted
260
+ // `X-RSC-Prefetch-Scope: source`), which means it was built for
261
+ // a different source page. Discard and refetch.
262
+ if (
263
+ adoptedViaWildcard &&
264
+ response.headers.get("x-rsc-prefetch-scope") === "source"
265
+ ) {
168
266
  if (tx) {
169
- browserDebugLog(tx, "server redirect", {
170
- redirectUrl: redirect.url,
171
- });
267
+ browserDebugLog(
268
+ tx,
269
+ "wildcard inflight turned out source-scoped, refetching",
270
+ );
172
271
  }
173
- resolveStreamComplete();
174
- throw new ServerRedirect(redirect.url, undefined);
272
+ return doFreshFetch();
175
273
  }
176
274
 
275
+ const validated = validateRscHeaders(response, "inflight prefetch");
276
+ if (validated instanceof Promise) return validated;
277
+
177
278
  return teeWithCompletion(
178
- response,
279
+ validated,
179
280
  () => {
180
- if (tx) browserDebugLog(tx, "stream complete");
281
+ if (tx) {
282
+ browserDebugLog(tx, "stream complete (from inflight prefetch)");
283
+ }
181
284
  resolveStreamComplete();
182
285
  },
183
286
  signal,
184
287
  );
185
288
  });
289
+ } else {
290
+ responsePromise = doFreshFetch();
186
291
  }
187
292
 
188
293
  try {
189
- // Deserialize RSC payload
190
294
  const payload = await deps.createFromFetch<RscPayload>(responsePromise);
295
+
191
296
  if (tx) {
192
297
  browserDebugLog(tx, "response received", {
193
298
  isPartial: payload.metadata?.isPartial,
@@ -12,7 +12,10 @@ import type {
12
12
  ActionStateListener,
13
13
  HandleData,
14
14
  } from "./types.js";
15
- import { clearPrefetchCache } from "./prefetch/cache.js";
15
+ import {
16
+ clearPrefetchCache,
17
+ clearPrefetchCacheLocal,
18
+ } from "./prefetch/cache.js";
16
19
 
17
20
  /**
18
21
  * Default action state (idle with no payload)
@@ -28,9 +31,15 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
28
31
  // Maximum number of history entries to cache (URLs visited)
29
32
  const HISTORY_CACHE_SIZE = 20;
30
33
 
31
- // Cache entry: [url-key, segments, stale, handleData?]
34
+ // Cache entry: [url-key, segments, stale, handleData?, routerId?]
32
35
  // stale=true means the data may be outdated and should be revalidated on access
33
- type HistoryCacheEntry = [string, ResolvedSegment[], boolean, HandleData?];
36
+ type HistoryCacheEntry = [
37
+ string,
38
+ ResolvedSegment[],
39
+ boolean,
40
+ HandleData?,
41
+ string?,
42
+ ];
34
43
 
35
44
  /**
36
45
  * Shallow clone handleData to avoid reference sharing between cache entries.
@@ -258,6 +267,11 @@ export function createNavigationStore(
258
267
  // Used to maintain intercept context during action revalidation
259
268
  let interceptSourceUrl: string | null = null;
260
269
 
270
+ // Router identity - tracks which router is currently active.
271
+ // When this changes on a partial response, the client forces a full
272
+ // tree replacement instead of reconciling with stale segments.
273
+ let currentRouterId: string | undefined;
274
+
261
275
  // Action state tracking (for useAction hook)
262
276
  // Maps action function ID to its tracked state
263
277
  const actionStates = new Map<string, TrackedActionState>();
@@ -269,18 +283,17 @@ export function createNavigationStore(
269
283
  /**
270
284
  * Create a debounced function that batches rapid calls
271
285
  */
286
+ // A non-keyed notifier is the keyed one restricted to a single constant key;
287
+ // its own keyed instance means the "" key never collides with action keys.
272
288
  function createDebouncedNotifier<T extends (...args: any[]) => void>(
273
289
  fn: T,
274
290
  ms: number = 20,
275
291
  ): T {
276
- let timeout: ReturnType<typeof setTimeout> | null = null;
277
- return ((...args: Parameters<T>) => {
278
- if (timeout !== null) clearTimeout(timeout);
279
- timeout = setTimeout(() => {
280
- timeout = null;
281
- fn(...args);
282
- }, ms);
283
- }) as T;
292
+ const keyed = createKeyedDebouncedNotifier(
293
+ (_key: string, ...args: any[]) => fn(...args),
294
+ ms,
295
+ );
296
+ return ((...args: Parameters<T>) => keyed("", ...args)) as T;
284
297
  }
285
298
 
286
299
  /**
@@ -324,6 +337,18 @@ export function createNavigationStore(
324
337
  clearPrefetchCache();
325
338
  }
326
339
 
340
+ /**
341
+ * Drop this tab's navigation + prefetch caches without broadcasting or
342
+ * rotating shared state. Used when the local session changes in a way that
343
+ * doesn't affect other tabs — e.g. this tab crosses into a different app
344
+ * via a cross-router navigation. Other tabs in the old app keep their
345
+ * caches and their X-Rango-State token.
346
+ */
347
+ function clearCacheInternalLocal(): void {
348
+ historyCache.length = 0;
349
+ clearPrefetchCacheLocal();
350
+ }
351
+
327
352
  /**
328
353
  * Mark all cache entries as stale (internal - does not broadcast)
329
354
  */
@@ -571,10 +596,17 @@ export function createNavigationStore(
571
596
  segments,
572
597
  false,
573
598
  clonedHandleData,
599
+ currentRouterId,
574
600
  ];
575
601
  } else {
576
602
  // Add new entry at the end (not stale)
577
- historyCache.push([historyKey, segments, false, clonedHandleData]);
603
+ historyCache.push([
604
+ historyKey,
605
+ segments,
606
+ false,
607
+ clonedHandleData,
608
+ currentRouterId,
609
+ ]);
578
610
  // Remove oldest entries if over limit
579
611
  while (historyCache.length > cacheSize) {
580
612
  historyCache.shift();
@@ -586,14 +618,22 @@ export function createNavigationStore(
586
618
  * Get cached segments for a history entry
587
619
  * Returns { segments, stale, handleData } or undefined if not cached
588
620
  */
589
- getCachedSegments(
590
- historyKey: string,
591
- ):
592
- | { segments: ResolvedSegment[]; stale: boolean; handleData?: HandleData }
621
+ getCachedSegments(historyKey: string):
622
+ | {
623
+ segments: ResolvedSegment[];
624
+ stale: boolean;
625
+ handleData?: HandleData;
626
+ routerId?: string;
627
+ }
593
628
  | undefined {
594
629
  const entry = historyCache.find(([key]) => key === historyKey);
595
630
  if (!entry) return undefined;
596
- return { segments: entry[1], stale: entry[2], handleData: entry[3] };
631
+ return {
632
+ segments: entry[1],
633
+ stale: entry[2],
634
+ handleData: entry[3],
635
+ routerId: entry[4],
636
+ };
597
637
  },
598
638
 
599
639
  /**
@@ -621,6 +661,7 @@ export function createNavigationStore(
621
661
  entry[1],
622
662
  entry[2],
623
663
  clonedHandleData,
664
+ entry[4], // preserve routerId
624
665
  ];
625
666
  }
626
667
  },
@@ -641,6 +682,15 @@ export function createNavigationStore(
641
682
  clearCacheAndBroadcast();
642
683
  },
643
684
 
685
+ /**
686
+ * Drop this tab's navigation + prefetch caches locally without
687
+ * broadcasting or rotating shared state. Intended for cross-app
688
+ * transitions where the session state diverges for this tab only.
689
+ */
690
+ clearHistoryCacheLocal(): void {
691
+ clearCacheInternalLocal();
692
+ },
693
+
644
694
  /**
645
695
  * Mark cache as stale and broadcast to other tabs
646
696
  * Called after server actions - allows SWR pattern for popstate
@@ -687,6 +737,14 @@ export function createNavigationStore(
687
737
  interceptSourceUrl = url;
688
738
  },
689
739
 
740
+ getRouterId(): string | undefined {
741
+ return currentRouterId;
742
+ },
743
+
744
+ setRouterId(id: string): void {
745
+ currentRouterId = id;
746
+ },
747
+
690
748
  // ========================================================================
691
749
  // UI Update Notifications
692
750
  // ========================================================================
@@ -7,12 +7,11 @@ import type {
7
7
  import { generateHistoryKey } from "./navigation-store.js";
8
8
  import {
9
9
  handleNavigationStart,
10
- handleNavigationEnd,
11
10
  ensureHistoryKey,
12
11
  } from "./scroll-restoration.js";
13
12
  import type { EventController, NavigationHandle } from "./event-controller.js";
14
13
  import { debugLog } from "./logging.js";
15
- import { buildHistoryState } from "./history-state.js";
14
+ import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
16
15
 
17
16
  // Re-export for consumers that import from navigation-transaction
18
17
  export { resolveNavigationState } from "./history-state.js";
@@ -81,11 +80,12 @@ export interface BoundTransaction {
81
80
  readonly currentUrl: string;
82
81
  /** Start streaming and get a token to end it when the stream completes */
83
82
  startStreaming(): StreamingToken;
83
+ /** Commit the navigation. Returns the effective scroll option for the caller to handle. */
84
84
  commit(
85
85
  segmentIds: string[],
86
86
  segments: ResolvedSegment[],
87
87
  overrides?: BoundCommitOverrides,
88
- ): void;
88
+ ): { scroll?: boolean };
89
89
  }
90
90
 
91
91
  /**
@@ -93,7 +93,7 @@ export interface BoundTransaction {
93
93
  * Uses the event controller handle for lifecycle management
94
94
  */
95
95
  interface NavigationTransaction extends Disposable {
96
- commit(options: CommitOptions): void;
96
+ commit(options: CommitOptions): { scroll?: boolean };
97
97
  with(
98
98
  options: Omit<CommitOptions, "segmentIds" | "segments">,
99
99
  ): BoundTransaction;
@@ -120,7 +120,7 @@ export function createNavigationTransaction(
120
120
  /**
121
121
  * Commit the navigation - updates store and URL atomically
122
122
  */
123
- function commit(opts: CommitOptions): void {
123
+ function commit(opts: CommitOptions): { scroll?: boolean } {
124
124
  committed = true;
125
125
 
126
126
  const {
@@ -150,7 +150,7 @@ export function createNavigationTransaction(
150
150
  // Without this, the entry lingers and weakens state-machine invariants.
151
151
  handle.complete(parsedUrl);
152
152
  debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
153
- return;
153
+ return { scroll: false };
154
154
  }
155
155
 
156
156
  // Save current scroll position before navigating
@@ -172,7 +172,7 @@ export function createNavigationTransaction(
172
172
  debugLog("[Browser] Store updated (action)");
173
173
  // Complete navigation to clear loading state
174
174
  handle.complete(parsedUrl);
175
- return;
175
+ return { scroll: false };
176
176
  }
177
177
 
178
178
  // Build history state - include user state, intercept info, and server-set state
@@ -186,12 +186,8 @@ export function createNavigationTransaction(
186
186
  // Used to detect when location state is being cleared.
187
187
  const oldState = window.history.state;
188
188
 
189
- // Update browser URL
190
- if (replace) {
191
- window.history.replaceState(historyState, "", url);
192
- } else {
193
- window.history.pushState(historyState, "", url);
194
- }
189
+ // Update browser URL (stamps history.state.idx for back() first-entry detection)
190
+ pushHistoryWithIdx(historyState, url, replace ?? false);
195
191
  // Ensure new history entry has a scroll restoration key
196
192
  ensureHistoryKey();
197
193
 
@@ -205,14 +201,16 @@ export function createNavigationTransaction(
205
201
  // Complete the navigation in event controller (sets idle state, updates location)
206
202
  handle.complete(parsedUrl);
207
203
 
208
- // Handle scroll after navigation
209
- handleNavigationEnd({ scroll });
204
+ // NOTE: Scroll is NOT handled here. The caller (partial-update.ts) handles
205
+ // scroll AFTER onUpdate() so React has the new content before we scroll.
210
206
 
211
207
  debugLog(
212
208
  "[Browser] Navigation committed, historyKey:",
213
209
  historyKey,
214
210
  intercept ? "(intercept)" : "",
215
211
  );
212
+
213
+ return { scroll };
216
214
  }
217
215
 
218
216
  return {
@@ -238,32 +236,18 @@ export function createNavigationTransaction(
238
236
  segments: ResolvedSegment[],
239
237
  overrides?: BoundCommitOverrides,
240
238
  ) => {
241
- // Allow overrides to disable scroll (e.g., for intercepts)
242
- const finalScroll =
243
- overrides?.scroll !== undefined ? overrides.scroll : opts.scroll;
244
- // Allow overrides to force replace (e.g., for intercepts)
245
- const finalReplace =
246
- overrides?.replace !== undefined ? overrides.replace : opts.replace;
247
- // Intercept info: overrides take precedence, fallback to opts
248
- const intercept =
249
- overrides?.intercept !== undefined
250
- ? overrides.intercept
251
- : opts.intercept;
239
+ const finalScroll = overrides?.scroll ?? opts.scroll;
240
+ const finalReplace = overrides?.replace ?? opts.replace;
241
+ const intercept = overrides?.intercept ?? opts.intercept;
252
242
  const interceptSourceUrl =
253
- overrides?.interceptSourceUrl !== undefined
254
- ? overrides.interceptSourceUrl
255
- : opts.interceptSourceUrl;
256
- // Cache-only mode: overrides take precedence, fallback to opts
257
- const cacheOnly =
258
- overrides?.cacheOnly !== undefined
259
- ? overrides.cacheOnly
260
- : opts.cacheOnly;
261
- // User state: overrides take precedence, fallback to opts
243
+ overrides?.interceptSourceUrl ?? opts.interceptSourceUrl;
244
+ const cacheOnly = overrides?.cacheOnly ?? opts.cacheOnly;
245
+ // state is `unknown` (null is meaningful) so `??` would wrongly drop a
246
+ // null override; serverState always comes from overrides, never opts.
262
247
  const state =
263
248
  overrides?.state !== undefined ? overrides.state : opts.state;
264
- // Server-set location state: only from overrides (set by partial-update)
265
249
  const serverState = overrides?.serverState;
266
- commit({
250
+ return commit({
267
251
  ...opts,
268
252
  segmentIds,
269
253
  segments,