@rangojs/router 0.0.0-experimental.122 → 0.0.0-experimental.125

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 (260) hide show
  1. package/dist/bin/rango.js +10 -6
  2. package/dist/testing/vitest.js +82 -0
  3. package/dist/vite/index.js +55 -48
  4. package/package.json +61 -21
  5. package/skills/caching/SKILL.md +2 -1
  6. package/skills/hooks/SKILL.md +40 -29
  7. package/skills/host-router/SKILL.md +16 -2
  8. package/skills/intercept/SKILL.md +4 -2
  9. package/skills/layout/SKILL.md +11 -6
  10. package/skills/loader/SKILL.md +6 -2
  11. package/skills/middleware/SKILL.md +4 -2
  12. package/skills/migrate-nextjs/SKILL.md +3 -1
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +12 -0
  15. package/skills/route/SKILL.md +10 -2
  16. package/skills/testing/SKILL.md +129 -0
  17. package/skills/testing/bindings.md +89 -0
  18. package/skills/testing/cache-prerender.md +98 -0
  19. package/skills/testing/client-components.md +122 -0
  20. package/skills/testing/e2e-parity.md +125 -0
  21. package/skills/testing/flight.md +89 -0
  22. package/skills/testing/handles.md +129 -0
  23. package/skills/testing/loader.md +128 -0
  24. package/skills/testing/middleware.md +99 -0
  25. package/skills/testing/render-handler.md +118 -0
  26. package/skills/testing/response-routes.md +95 -0
  27. package/skills/testing/reverse-and-types.md +84 -0
  28. package/skills/testing/server-actions.md +107 -0
  29. package/skills/testing/server-tree.md +128 -0
  30. package/skills/testing/setup.md +120 -0
  31. package/src/__internal.ts +0 -65
  32. package/src/browser/action-coordinator.ts +1 -1
  33. package/src/browser/action-fence.ts +47 -0
  34. package/src/browser/cookie-name.ts +140 -0
  35. package/src/browser/event-controller.ts +1 -83
  36. package/src/browser/invalidate-client-cache.ts +52 -0
  37. package/src/browser/navigation-bridge.ts +14 -1
  38. package/src/browser/navigation-client.ts +14 -1
  39. package/src/browser/navigation-store-handle.ts +38 -0
  40. package/src/browser/navigation-store.ts +26 -51
  41. package/src/browser/navigation-transaction.ts +0 -32
  42. package/src/browser/partial-update.ts +1 -83
  43. package/src/browser/prefetch/cache.ts +6 -45
  44. package/src/browser/prefetch/fetch.ts +7 -0
  45. package/src/browser/prefetch/queue.ts +6 -3
  46. package/src/browser/rango-state.ts +157 -99
  47. package/src/browser/react/Link.tsx +0 -2
  48. package/src/browser/react/NavigationProvider.tsx +2 -1
  49. package/src/browser/react/ScrollRestoration.tsx +10 -6
  50. package/src/browser/react/filter-segment-order.ts +0 -2
  51. package/src/browser/react/index.ts +0 -51
  52. package/src/browser/react/location-state-shared.ts +0 -13
  53. package/src/browser/react/location-state.ts +0 -1
  54. package/src/browser/react/use-action.ts +6 -15
  55. package/src/browser/react/use-handle.ts +0 -5
  56. package/src/browser/react/use-link-status.ts +0 -4
  57. package/src/browser/react/use-navigation.ts +0 -3
  58. package/src/browser/react/use-params.ts +0 -2
  59. package/src/browser/react/use-search-params.ts +0 -5
  60. package/src/browser/react/use-segments.ts +0 -13
  61. package/src/browser/rsc-router.tsx +12 -4
  62. package/src/browser/server-action-bridge.ts +77 -15
  63. package/src/browser/types.ts +7 -2
  64. package/src/browser/validate-redirect-origin.ts +4 -5
  65. package/src/build/route-trie.ts +3 -0
  66. package/src/build/route-types/param-extraction.ts +6 -3
  67. package/src/build/route-types/router-processing.ts +0 -8
  68. package/src/cache/cache-policy.ts +0 -54
  69. package/src/cache/cache-runtime.ts +27 -24
  70. package/src/cache/cache-scope.ts +0 -27
  71. package/src/cache/cache-tag.ts +0 -37
  72. package/src/cache/cf/cf-cache-store.ts +94 -46
  73. package/src/cache/cf/index.ts +0 -24
  74. package/src/cache/document-cache.ts +11 -36
  75. package/src/cache/handle-snapshot.ts +0 -40
  76. package/src/cache/index.ts +0 -27
  77. package/src/cache/memory-segment-store.ts +2 -48
  78. package/src/cache/profile-registry.ts +7 -3
  79. package/src/cache/read-through-swr.ts +41 -11
  80. package/src/cache/segment-codec.ts +0 -16
  81. package/src/cache/types.ts +0 -98
  82. package/src/client.rsc.tsx +1 -22
  83. package/src/client.tsx +14 -38
  84. package/src/component-utils.ts +19 -0
  85. package/src/deps/ssr.ts +0 -1
  86. package/src/handle.ts +28 -18
  87. package/src/handles/MetaTags.tsx +0 -14
  88. package/src/handles/meta.ts +0 -39
  89. package/src/host/cookie-handler.ts +0 -36
  90. package/src/host/errors.ts +0 -24
  91. package/src/host/index.ts +6 -0
  92. package/src/host/pattern-matcher.ts +7 -50
  93. package/src/host/router.ts +1 -65
  94. package/src/host/testing.ts +40 -27
  95. package/src/host/types.ts +6 -2
  96. package/src/href-client.ts +0 -4
  97. package/src/index.rsc.ts +42 -3
  98. package/src/index.ts +31 -1
  99. package/src/internal-debug.ts +2 -4
  100. package/src/loader.rsc.ts +19 -9
  101. package/src/loader.ts +12 -4
  102. package/src/network-error-thrower.tsx +1 -6
  103. package/src/outlet-provider.tsx +1 -5
  104. package/src/prerender/param-hash.ts +10 -11
  105. package/src/prerender/store.ts +23 -30
  106. package/src/prerender.ts +58 -3
  107. package/src/root-error-boundary.tsx +1 -19
  108. package/src/route-content-wrapper.tsx +1 -44
  109. package/src/route-definition/dsl-helpers.ts +7 -19
  110. package/src/route-definition/helpers-types.ts +3 -3
  111. package/src/route-definition/redirect.ts +11 -1
  112. package/src/route-map-builder.ts +0 -16
  113. package/src/router/basename.ts +14 -0
  114. package/src/router/content-negotiation.ts +0 -13
  115. package/src/router/error-handling.ts +12 -16
  116. package/src/router/find-match.ts +4 -30
  117. package/src/router/intercept-resolution.ts +10 -1
  118. package/src/router/lazy-includes.ts +1 -57
  119. package/src/router/loader-resolution.ts +3 -2
  120. package/src/router/logging.ts +0 -6
  121. package/src/router/manifest.ts +1 -25
  122. package/src/router/match-api.ts +0 -20
  123. package/src/router/match-context.ts +0 -22
  124. package/src/router/match-handlers.ts +57 -58
  125. package/src/router/match-middleware/background-revalidation.ts +0 -7
  126. package/src/router/match-middleware/cache-lookup.ts +1 -54
  127. package/src/router/match-middleware/cache-store.ts +0 -31
  128. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  129. package/src/router/match-middleware/segment-resolution.ts +0 -21
  130. package/src/router/match-pipelines.ts +1 -42
  131. package/src/router/match-result.ts +1 -52
  132. package/src/router/metrics.ts +0 -34
  133. package/src/router/middleware-cookies.ts +0 -13
  134. package/src/router/middleware-types.ts +0 -115
  135. package/src/router/middleware.ts +7 -30
  136. package/src/router/navigation-snapshot.ts +0 -51
  137. package/src/router/params-util.ts +23 -0
  138. package/src/router/pattern-matching.ts +1 -33
  139. package/src/router/prerender-match.ts +33 -45
  140. package/src/router/request-classification.ts +1 -38
  141. package/src/router/revalidation.ts +5 -58
  142. package/src/router/router-context.ts +0 -26
  143. package/src/router/router-interfaces.ts +7 -0
  144. package/src/router/router-options.ts +30 -0
  145. package/src/router/segment-resolution/fresh.ts +25 -57
  146. package/src/router/segment-resolution/helpers.ts +34 -0
  147. package/src/router/segment-resolution/loader-cache.ts +10 -13
  148. package/src/router/segment-resolution/revalidation.ts +5 -42
  149. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  150. package/src/router/segment-resolution.ts +4 -1
  151. package/src/router/state-cookie-name.ts +33 -0
  152. package/src/router/telemetry-otel.ts +0 -20
  153. package/src/router/telemetry.ts +96 -19
  154. package/src/router/timeout.ts +0 -20
  155. package/src/router/trie-matching.ts +63 -40
  156. package/src/router/types.ts +1 -63
  157. package/src/router/url-params.ts +0 -5
  158. package/src/router.ts +40 -9
  159. package/src/rsc/handler.ts +14 -2
  160. package/src/rsc/helpers.ts +34 -0
  161. package/src/rsc/origin-guard.ts +0 -12
  162. package/src/rsc/progressive-enhancement.ts +4 -1
  163. package/src/rsc/rsc-rendering.ts +4 -7
  164. package/src/rsc/runtime-warnings.ts +14 -0
  165. package/src/rsc/server-action.ts +30 -28
  166. package/src/rsc/types.ts +2 -1
  167. package/src/runtime-env.ts +18 -0
  168. package/src/search-params.ts +0 -16
  169. package/src/segment-loader-promise.ts +14 -2
  170. package/src/segment-system.tsx +79 -88
  171. package/src/server/cookie-store.ts +52 -1
  172. package/src/server/handle-store.ts +7 -24
  173. package/src/server/loader-registry.ts +5 -24
  174. package/src/server/request-context.ts +74 -77
  175. package/src/ssr/index.tsx +14 -14
  176. package/src/static-handler.ts +10 -13
  177. package/src/testing/cache-status.ts +119 -0
  178. package/src/testing/collect-handle.ts +40 -0
  179. package/src/testing/dispatch.ts +581 -0
  180. package/src/testing/dom.entry.ts +22 -0
  181. package/src/testing/e2e/fixture.ts +188 -0
  182. package/src/testing/e2e/index.ts +127 -0
  183. package/src/testing/e2e/matchers.ts +35 -0
  184. package/src/testing/e2e/page-helpers.ts +272 -0
  185. package/src/testing/e2e/parity.ts +387 -0
  186. package/src/testing/e2e/server.ts +195 -0
  187. package/src/testing/flight-matchers.ts +97 -0
  188. package/src/testing/flight-normalize.ts +11 -0
  189. package/src/testing/flight-runtime.d.ts +57 -0
  190. package/src/testing/flight-tree.ts +682 -0
  191. package/src/testing/flight.entry.ts +52 -0
  192. package/src/testing/flight.ts +186 -0
  193. package/src/testing/generated-routes.ts +183 -0
  194. package/src/testing/index.ts +98 -0
  195. package/src/testing/internal/context.ts +348 -0
  196. package/src/testing/internal/flight-client-globals.ts +30 -0
  197. package/src/testing/internal/seed-vars.ts +54 -0
  198. package/src/testing/render-handler.ts +311 -0
  199. package/src/testing/render-route.tsx +504 -0
  200. package/src/testing/run-loader.ts +378 -0
  201. package/src/testing/run-middleware.ts +205 -0
  202. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  203. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  204. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  205. package/src/testing/vitest-stubs/version.ts +5 -0
  206. package/src/testing/vitest.ts +305 -0
  207. package/src/theme/ThemeProvider.tsx +0 -52
  208. package/src/theme/ThemeScript.tsx +0 -6
  209. package/src/theme/constants.ts +0 -12
  210. package/src/theme/index.ts +0 -7
  211. package/src/theme/theme-context.ts +1 -5
  212. package/src/theme/theme-script.ts +0 -14
  213. package/src/theme/use-theme.ts +0 -3
  214. package/src/types/boundaries.ts +0 -35
  215. package/src/types/error-types.ts +25 -89
  216. package/src/types/global-namespace.ts +15 -15
  217. package/src/types/handler-context.ts +16 -13
  218. package/src/types/index.ts +0 -10
  219. package/src/types/request-scope.ts +0 -19
  220. package/src/types/route-config.ts +6 -50
  221. package/src/types/route-entry.ts +0 -6
  222. package/src/types/segments.ts +0 -13
  223. package/src/urls/include-helper.ts +0 -4
  224. package/src/urls/index.ts +0 -6
  225. package/src/urls/path-helper-types.ts +2 -2
  226. package/src/urls/path-helper.ts +0 -54
  227. package/src/urls/urls-function.ts +0 -13
  228. package/src/use-loader.tsx +0 -186
  229. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  230. package/src/vite/discovery/discover-routers.ts +6 -7
  231. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  232. package/src/vite/plugin-types.ts +3 -1
  233. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  234. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  235. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  236. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  237. package/src/vite/plugins/expose-action-id.ts +2 -73
  238. package/src/vite/plugins/expose-id-utils.ts +0 -55
  239. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  240. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  241. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  242. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  243. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  244. package/src/vite/plugins/performance-tracks.ts +0 -3
  245. package/src/vite/plugins/use-cache-transform.ts +0 -36
  246. package/src/vite/plugins/version-injector.ts +0 -20
  247. package/src/vite/plugins/version-plugin.ts +1 -49
  248. package/src/vite/plugins/virtual-entries.ts +0 -15
  249. package/src/vite/rango.ts +1 -108
  250. package/src/vite/router-discovery.ts +2 -1
  251. package/src/vite/utils/ast-handler-extract.ts +0 -16
  252. package/src/vite/utils/bundle-analysis.ts +6 -13
  253. package/src/vite/utils/client-chunks.ts +0 -6
  254. package/src/vite/utils/forward-user-plugins.ts +0 -22
  255. package/src/vite/utils/manifest-utils.ts +0 -4
  256. package/src/vite/utils/package-resolution.ts +1 -73
  257. package/src/vite/utils/prerender-utils.ts +0 -35
  258. package/src/vite/utils/shared-utils.ts +3 -35
  259. package/src/browser/react/use-client-cache.ts +0 -58
  260. package/src/browser/shallow.ts +0 -40
@@ -22,6 +22,7 @@ import type {
22
22
  import type { EventController } from "./event-controller.js";
23
23
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
24
24
  import { initRangoState } from "./rango-state.js";
25
+ import { registerNavigationStore } from "./navigation-store-handle.js";
25
26
  import { initPrefetchCache } from "./prefetch/cache.js";
26
27
  import { setPrefetchDecoder } from "./prefetch/fetch.js";
27
28
  import { setAppVersion } from "./app-version.js";
@@ -175,6 +176,12 @@ export async function initBrowserApp(
175
176
  ...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
176
177
  });
177
178
 
179
+ // Register the active store on the module-level handle and wire the
180
+ // jar-divergence observer before any getRangoState() read can detect a
181
+ // cross-tab/server rotation. There is no global store singleton, so this
182
+ // handle is the live reference.
183
+ registerNavigationStore(store);
184
+
178
185
  // Seed router identity from the initial SSR payload so the first
179
186
  // cross-app SPA navigation can detect the app switch.
180
187
  if (initialPayload.metadata?.routerId) {
@@ -228,10 +235,11 @@ export async function initBrowserApp(
228
235
  version,
229
236
  });
230
237
 
231
- // Initialize the localStorage state key for cache invalidation.
232
- // The build version busts cached prefetches on deploy; the routerId
233
- // namespaces the key so sibling apps on the same origin don't collide.
234
- initRangoState(version ?? "0", initialPayload.metadata?.routerId);
238
+ // Initialize the rango state cookie for cache invalidation. The build version
239
+ // busts cached prefetches on deploy; the server-resolved cookie name
240
+ // namespaces the cookie so sibling apps on the same origin don't collide
241
+ // (falls back to the bare default prefix if metadata lacks the name).
242
+ initRangoState(version ?? "0", initialPayload.metadata?.stateCookieName);
235
243
  setAppVersion(version);
236
244
 
237
245
  // Initialize the in-memory prefetch cache TTL from server config.
@@ -4,6 +4,8 @@ import type {
4
4
  RscPayload,
5
5
  } from "./types.js";
6
6
  import { createPartialUpdater } from "./partial-update.js";
7
+ import { enterActionFence, exitActionFence } from "./action-fence.js";
8
+ import { KEEP_CACHE_HEADER } from "./cookie-name.js";
7
9
  import { createNavigationTransaction } from "./navigation-transaction.js";
8
10
  import {
9
11
  reconcileSegments,
@@ -156,12 +158,40 @@ export function createServerActionBridge(
156
158
 
157
159
  // Start action in event controller - handles lifecycle tracking
158
160
  const handle = eventController.startAction(id, args);
161
+ // Whether the action's response carried the keepClientCache() directive.
162
+ // Set when the response arrives; gates the deferred invalidation below.
163
+ let keepCache = false;
164
+ // Single deferred invalidation + fence release, run exactly ONCE however the
165
+ // action terminates (normal, redirect, error, abort, intercept, concurrent).
166
+ // This replaces main's eager clear at action start: every directive-free
167
+ // action invalidates once; keepClientCache() suppresses only the automatic
168
+ // invalidation, so a concurrent directive-free action still invalidates via
169
+ // its own latch. Latched so the finally AND the early SPA-redirect returns
170
+ // (whose Flight stream never settles) can both call it safely.
171
+ let actionFinalized = false;
172
+ // skipInvalidation: the version-mismatch reload terminal released nothing
173
+ // server-side, so it releases the fence without invalidating.
174
+ const finalizeAction = (skipInvalidation = false): void => {
175
+ if (actionFinalized) return;
176
+ actionFinalized = true;
177
+ // finally so a throw in invalidation cannot leak the fence (latch is set).
178
+ try {
179
+ if (!keepCache && !skipInvalidation) {
180
+ store.markCacheAsStaleAndBroadcast();
181
+ }
182
+ } finally {
183
+ exitActionFence();
184
+ }
185
+ };
159
186
  try {
160
187
  const segmentState = store.getSegmentState();
161
188
 
162
- // Mark cache as stale immediately when action starts
163
- // This ensures SWR pattern kicks in if user navigates away during action
164
- store.markCacheAsStaleAndBroadcast();
189
+ // Raise the action fence (replaces the old eager clear). Nothing is wiped,
190
+ // rotated, or broadcast yet: navigations during the flight fetch fresh
191
+ // (no-store) and popstate is treated as SWR, but the decision to
192
+ // invalidate is deferred to the response so a no-op action (keepClientCache)
193
+ // can leave the caches and the jar untouched.
194
+ enterActionFence();
165
195
 
166
196
  // Create temporary references for serialization
167
197
  const temporaryReferences = deps.createTemporaryReferenceSet();
@@ -237,11 +267,22 @@ export function createServerActionBridge(
237
267
  // abortAllActions() doesn't disrupt the in-progress Flight stream.
238
268
  handle.signal.removeEventListener("abort", onHandleAbort);
239
269
 
270
+ // Did the action call keepClientCache()? If so the deferred invalidation
271
+ // below is suppressed for THIS action (a concurrent directive-free
272
+ // action still invalidates via its own response).
273
+ keepCache = response.headers.get(KEEP_CACHE_HEADER) === "1";
274
+
240
275
  // Check for version mismatch - server wants us to reload
241
276
  const reloadResult = handleReloadHeader(response, {
242
277
  onBlocked: resolveStreamComplete,
243
- onReload: (url) =>
244
- log("version mismatch on action, reloading", { reloadUrl: url }),
278
+ onReload: (url) => {
279
+ log("version mismatch on action, reloading", { reloadUrl: url });
280
+ // Never-settling terminal (navigates away), so the finally never
281
+ // runs: release the fence here. skipInvalidation — the mismatch
282
+ // short-circuits the action server-side, so nothing mutated and a
283
+ // broadcast would only risk hard-reloading a sibling mid-task.
284
+ finalizeAction(true);
285
+ },
245
286
  });
246
287
  if (reloadResult) return reloadResult;
247
288
 
@@ -253,6 +294,10 @@ export function createServerActionBridge(
253
294
  if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
254
295
  log("action simple redirect", { url: redirect.url });
255
296
  handle.complete(undefined);
297
+ // This path returns a never-settling promise, so the finally never
298
+ // runs: invalidate + release the fence here (the mutation committed
299
+ // and we're navigating away). Latched, so the finally is a no-op.
300
+ finalizeAction();
256
301
  await dispatchRedirect(redirect.url);
257
302
  return new Promise<Response>(() => {});
258
303
  }
@@ -277,6 +322,9 @@ export function createServerActionBridge(
277
322
  log("action router id mismatch, reloading to re-sync");
278
323
  handle.complete(undefined);
279
324
  resolveStreamComplete();
325
+ // Never-settling return: release the fence before the reload (the
326
+ // reload resets module state anyway, but stay balanced). Latched.
327
+ finalizeAction();
280
328
  window.location.reload();
281
329
  return new Promise<Response>(() => {});
282
330
  }
@@ -542,8 +590,9 @@ export function createServerActionBridge(
542
590
  handle.clearConsolidation();
543
591
 
544
592
  if (scenario.historyKeyChanged) {
545
- if (!scenario.onInterceptRoute) {
546
- store.markCacheAsStaleAndBroadcast();
593
+ // Invalidation is deferred to finalizeAction(); here we only trigger
594
+ // the revalidation refetch of the new route (suppressed on keep).
595
+ if (!scenario.onInterceptRoute && !keepCache) {
547
596
  refetchRoute().catch((error) => {
548
597
  if (isBackgroundSuppressible(error)) return;
549
598
  console.error(
@@ -555,11 +604,14 @@ export function createServerActionBridge(
555
604
  break;
556
605
  }
557
606
 
558
- // Same history key but different pathname - safe to refetch current route
559
- store.markCacheAsStaleAndBroadcast();
560
- await refetchRoute({
561
- interceptSourceUrl: store.getInterceptSourceUrl(),
562
- });
607
+ // Same history key but different pathname - safe to refetch current
608
+ // route. Invalidation is deferred to finalizeAction(); here we only
609
+ // trigger the revalidation refetch (suppressed on keep).
610
+ if (!keepCache) {
611
+ await refetchRoute({
612
+ interceptSourceUrl: store.getInterceptSourceUrl(),
613
+ });
614
+ }
563
615
  break;
564
616
  }
565
617
 
@@ -567,8 +619,11 @@ export function createServerActionBridge(
567
619
  console.warn(
568
620
  `[Browser] Missing segments after action (HMR detected), refetching...`,
569
621
  );
622
+ // Repair (not revalidation), so ungated on keepCache: a keep action
623
+ // resolving last must discharge a directive-free sibling's repair.
624
+ // See the keep row in docs/design/rango-state-cookie.md (the all-keep
625
+ // edge, and the benign re-mark-stale-after-refetch end-state delta).
570
626
  await refetchRoute({ interceptSourceUrl });
571
- store.broadcastCacheInvalidation();
572
627
  break;
573
628
  }
574
629
 
@@ -585,11 +640,11 @@ export function createServerActionBridge(
585
640
  // Clear consolidation tracking before fetch
586
641
  handle.clearConsolidation();
587
642
 
643
+ // Ungated on keepCache, same as hmr-missing above (see the keep row).
588
644
  await refetchRoute({
589
645
  segments: segmentsToSend,
590
646
  interceptSourceUrl,
591
647
  });
592
- store.broadcastCacheInvalidation();
593
648
  break;
594
649
  }
595
650
 
@@ -653,7 +708,9 @@ export function createServerActionBridge(
653
708
  fullSegments,
654
709
  currentHandleData,
655
710
  );
656
- store.markCacheAsStaleAndBroadcast();
711
+ // Invalidation deferred to finalizeAction() (runs after this caches
712
+ // the fresh segments), suppressed when the action called
713
+ // keepClientCache().
657
714
  break;
658
715
  }
659
716
  }
@@ -661,6 +718,11 @@ export function createServerActionBridge(
661
718
  handle.complete(returnData);
662
719
  return returnData;
663
720
  } finally {
721
+ // The single deferred invalidation + fence release for this action. Runs
722
+ // for every terminal that settles (normal, navigated-away, error, abort,
723
+ // intercept, concurrent); the SPA-redirect paths above already ran it.
724
+ // Latched, so it fires exactly once.
725
+ finalizeAction();
664
726
  handle[Symbol.dispose]();
665
727
  }
666
728
  }
@@ -14,7 +14,6 @@ import type { RenderSegmentsOptions } from "../segment-system.js";
14
14
  export interface RscPayload<TMetadata = RscMetadata> {
15
15
  metadata?: TMetadata;
16
16
  returnValue?: ActionResult;
17
- formState?: unknown;
18
17
  }
19
18
 
20
19
  /**
@@ -71,6 +70,12 @@ export interface RscMetadata {
71
70
  * Sent on initial render so the browser can configure its cache duration.
72
71
  */
73
72
  prefetchCacheTTL?: number;
73
+ /**
74
+ * Server-resolved rango state cookie name (`{prefix}_{routerId}`). The client
75
+ * reads it verbatim and binds the rango state cookie to it; composition
76
+ * happens only server-side.
77
+ */
78
+ stateCookieName?: string;
74
79
  /**
75
80
  * Theme configuration from router.
76
81
  * Included when theme is enabled in router config.
@@ -433,9 +438,9 @@ export interface NavigationStore {
433
438
  hasHistoryCache(historyKey: string): boolean;
434
439
  updateCacheHandleData(historyKey: string, handleData: HandleData): void;
435
440
  markCacheAsStale(): void;
441
+ markHistoryCacheStale(): void;
436
442
  markCacheAsStaleAndBroadcast(): void;
437
443
  clearHistoryCache(): void;
438
- broadcastCacheInvalidation(): void;
439
444
 
440
445
  // Cross-tab refresh callback (set by navigation bridge)
441
446
  setCrossTabRefreshCallback(callback: () => void): void;
@@ -17,11 +17,10 @@ export function validateRedirectOrigin(
17
17
  );
18
18
  return null;
19
19
  }
20
- // Return pathname+search+hash for relative inputs, full href for absolute.
21
- // This normalizes protocol-relative and other ambiguous forms.
22
- return target.href.startsWith(currentOrigin)
23
- ? target.href
24
- : target.pathname + target.search + target.hash;
20
+ // Origin matched above, so target.href is same-origin: return the
21
+ // canonical full href. This normalizes protocol-relative and other
22
+ // ambiguous forms.
23
+ return target.href;
25
24
  } catch {
26
25
  console.error(`[rango] Redirect blocked: invalid URL "${url}"`);
27
26
  return null;
@@ -63,6 +63,9 @@ export interface TrieNode {
63
63
  * @param routeAncestry - Map of route name to ancestry shortCodes
64
64
  * @param routeToStaticPrefix - Map of route name to its entry's staticPrefix
65
65
  * @param routeTrailingSlash - Optional map of route name to trailing slash mode
66
+ * @param prerenderRouteNames - Optional set of prerendered route names (sets leaf.pr)
67
+ * @param passthroughRouteNames - Optional set of passthrough route names (sets leaf.pt)
68
+ * @param responseTypeRoutes - Optional map of route name to response type (sets leaf.rt)
66
69
  */
67
70
  export function buildRouteTrie(
68
71
  routeManifest: Record<string, string>,
@@ -37,12 +37,15 @@ export function formatRouteEntry(
37
37
  ): string {
38
38
  const hasSearch = search && Object.keys(search).length > 0;
39
39
 
40
+ // JSON.stringify the pattern and search values so backslashes and quotes in a
41
+ // route pattern (e.g. a custom regex constraint) survive interpolation into
42
+ // both the type-level string and the runtime NamedRoutes value.
40
43
  if (!hasSearch) {
41
- return ` ${key}: "${pattern}",`;
44
+ return ` ${key}: ${JSON.stringify(pattern)},`;
42
45
  }
43
46
 
44
47
  const searchBody = Object.entries(search!)
45
- .map(([k, v]) => `${k}: "${v}"`)
48
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
46
49
  .join(", ");
47
- return ` ${key}: { path: "${pattern}", search: { ${searchBody} } },`;
50
+ return ` ${key}: { path: ${JSON.stringify(pattern)}, search: { ${searchBody} } },`;
48
51
  }
@@ -617,9 +617,6 @@ export function writeCombinedRouteTypes(
617
617
  ? readFileSync(outPath, "utf-8")
618
618
  : null;
619
619
 
620
- // When the static parser can't extract routes (e.g. callback-style urls()),
621
- // write an empty placeholder so the build-time transform's injected import
622
- // resolves. Runtime discovery will overwrite this with the real routes.
623
620
  if (Object.keys(result.routes).length === 0) {
624
621
  if (!existing) {
625
622
  const emptySource = generateRouteTypesSource({});
@@ -635,11 +632,6 @@ export function writeCombinedRouteTypes(
635
632
  hasSearchSchemas ? result.searchSchemas : undefined,
636
633
  );
637
634
  if (existing !== source) {
638
- // On initial dev startup, don't overwrite a file from runtime discovery
639
- // (which has all dynamic routes) with a smaller set from the static
640
- // parser. The static parser can't see routes generated by Array.from()
641
- // or other dynamic code. During HMR (file watcher), always write so
642
- // newly added routes appear immediately.
643
635
  if (opts?.preserveIfLarger && existing) {
644
636
  const existingCount = countPublicRouteEntries(existing);
645
637
  const newCount = Object.keys(result.routes).filter(
@@ -69,24 +69,6 @@ export function computeExpiration(
69
69
  return { staleAt, expiresAt };
70
70
  }
71
71
 
72
- // ============================================================================
73
- // Cache Key Resolution
74
- // ============================================================================
75
-
76
- /**
77
- * Resolve cache key using the 3-tier priority:
78
- * 1. keyFn (full override from route/loader cache options)
79
- * 2. store.keyGenerator (modifies default key)
80
- * 3. defaultKey (used when neither keyFn nor keyGenerator is provided)
81
- *
82
- * Errors from keyFn and store.keyGenerator propagate to the caller.
83
- * Cache identity is correctness-critical: if explicit key logic throws,
84
- * silently remapping to a different key could cause cache collisions or
85
- * serve stale/wrong data. Callers must handle the error or let it surface.
86
- *
87
- * Uses _getRequestContext (non-throwing) so that calls outside ALS
88
- * (e.g. build-time) gracefully fall back to defaultKey.
89
- */
90
72
  export async function resolveCacheKey(
91
73
  keyFn: ((ctx: RequestContext) => string | Promise<string>) | undefined,
92
74
  store: SegmentCacheStore | null,
@@ -95,34 +77,17 @@ export async function resolveCacheKey(
95
77
  ): Promise<string> {
96
78
  const requestCtx = _getRequestContext();
97
79
 
98
- // Priority 1: Route/loader-level key function (full override)
99
80
  if (keyFn && requestCtx) {
100
81
  return await keyFn(requestCtx);
101
82
  }
102
83
 
103
- // Priority 2: Store-level keyGenerator (modifies default key)
104
84
  if (store?.keyGenerator && requestCtx) {
105
85
  return await store.keyGenerator(requestCtx, defaultKey);
106
86
  }
107
87
 
108
- // Priority 3: Default key (no custom key logic provided)
109
88
  return defaultKey;
110
89
  }
111
90
 
112
- // ============================================================================
113
- // Cache Tag Resolution
114
- // ============================================================================
115
-
116
- /**
117
- * Resolve cache tags from a tags option (static array or function of ctx).
118
- *
119
- * Fails open: a thrown tag callback falls back to no tags rather than
120
- * aborting the request. Tags are additive metadata (not identity), so a
121
- * missing tag does not cause cache collisions, only a missed invalidation.
122
- *
123
- * Shared by the cache() DSL (cache-scope) and loader caching (loader-cache)
124
- * so tag resolution behaves identically across every cache axis.
125
- */
126
91
  export function resolveTagsOption<TEnv>(
127
92
  tags: string[] | ((ctx: RequestContext<TEnv>) => string[]) | undefined,
128
93
  ctx: RequestContext<TEnv> | undefined,
@@ -131,10 +96,6 @@ export function resolveTagsOption<TEnv>(
131
96
  if (!tags) return undefined;
132
97
  if (typeof tags === "function") {
133
98
  if (!ctx) {
134
- // A dynamic tags function needs the request context to run. Without it
135
- // (e.g. resolved outside a request, at build/prerender time) the entry is
136
- // cached UNTAGGED and can never be invalidated - surface that rather than
137
- // silently dropping the tags, matching the thrown-callback branch below.
138
99
  console.warn(
139
100
  `[${label}] Dynamic tags function present but no request context; ` +
140
101
  `caching without tags (this entry will not be tag-invalidatable).`,
@@ -167,25 +128,10 @@ function normalizeTagList(tags: string[]): string[] | undefined {
167
128
  return out.length > 0 ? out : undefined;
168
129
  }
169
130
 
170
- // ============================================================================
171
- // Cache Store Resolution
172
- // ============================================================================
173
-
174
- /**
175
- * Resolve cache store from the 2-tier priority:
176
- * 1. Explicit store from cache options
177
- * 2. App-level store from request context
178
- */
179
131
  export function resolveCacheStore(
180
132
  explicitStore: SegmentCacheStore | undefined,
181
133
  ): SegmentCacheStore | null {
182
134
  if (explicitStore) {
183
- // Register explicit per-scope stores so updateTag()/revalidateTag() can
184
- // reach them. This is the single chokepoint every cache axis (segment,
185
- // response, loader) resolves through, so registering here covers them all
186
- // eagerly - no dependence on whether a tagged write has happened yet. The
187
- // app-level store is intentionally not registered (always reachable via
188
- // ctx._cacheStore).
189
135
  registerExplicitTaggedStore(explicitStore);
190
136
  return explicitStore;
191
137
  }
@@ -46,6 +46,7 @@ import {
46
46
  runWithCacheTagScope,
47
47
  } from "./cache-tag.js";
48
48
  import { reportCacheError } from "./cache-error.js";
49
+ import type { CacheItemResult } from "./types.js";
49
50
 
50
51
  /**
51
52
  * Convert encodeReply result to a stable string key.
@@ -84,6 +85,11 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
84
85
  // cacheTag() call inside the function degrades to a no-op rather than
85
86
  // throwing "must be called inside a use cache function" - adopting cacheTag()
86
87
  // must not hard-fail in apps/tests without an item-capable cache configured.
88
+ // Note: the INSIDE_CACHE_EXEC guard (cookies()/headers()/ctx.set() rejection)
89
+ // is intentionally NOT stamped here. It is a cached-path-only check; in the
90
+ // bypass the body actually executes, so the guarded side effects take effect
91
+ // and nothing is lost on a (non-existent) hit. Same applies to the
92
+ // non-serializable-args bypass below.
87
93
  if (!store?.getItem) {
88
94
  const scoped = runWithCacheTagScope(() => fn.apply(this, args));
89
95
  const result = await scoped.result;
@@ -185,23 +191,29 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
185
191
  // Cache lookup
186
192
  const cached = await store.getItem(cacheKey);
187
193
 
194
+ // Serve a cached entry on the hit path: deserialize the stored value,
195
+ // replay handle data (gated on tainted args), and surface the entry's tags
196
+ // to the request set (the function did not re-run, so its runtime cacheTag()
197
+ // tags are only available from the stored entry). Shared by the fresh-hit
198
+ // and stale-hit branches; the only divergence is the stale branch scheduling
199
+ // background revalidation, which it does after this returns.
200
+ const serveCached = async (entry: CacheItemResult): Promise<any> => {
201
+ const result = await deserializeResult(entry.value);
202
+ if (entry.handles && hasTaintedArgs) {
203
+ const handleStore = requestCtx?._handleStore;
204
+ if (handleStore) {
205
+ const r = await decodeHandles(entry.handles);
206
+ if (r) restoreHandles(r, handleStore);
207
+ }
208
+ }
209
+ recordRequestTags(entry.tags, requestCtx);
210
+ return result;
211
+ };
212
+
188
213
  if (cached && !cached.shouldRevalidate) {
189
214
  // Fresh hit: deserialize and return
190
215
  try {
191
- const result = await deserializeResult(cached.value);
192
- // Restore handle data if present
193
- if (cached.handles && hasTaintedArgs) {
194
- const handleStore = requestCtx?._handleStore;
195
- if (handleStore) {
196
- const r = await decodeHandles(cached.handles);
197
- if (r) restoreHandles(r, handleStore);
198
- }
199
- }
200
- // Surface the hit's tags to the request set so a document built from a
201
- // cached item is still tagged (the function did not re-run, so its
202
- // runtime cacheTag() tags are only available from the stored entry).
203
- recordRequestTags(cached.tags, requestCtx);
204
- return result;
216
+ return await serveCached(cached);
205
217
  } catch (error) {
206
218
  // The stored value is corrupt/partial (failed RSC deserialize). Report
207
219
  // it, then fall through to fresh execution - the miss path below re-runs
@@ -217,16 +229,7 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
217
229
  if (cached?.shouldRevalidate) {
218
230
  // Stale hit: return stale value, revalidate in background
219
231
  try {
220
- const result = await deserializeResult(cached.value);
221
- if (cached.handles && hasTaintedArgs) {
222
- const handleStore = requestCtx?._handleStore;
223
- if (handleStore) {
224
- const r = await decodeHandles(cached.handles);
225
- if (r) restoreHandles(r, handleStore);
226
- }
227
- }
228
- // Tag the request with the stale entry's tags (see fresh-hit note).
229
- recordRequestTags(cached.tags, requestCtx);
232
+ const result = await serveCached(cached);
230
233
  // Background revalidation — must capture handles if tainted args present.
231
234
  // Use an isolated handle store so background pushes don't pollute the
232
235
  // live response or throw LateHandlePushError on the completed store.
@@ -34,12 +34,6 @@ import {
34
34
  } from "./cache-policy.js";
35
35
  import type { RequestContext } from "../server/request-context.js";
36
36
 
37
- /**
38
- * Resolve tags for a cache() boundary from its config (static array or
39
- * function of ctx). Thin wrapper over the shared resolveTagsOption so the
40
- * cache() DSL and loader caching resolve tags identically.
41
- * @internal
42
- */
43
37
  export function resolveCacheTags(
44
38
  config: PartialCacheOptions | false,
45
39
  ctx: RequestContext | undefined,
@@ -54,17 +48,6 @@ function debugCacheLog(message: string): void {
54
48
  }
55
49
  }
56
50
 
57
- // ============================================================================
58
- // Key Generation (internal)
59
- // ============================================================================
60
-
61
- /**
62
- * Generate cache key base from host, pathname, route params, and search params.
63
- * Host is included to prevent cross-host cache collisions on shared stores.
64
- * Route params and search params are sorted alphabetically for deterministic keys.
65
- * Internal _rsc* and __* query params are excluded.
66
- * @internal
67
- */
68
51
  function getCacheKeyBase(
69
52
  host: string,
70
53
  pathname: string,
@@ -80,16 +63,6 @@ function getCacheKeyBase(
80
63
  return key;
81
64
  }
82
65
 
83
- /**
84
- * Generate default cache key for a route request.
85
- * Includes pathname, route params, and user-facing search params for
86
- * correct scoping. Internal _rsc* params are excluded.
87
- * Includes request type prefix since they produce different segment sets:
88
- * - doc: document requests (full page load)
89
- * - partial: navigation requests (client-side navigation)
90
- * - intercept: intercept navigation (modal/overlay routes)
91
- * @internal
92
- */
93
66
  function getDefaultRouteCacheKey(
94
67
  pathname: string,
95
68
  params?: Record<string, string>,
@@ -18,35 +18,11 @@ import {
18
18
 
19
19
  const cacheTagStorage = new AsyncLocalStorage<Set<string>>();
20
20
 
21
- /**
22
- * Normalize a tag for storage.
23
- *
24
- * Returns the tag unchanged if usable, or null if it is empty/whitespace-only
25
- * (dropped consistently in every environment - an empty tag matches nothing).
26
- *
27
- * Backend-specific constraints are intentionally NOT enforced here so the tag
28
- * primitive stays backend-agnostic. In particular, the CFCacheStore
29
- * encodeURIComponent's tags at serialization time so commas/spaces/non-Latin1
30
- * characters cannot corrupt the comma-delimited Cloudflare Cache-Tag header or
31
- * the HTTP marker header (it does not reject them). Keep tags short and
32
- * low-cardinality: a tag's KV marker key must stay under Cloudflare's 512-byte
33
- * limit, and a Cache-Tag value under 1024 bytes. The in-memory store has no
34
- * such limitations.
35
- *
36
- * @internal
37
- */
38
21
  export function normalizeTag(tag: string): string | null {
39
22
  if (!tag || !tag.trim()) return null;
40
23
  return tag;
41
24
  }
42
25
 
43
- /**
44
- * Normalize a tag collection: drop empty/whitespace-only tags so the WRITE path
45
- * matches the invalidate path (updateTag/revalidateTag/cacheTag all normalize).
46
- * Does not deduplicate - callers that need that wrap with a Set.
47
- *
48
- * @internal
49
- */
50
26
  export function normalizeTags(tags: Iterable<string>): string[] {
51
27
  const out: string[] = [];
52
28
  for (const tag of tags) {
@@ -89,19 +65,6 @@ export function cacheTag(...tags: string[]): void {
89
65
  }
90
66
  }
91
67
 
92
- /**
93
- * Record `tags` into the request-scoped tag set (ctx._requestTags), the union of
94
- * every cache tag resolved while producing the response. The document cache reads
95
- * this after the render settles so a full-page entry is tagged with everything its
96
- * content used, making it invalidatable by updateTag()/revalidateTag().
97
- *
98
- * Called at the tag-resolution sites: "use cache" stores (cache-runtime, both the
99
- * miss and read/hit paths), loader cache (cache-policy/loader-cache), and segment
100
- * cache() (cache-scope). Writes the field directly (not via ctx.set()) so it does
101
- * not trip the cache-scope side-effect guard, mirroring cacheTag() itself.
102
- *
103
- * @internal
104
- */
105
68
  export function recordRequestTags(
106
69
  tags: Iterable<string> | undefined,
107
70
  ctx: RequestContext | undefined = _getRequestContext(),