@rangojs/router 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/README.md +3 -2
  2. package/dist/bin/browser-7KIJ5HSV.js +15 -0
  3. package/dist/bin/chunk-3B7CKBRO.js +463 -0
  4. package/dist/bin/chunk-524VACCG.js +71 -0
  5. package/dist/bin/chunk-LD7UBHT4.js +196 -0
  6. package/dist/bin/chunk-TO6CRWLN.js +1429 -0
  7. package/dist/bin/chunk-TTXC2U3G.js +2477 -0
  8. package/dist/bin/client-urls-projection-DQLWPDAT.js +28 -0
  9. package/dist/bin/forward-user-plugins-URBZKJP4.js +62 -0
  10. package/dist/bin/rango.js +42 -10143
  11. package/dist/bin/runtime-EPUDEHO4.js +596 -0
  12. package/dist/bin/runtime-discovery-CZ2QHT4Q.js +352 -0
  13. package/dist/bin/state-ACD2MGKJ.js +16 -0
  14. package/dist/bin/temp-rsc-server-QRR6HD6T.js +3190 -0
  15. package/dist/types/bin/rango.d.ts +1 -0
  16. package/dist/types/browser/action-fence.d.ts +20 -0
  17. package/dist/types/browser/cookie-name.d.ts +51 -10
  18. package/dist/types/browser/history-state.d.ts +13 -2
  19. package/dist/types/browser/invalidate-client-cache.d.ts +8 -6
  20. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  21. package/dist/types/browser/navigation-client.d.ts +19 -0
  22. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  23. package/dist/types/browser/navigation-store.d.ts +2 -2
  24. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  25. package/dist/types/browser/partial-update.d.ts +13 -0
  26. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  27. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  28. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  29. package/dist/types/browser/rango-state.d.ts +34 -5
  30. package/dist/types/browser/react/Link.d.ts +19 -13
  31. package/dist/types/browser/react/location-state-shared.d.ts +2 -1
  32. package/dist/types/browser/types.d.ts +25 -0
  33. package/dist/types/build/generate-route-types.d.ts +1 -1
  34. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  35. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  36. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  37. package/dist/types/cache/cache-scope.d.ts +4 -2
  38. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  39. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  40. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  41. package/dist/types/cache/segment-codec.d.ts +2 -2
  42. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  43. package/dist/types/cache/types.d.ts +12 -7
  44. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  45. package/dist/types/client-urls/types.d.ts +7 -5
  46. package/dist/types/prefetch-markers.d.ts +9 -0
  47. package/dist/types/prefetch-source-scope.d.ts +66 -0
  48. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  49. package/dist/types/route-definition/redirect.d.ts +5 -0
  50. package/dist/types/router/intercept-resolution.d.ts +25 -11
  51. package/dist/types/router/match-context.d.ts +1 -0
  52. package/dist/types/router/match-handlers.d.ts +1 -1
  53. package/dist/types/router/middleware-types.d.ts +6 -0
  54. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  55. package/dist/types/router/router-context.d.ts +1 -1
  56. package/dist/types/router/router-options.d.ts +6 -3
  57. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  58. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  59. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  60. package/dist/types/router/source-reads.d.ts +53 -0
  61. package/dist/types/router/types.d.ts +1 -1
  62. package/dist/types/rsc/helpers.d.ts +8 -3
  63. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  64. package/dist/types/rsc/request-stages.d.ts +4 -0
  65. package/dist/types/rsc/shell-serve.d.ts +6 -1
  66. package/dist/types/server/context.d.ts +23 -6
  67. package/dist/types/server/cookie-store.d.ts +15 -1
  68. package/dist/types/server/document-location-state.d.ts +59 -0
  69. package/dist/types/server/handle-store.d.ts +2 -0
  70. package/dist/types/server/request-context.d.ts +65 -12
  71. package/dist/types/testing/e2e/server.d.ts +16 -1
  72. package/dist/types/testing/render-route.d.ts +83 -2
  73. package/dist/types/testing/serve-shell-request.d.ts +15 -3
  74. package/dist/types/types/handler-context.d.ts +21 -4
  75. package/dist/types/types/loader-types.d.ts +7 -4
  76. package/dist/types/types/segments.d.ts +18 -1
  77. package/dist/types/urls/path-helper-types.d.ts +7 -5
  78. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  79. package/dist/types/vite/utils/forward-user-plugins.d.ts +32 -2
  80. package/dist/vite/index.js +451 -191
  81. package/package.json +2 -2
  82. package/skills/app-structure/SKILL.md +74 -0
  83. package/skills/app-structure/e-commerce.md +10 -9
  84. package/skills/app-structure/large-app.md +15 -2
  85. package/skills/caching/SKILL.md +10 -5
  86. package/skills/client-urls/SKILL.md +18 -11
  87. package/skills/comparison/references/framework-comparison.md +16 -10
  88. package/skills/document-cache/SKILL.md +13 -0
  89. package/skills/hooks/navigation.md +8 -8
  90. package/skills/hooks/state.md +132 -12
  91. package/skills/intercept/SKILL.md +33 -11
  92. package/skills/layout/SKILL.md +11 -0
  93. package/skills/links/SKILL.md +101 -4
  94. package/skills/loader/SKILL.md +49 -22
  95. package/skills/migrate-nextjs/SKILL.md +8 -2
  96. package/skills/migrate-react-router/data-and-actions.md +5 -0
  97. package/skills/ppr/SKILL.md +38 -5
  98. package/skills/prerender/SKILL.md +4 -1
  99. package/skills/route/SKILL.md +6 -6
  100. package/skills/router-setup/SKILL.md +2 -1
  101. package/skills/server-actions/SKILL.md +49 -8
  102. package/skills/testing/client-components.md +73 -32
  103. package/skills/testing/serve-shell-request.md +45 -20
  104. package/skills/testing/server-actions.md +8 -0
  105. package/skills/ux-architecture/SKILL.md +67 -15
  106. package/src/bin/rango.ts +39 -13
  107. package/src/browser/action-fence.ts +72 -0
  108. package/src/browser/cookie-name.ts +82 -14
  109. package/src/browser/history-state.ts +24 -2
  110. package/src/browser/invalidate-client-cache.ts +9 -7
  111. package/src/browser/navigation-bridge.ts +164 -32
  112. package/src/browser/navigation-client.ts +95 -20
  113. package/src/browser/navigation-store-handle.ts +67 -5
  114. package/src/browser/navigation-store.ts +86 -17
  115. package/src/browser/navigation-transaction.ts +0 -6
  116. package/src/browser/partial-update.ts +31 -12
  117. package/src/browser/prefetch/cache.ts +188 -11
  118. package/src/browser/prefetch/fetch.ts +211 -51
  119. package/src/browser/prefetch/queue.ts +3 -2
  120. package/src/browser/rango-state.ts +50 -8
  121. package/src/browser/react/Link.tsx +24 -16
  122. package/src/browser/react/location-state-shared.ts +8 -1
  123. package/src/browser/react/use-navigation.ts +1 -1
  124. package/src/browser/rsc-router.tsx +13 -3
  125. package/src/browser/server-action-bridge.ts +3 -3
  126. package/src/browser/types.ts +25 -0
  127. package/src/build/generate-route-types.ts +1 -0
  128. package/src/build/route-types/include-resolution.ts +40 -3
  129. package/src/build/route-types/router-processing.ts +170 -25
  130. package/src/build/route-types/scan-filter.ts +1 -0
  131. package/src/build/runtime-discovery.ts +46 -8
  132. package/src/cache/cache-key-utils.ts +12 -8
  133. package/src/cache/cache-runtime.ts +5 -1
  134. package/src/cache/cache-scope.ts +28 -7
  135. package/src/cache/cf/cf-cache-types.ts +9 -5
  136. package/src/cache/document-cache-policy.ts +37 -1
  137. package/src/cache/document-cache.ts +12 -0
  138. package/src/cache/memory-segment-store.ts +3 -2
  139. package/src/cache/segment-codec.ts +2 -2
  140. package/src/cache/shell-snapshot.ts +1 -1
  141. package/src/cache/types.ts +12 -7
  142. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  143. package/src/client-urls/types.ts +7 -5
  144. package/src/prefetch-markers.ts +10 -0
  145. package/src/prefetch-source-scope.ts +137 -0
  146. package/src/route-definition/helpers-types.ts +8 -5
  147. package/src/route-definition/redirect.ts +5 -0
  148. package/src/router/intercept-resolution.ts +94 -50
  149. package/src/router/loader-resolution.ts +8 -6
  150. package/src/router/match-api.ts +33 -29
  151. package/src/router/match-context.ts +5 -0
  152. package/src/router/match-handlers.ts +2 -1
  153. package/src/router/match-middleware/cache-lookup.ts +39 -14
  154. package/src/router/match-result.ts +45 -85
  155. package/src/router/middleware-types.ts +6 -0
  156. package/src/router/navigation-snapshot.ts +18 -3
  157. package/src/router/prefetch-cache-ttl.ts +15 -3
  158. package/src/router/revalidation.ts +63 -13
  159. package/src/router/router-context.ts +2 -1
  160. package/src/router/router-options.ts +6 -3
  161. package/src/router/segment-resolution/loader-cache.ts +21 -3
  162. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  163. package/src/router/segment-resolution/revalidation.ts +40 -12
  164. package/src/router/source-reads.ts +122 -0
  165. package/src/router/types.ts +2 -1
  166. package/src/router.ts +11 -12
  167. package/src/rsc/helpers.ts +20 -6
  168. package/src/rsc/prefetch-render-error.ts +7 -2
  169. package/src/rsc/progressive-enhancement.ts +8 -2
  170. package/src/rsc/request-stages.ts +87 -1
  171. package/src/rsc/rsc-rendering.ts +78 -32
  172. package/src/rsc/server-action.ts +2 -1
  173. package/src/rsc/shell-capture.ts +4 -5
  174. package/src/rsc/shell-serve.ts +12 -1
  175. package/src/segment-system.tsx +1 -1
  176. package/src/server/context.ts +37 -6
  177. package/src/server/cookie-store.ts +22 -1
  178. package/src/server/document-location-state.ts +334 -0
  179. package/src/server/handle-store.ts +2 -0
  180. package/src/server/request-context.ts +137 -33
  181. package/src/testing/dispatch.ts +6 -0
  182. package/src/testing/e2e/fixture.ts +4 -12
  183. package/src/testing/e2e/server.ts +91 -4
  184. package/src/testing/render-route.tsx +309 -10
  185. package/src/testing/run-revalidate.ts +11 -1
  186. package/src/testing/serve-shell-request.ts +22 -3
  187. package/src/types/handler-context.ts +21 -4
  188. package/src/types/loader-types.ts +7 -4
  189. package/src/types/segments.ts +18 -1
  190. package/src/urls/path-helper-types.ts +7 -5
  191. package/src/vite/temp-rsc-server.ts +2 -2
  192. package/src/vite/utils/forward-user-plugins.ts +13 -5
package/src/bin/rango.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { resolve, dirname } from "node:path";
1
+ #!/usr/bin/env node
2
+ import { resolve, dirname, basename } from "node:path";
2
3
  import { readFileSync, statSync, existsSync } from "node:fs";
3
4
  import {
4
5
  findTsFiles,
@@ -8,6 +9,7 @@ import {
8
9
  detectUnresolvableIncludesForUrlsFile,
9
10
  findNestedRouterConflict,
10
11
  formatNestedRouterConflictError,
12
+ isRoutableSourceFile,
11
13
  type UnresolvableInclude,
12
14
  } from "../build/generate-route-types.ts";
13
15
 
@@ -93,6 +95,19 @@ Examples:
93
95
  );
94
96
  }
95
97
 
98
+ /**
99
+ * A createRouter() file that is not a test: a test builds its own router (a
100
+ * Flight test's `*.rsc-test.tsx` usually mounts an inline urls() tree), and a
101
+ * named-routes.gen.ts beside it would merge its routes into the app's global
102
+ * GeneratedRouteMap. The same rule findRouterFiles() applies in Vite.
103
+ */
104
+ function isRouterFile(filePath: string, source: string): boolean {
105
+ return (
106
+ isRoutableSourceFile(basename(filePath)) &&
107
+ /\bcreateRouter\s*[<(]/.test(source)
108
+ );
109
+ }
110
+
96
111
  /**
97
112
  * Walk up from a file path to find the project root (directory containing
98
113
  * package.json or vite.config.ts).
@@ -141,7 +156,7 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
141
156
  for (const filePath of files) {
142
157
  try {
143
158
  const source = readFileSync(filePath, "utf-8");
144
- if (/\bcreateRouter\s*[<(]/.test(source)) {
159
+ if (isRouterFile(filePath, source)) {
145
160
  routerFiles.push(filePath);
146
161
  }
147
162
  // Both spellings, matching the writer's own gate: "clientUrls(" does
@@ -181,7 +196,8 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
181
196
  // Deduplicate diagnostics (router and urls detection may find the same issue)
182
197
  const seen = new Set<string>();
183
198
  const uniqueDiagnostics = allDiagnostics.filter((d) => {
184
- const key = `${d.sourceFile}:${d.pathPrefix}:${d.reason}`;
199
+ // Every unreadable routes mount has pathPrefix "/": its detail names it.
200
+ const key = `${d.sourceFile}:${d.pathPrefix}:${d.reason}${d.reason === "unresolvable-routes" ? `:${d.detail}` : ""}`;
185
201
  if (seen.has(key)) return false;
186
202
  seen.add(key);
187
203
  return true;
@@ -192,10 +208,11 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
192
208
  console.error("\n[rango] Unresolvable routes detected:\n");
193
209
  formatDiagnostics(uniqueDiagnostics);
194
210
  console.error(
195
- "\nThe static parser cannot resolve these includes or basename because " +
196
- "they use factory functions or dynamic expressions. A basename must be " +
197
- "a string literal or a top-level const (in the router file or imported " +
198
- "from a relative module).\n\n" +
211
+ "\nThe static parser cannot resolve these includes, routes or basename " +
212
+ "because they use factory functions or dynamic expressions. A router's " +
213
+ "routes must be a urls() call, a builder function or a variable holding " +
214
+ "urls(); a basename must be a string literal or a top-level const (in " +
215
+ "the router file or imported from a relative module).\n\n" +
199
216
  "Options:\n" +
200
217
  " rango generate <path> --runtime Use Vite-based discovery (requires vite)\n" +
201
218
  " rango generate <path> --static Accept partial output (missing routes or basename prefix above)\n",
@@ -255,7 +272,7 @@ async function runRuntimeDiscovery(args: string[], configFile?: string) {
255
272
  for (const filePath of files) {
256
273
  try {
257
274
  const source = readFileSync(filePath, "utf-8");
258
- if (/\bcreateRouter\s*[<(]/.test(source)) {
275
+ if (isRouterFile(filePath, source)) {
259
276
  routerEntries.push(filePath);
260
277
  }
261
278
  // Also generate per-module types for urls files. Both spellings — a
@@ -281,6 +298,10 @@ async function runRuntimeDiscovery(args: string[], configFile?: string) {
281
298
  process.exit(1);
282
299
  }
283
300
 
301
+ // Loads from its own chunk only because the bin is built with esbuild
302
+ // --splitting (package.json "build"). A single-file bundle folds this module
303
+ // in and hoists its Vite imports to the top, so every command, --help
304
+ // included, failed without vite installed.
284
305
  let discoverAndWriteRouteTypes: typeof import("../build/runtime-discovery.ts").discoverAndWriteRouteTypes;
285
306
  try {
286
307
  const mod = await import("../build/runtime-discovery.ts");
@@ -290,9 +311,12 @@ async function runRuntimeDiscovery(args: string[], configFile?: string) {
290
311
  err.code === "ERR_MODULE_NOT_FOUND" ||
291
312
  err.code === "MODULE_NOT_FOUND"
292
313
  ) {
314
+ // runtime-discovery checks vite and @vitejs/plugin-rsc itself and names
315
+ // what to install, so reaching here means a bundled chunk file is
316
+ // missing: the installed package is incomplete.
293
317
  console.error(
294
- "[rango] Runtime discovery requires 'vite' and '@vitejs/plugin-rsc'.\n" +
295
- "Install them with: pnpm add -D vite @vitejs/plugin-rsc",
318
+ `[rango] Failed to load runtime discovery: ${err.message}\n` +
319
+ "The installed @rangojs/router looks broken (a CLI chunk is missing). Reinstall it.",
296
320
  );
297
321
  } else {
298
322
  console.error(`[rango] Failed to load runtime discovery: ${err.message}`);
@@ -320,9 +344,11 @@ function formatDiagnostics(
320
344
  const prefix =
321
345
  d.reason === "unresolvable-basename"
322
346
  ? "createRouter basename"
323
- : d.namePrefix
324
- ? `${d.namePrefix}.*`
325
- : `${d.pathPrefix}*`;
347
+ : d.reason === "unresolvable-routes"
348
+ ? "createRouter routes"
349
+ : d.namePrefix
350
+ ? `${d.namePrefix}.*`
351
+ : `${d.pathPrefix}*`;
326
352
  console.error(` ${prefix}`);
327
353
  console.error(` Reason: ${d.reason} -- ${d.detail}`);
328
354
  console.error(` Source: ${d.sourceFile}`);
@@ -38,12 +38,82 @@
38
38
 
39
39
  let fenceCount = 0;
40
40
 
41
+ /**
42
+ * Whether a cookie read saw the state rotate externally while the fence was
43
+ * up and no action's landing took it yet (navigation-store-handle.ts reports
44
+ * it). Almost always an action's own server-seat `invalidateClientCache()`
45
+ * `Set-Cookie`; a sibling tab's rotation in that window reads the same and
46
+ * costs one full flush, today's behavior. navigation-store.ts takes it when
47
+ * an action lands, so one rotation makes one landing flush, not every later
48
+ * landing of the batch.
49
+ */
50
+ let rotatedInFence = false;
51
+
52
+ /**
53
+ * Whether a cookie read saw a write-only change of the state (a server
54
+ * action's response, this tab's or another's) while the fence was up and no
55
+ * action's landing took it yet. A landing marks every cache stale, so it
56
+ * takes this; when the fence drops with it still set (the actions that
57
+ * landed called keepClientCache(), failed before they were sent, or reloaded
58
+ * on a version mismatch), the drop hands it to `unlandedWriteHandler`.
59
+ */
60
+ let wroteInFence = false;
61
+
62
+ let unlandedWriteHandler: (() => void) | null = null;
63
+
64
+ /**
65
+ * Register what runs when the fence drops holding a write no landing took:
66
+ * the navigation store's markCachesStaleAfterWrite (navigation-store-handle.ts).
67
+ */
68
+ export function setUnlandedWriteHandler(handler: (() => void) | null): void {
69
+ unlandedWriteHandler = handler;
70
+ }
71
+
41
72
  export function enterActionFence(): void {
73
+ if (fenceCount === 0) {
74
+ rotatedInFence = false;
75
+ wroteInFence = false;
76
+ }
42
77
  fenceCount++;
43
78
  }
44
79
 
45
80
  export function exitActionFence(): void {
46
81
  if (fenceCount > 0) fenceCount--;
82
+ if (fenceCount === 0 && wroteInFence) {
83
+ wroteInFence = false;
84
+ unlandedWriteHandler?.();
85
+ }
86
+ }
87
+
88
+ /** A cookie read saw an external state rotation (rango-state.ts observer). */
89
+ export function noteStateRotation(): void {
90
+ if (fenceCount > 0) rotatedInFence = true;
91
+ }
92
+
93
+ /**
94
+ * A cookie read saw a write-only change (rango-state.ts write observer).
95
+ * Returns whether the fence is up and took it; with the fence down the
96
+ * caller marks the caches stale itself.
97
+ */
98
+ export function noteStateWrite(): boolean {
99
+ if (fenceCount === 0) return false;
100
+ wroteInFence = true;
101
+ return true;
102
+ }
103
+
104
+ /** A landing marked every cache stale: a write seen so far is covered. */
105
+ export function takeStateWriteInActionFence(): void {
106
+ wroteInFence = false;
107
+ }
108
+
109
+ /**
110
+ * Whether the state rotated externally since the fence went up or the last
111
+ * landing took it, and clear it. False with the fence down.
112
+ */
113
+ export function takeStateRotationInActionFence(): boolean {
114
+ const rotated = fenceCount > 0 && rotatedInFence;
115
+ rotatedInFence = false;
116
+ return rotated;
47
117
  }
48
118
 
49
119
  export function isActionFenceActive(): boolean {
@@ -53,4 +123,6 @@ export function isActionFenceActive(): boolean {
53
123
  /** Test-only: reset the refcount between cases. */
54
124
  export function __resetActionFence(): void {
55
125
  fenceCount = 0;
126
+ rotatedInFence = false;
127
+ wroteInFence = false;
56
128
  }
@@ -91,31 +91,48 @@ export function getRawCookieValue(
91
91
  }
92
92
 
93
93
  /**
94
- * Encode a state value for the wire: `encodeURIComponent(version):timestamp`.
95
- * Only the build-derived version is encoded (it is arbitrary); the `:`
96
- * separator and numeric timestamp stay raw, so the `{version}:{timestamp}`
97
- * shape survives and `:` is a legal cookie-value octet.
94
+ * Encode a state value for the wire: `encodeURIComponent(version):timestamp`,
95
+ * plus `:write` when the write part is not 0. Only the build-derived version
96
+ * is encoded (it is arbitrary); the `:` separators and the numeric parts stay
97
+ * raw, so the shape survives and `:` is a legal cookie-value octet. A value no
98
+ * server action has bumped keeps the two-part shape it had before the write
99
+ * part existed.
98
100
  */
99
- export function encodeStateValue(version: string, timestamp: number): string {
100
- return `${encodeURIComponent(version)}:${timestamp}`;
101
+ export function encodeStateValue(
102
+ version: string,
103
+ timestamp: number,
104
+ write = 0,
105
+ ): string {
106
+ const base = `${encodeURIComponent(version)}:${timestamp}`;
107
+ return write === 0 ? base : `${base}:${write}`;
101
108
  }
102
109
 
103
- /** Parsed state value. `version` is decoded; `timestamp` is the raw integer. */
110
+ /**
111
+ * Parsed state value. `version` is decoded; `timestamp` (the invalidation
112
+ * part) and `write` are the raw integers. `write` is 0 for a two-part value.
113
+ */
104
114
  export interface StateValue {
105
115
  version: string;
106
116
  timestamp: number;
117
+ write: number;
107
118
  }
108
119
 
109
120
  /**
110
- * Decode a wire value back into `{version, timestamp}`. Returns null for a
111
- * malformed value (no `:`, empty version, or non-numeric timestamp) so callers
112
- * mint fresh instead of trusting garbage.
121
+ * Decode a wire value back into `{version, timestamp, write}`. Accepts the
122
+ * two-part `{version}:{timestamp}` (a value no action bumped, or one an open
123
+ * tab wrote before the write part existed) and the three-part
124
+ * `{version}:{timestamp}:{write}`. Returns null for a malformed value (no `:`,
125
+ * empty version, a non-numeric part, or more parts) so callers mint fresh
126
+ * instead of trusting garbage.
113
127
  */
114
128
  export function decodeStateValue(raw: string): StateValue | null {
115
129
  const colon = raw.indexOf(":");
116
130
  if (colon <= 0) return null;
117
- const timestamp = Number(raw.slice(colon + 1));
118
- if (!Number.isFinite(timestamp)) return null;
131
+ const parts = raw.slice(colon + 1).split(":");
132
+ if (parts.length > 2) return null;
133
+ const timestamp = parseStatePart(parts[0]!);
134
+ const write = parts.length === 2 ? parseStatePart(parts[1]!) : 0;
135
+ if (timestamp === null || write === null) return null;
119
136
  let version: string;
120
137
  try {
121
138
  version = decodeURIComponent(raw.slice(0, colon));
@@ -124,7 +141,31 @@ export function decodeStateValue(raw: string): StateValue | null {
124
141
  // a thrown URIError here would 500 the server seat or fail client boot.
125
142
  return null;
126
143
  }
127
- return { version, timestamp };
144
+ return { version, timestamp, write };
145
+ }
146
+
147
+ function parseStatePart(part: string): number | null {
148
+ if (part === "") return null;
149
+ const value = Number(part);
150
+ return Number.isFinite(value) ? value : null;
151
+ }
152
+
153
+ /**
154
+ * The `{version}:{timestamp}` part of a wire value: what every in-memory
155
+ * comparison on the client reads (the prefetch cache keys, the action
156
+ * fence's rotation check, the external-rotation observer). A change to it is
157
+ * a hard invalidation (a deploy, or invalidateClientCache() on either seat);
158
+ * a change to the write part alone is a server action's write, which marks
159
+ * caches stale and flushes nothing. The full value, write part included, is
160
+ * what requests send as `X-Rango-State`, so the browser HTTP cache (Vary)
161
+ * misses after every write. A raw cut, no decode: the version is
162
+ * percent-encoded, so the second `:` is always the write separator.
163
+ */
164
+ export function stateEpoch(raw: string): string {
165
+ const first = raw.indexOf(":");
166
+ if (first < 0) return raw;
167
+ const second = raw.indexOf(":", first + 1);
168
+ return second < 0 ? raw : raw.slice(0, second);
128
169
  }
129
170
 
130
171
  /**
@@ -132,7 +173,9 @@ export function decodeStateValue(raw: string): StateValue | null {
132
173
  * one, so a re-mint inside the same millisecond (or a backward clock step) still
133
174
  * differs from the current value. `prevRaw` is the current wire value (the
134
175
  * client's in-memory mirror, or the server's inbound header/cookie) or null; its
135
- * timestamp is the floor. Shared by both seats so the monotonic guard lives once.
176
+ * timestamp is the floor. The write part starts over (left out): the new
177
+ * timestamp alone makes the value one no response was cached under. Shared by
178
+ * both seats so the monotonic guard lives once.
136
179
  */
137
180
  export function mintStateValue(
138
181
  version: string,
@@ -143,6 +186,31 @@ export function mintStateValue(
143
186
  return encodeStateValue(version, ts);
144
187
  }
145
188
 
189
+ /**
190
+ * Bump the write part of `prevRaw`, keeping its version and timestamp: what
191
+ * the response of a server action without a directive sets. Keeping the
192
+ * version keeps the bump soft for a client still on another build (a form
193
+ * posted from a document served before a deploy); its own boot handles the
194
+ * deploy. Returns null when `prevRaw` is absent or malformed: a client with
195
+ * no state value sends no `X-Rango-State` and has nothing in the HTTP cache
196
+ * keyed by one.
197
+ *
198
+ * The new write part must be one no response was cached under. It is
199
+ * strictly greater than the previous one, and it is the current time in
200
+ * microseconds with random low digits: two actions that start from the same
201
+ * value (two tabs, or two in flight in one tab) can run in the same server
202
+ * millisecond (a Worker's clock does not move between I/O), and the second
203
+ * response must not set the value the first one set, under which a prefetch
204
+ * between them may have been cached with the second write missing.
205
+ */
206
+ export function bumpStateWrite(prevRaw: string | null): string | null {
207
+ const prev = prevRaw ? decodeStateValue(prevRaw) : null;
208
+ if (!prev) return null;
209
+ const fresh = Date.now() * 1000 + Math.floor(Math.random() * 1000);
210
+ const write = Math.max(fresh, prev.write + 1);
211
+ return encodeStateValue(prev.version, prev.timestamp, write);
212
+ }
213
+
146
214
  /**
147
215
  * Attribute string for the state cookie. Session cookie (no Max-Age/Expires),
148
216
  * Path=/ (whole app), SameSite=Lax (sent on top-level navigations), and Secure
@@ -158,8 +158,9 @@ export function stripShellMissMarker(): void {
158
158
  * the server rendered this document without them. One pass before hydration
159
159
  * (initBrowserApp; renderRoute's `hydrate` mode), so no reader needs to know
160
160
  * the page began as a document load, and a slot nobody reads is removed too.
161
- * No server-set state exists yet to lose: a document response carries none
162
- * (rsc-rendering.ts attaches it to partial payloads only).
161
+ * Server-set state is not lost to it: the state a document response carries
162
+ * is merged after this pass (applyDocumentLocationState), so a `~r` slot the
163
+ * server set for this load survives this load and no later one.
163
164
  *
164
165
  * A slot is recognised by its key alone (the `~r` suffix): at start-up the
165
166
  * module that defines it may not be loaded.
@@ -185,6 +186,27 @@ export function clearLocationStateOnDocumentLoad(): void {
185
186
  if (next) window.history.replaceState(next, "");
186
187
  }
187
188
 
189
+ /**
190
+ * Merge the location state a document response carried (its bootstrap
191
+ * payload's `metadata.locationState`, server/document-location-state.ts)
192
+ * into the entry the document loaded on. Start-up runs it after
193
+ * clearLocationStateOnDocumentLoad and the version is set, before the entry
194
+ * is committed for readers (initBrowserApp; renderRoute's `hydrate` mode),
195
+ * so readers get it once they have hydrated, as stored state. Only typed
196
+ * slots are taken.
197
+ */
198
+ export function applyDocumentLocationState(
199
+ locationState: Record<string, unknown> | undefined,
200
+ ): void {
201
+ if (!locationState) return;
202
+ let slots: Record<string, unknown> | undefined;
203
+ for (const key of Object.keys(locationState)) {
204
+ if (!isLocationStateKey(key)) continue;
205
+ (slots ??= {})[key] = locationState[key];
206
+ }
207
+ if (slots) mergeLocationState(slots);
208
+ }
209
+
188
210
  /**
189
211
  * Merge server-set location state into the current history entry and return
190
212
  * the entry state written. Readers see it when the caller commits it
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Client seat of `invalidateClientCache()` (the `default` export condition).
3
3
  *
4
- * Makes the current client behave as if a server action had just completed:
5
- * the history cache is marked stale (SWR), the prefetch map is flushed, the
6
- * state rotates, and sibling tabs are broadcast to — the same
7
- * `markCacheAsStaleAndBroadcast()` path the server-action bridge uses. This is
8
- * the gentler mark-stale (not hard-clear) behavior, so Back renders the cached
9
- * entry instantly and revalidates.
4
+ * The full client invalidation, more than a server action without a
5
+ * directive does: the history cache is marked stale (SWR), the prefetch map
6
+ * is flushed (in-flight prefetches aborted, visible links prefetch again), the
7
+ * state rotates, and sibling tabs are broadcast to, which adopt the new state
8
+ * and flush too (`invalidateCacheAndBroadcast()`). A directive-free action
9
+ * only marks history and prefetches stale and rotates nothing
10
+ * (`markCacheAsStaleAndBroadcast()`). History is marked stale, not cleared, so
11
+ * Back renders the cached entry instantly and revalidates.
10
12
  */
11
13
 
12
14
  import { getRegisteredStore } from "./navigation-store-handle.js";
@@ -27,7 +29,7 @@ export function invalidateClientCache(): void {
27
29
 
28
30
  const store = getRegisteredStore();
29
31
  if (store) {
30
- store.markCacheAsStaleAndBroadcast();
32
+ store.invalidateCacheAndBroadcast();
31
33
  return;
32
34
  }
33
35
 
@@ -43,7 +43,7 @@ import {
43
43
  generateHistoryKey,
44
44
  liveHandleDataForCache,
45
45
  } from "./navigation-store.js";
46
- import type { EventController } from "./event-controller.js";
46
+ import type { EventController, NavigationEntry } from "./event-controller.js";
47
47
  import { isInterceptOnlyCache } from "./intercept-utils.js";
48
48
  import { actionBoundaryAt, idsAboveBoundary } from "./segment-reconciler.js";
49
49
  import {
@@ -140,6 +140,82 @@ function isRestorable(
140
140
  );
141
141
  }
142
142
 
143
+ /**
144
+ * The request a stale-prefetch revalidation makes (scheduleStalePrefetch-
145
+ * Revalidation): the ids it holds, and the intercept and action error
146
+ * boundary on screen.
147
+ */
148
+ export interface StalePrefetchRevalidation {
149
+ segmentIds: string[];
150
+ intercept: { isIntercept: boolean; sourceUrl?: string };
151
+ boundary: string | undefined;
152
+ }
153
+
154
+ /**
155
+ * A click that adopted a prefetch a server action marked stale
156
+ * (prefetch/cache.ts isPrefetchStale) committed the pre-write payload, as any
157
+ * prefetched click commits. Once that navigation settled
158
+ * (NavigationEntry.settled: its commit, any `prefetch: false` fill and its
159
+ * stream, so this never supersedes the click nor cuts a response
160
+ * that arrived short), `revalidate` runs the page's request in
161
+ * Back's lane (the bridge's revalidateInBackground), with the ids on screen
162
+ * and the action error boundary it shows held, as Back onto a stale entry
163
+ * does.
164
+ *
165
+ * The entry is marked stale first, so a Back onto it revalidates when this
166
+ * one never ran or was left. Nothing runs when the click did not commit, or
167
+ * when the page moved since (a newer navigation aborts the click's entry, a
168
+ * shallow one moves the history key) or another navigation is open. An
169
+ * action in flight does not hold it back, as it does not hold Back's: its
170
+ * follow-up, when it owes one, starts a navigation that supersedes this one.
171
+ *
172
+ * Exported so renderRoute (testing/render-route.tsx) drives these guards and
173
+ * this plan rather than a copy of them.
174
+ */
175
+ export function scheduleStalePrefetchRevalidation(
176
+ store: Pick<
177
+ NavigationStore,
178
+ "getCachedSegments" | "getHistoryKey" | "updateCacheHandleData"
179
+ >,
180
+ eventController: Pick<EventController, "getUnsettledNavigation">,
181
+ entry: Pick<NavigationEntry, "settled">,
182
+ historyKey: string,
183
+ revalidate: (request: StalePrefetchRevalidation) => void,
184
+ ): void {
185
+ void entry.settled.then((committed) => {
186
+ if (!committed) return;
187
+ const cached = store.getCachedSegments(historyKey);
188
+ if (!cached) return;
189
+ store.updateCacheHandleData(historyKey, cached.handleData ?? {}, true);
190
+ if (
191
+ store.getHistoryKey() !== historyKey ||
192
+ eventController.getUnsettledNavigation() ||
193
+ !isRestorable(cached.segments)
194
+ ) {
195
+ return;
196
+ }
197
+ const historyState = window.history.state;
198
+ const isIntercept = historyState?.intercept === true;
199
+ const boundaryAt = isIntercept ? -1 : actionBoundaryAt(cached.segments);
200
+ if (boundaryAt === 0) return;
201
+ const boundary = cached.segments[boundaryAt]?.id;
202
+ debugLog("[Browser] Stale prefetch committed, background revalidating");
203
+ // A deferred Meta still pending when its handles were cached: a whole
204
+ // render re-streams them (handlePopstate, handlesPending).
205
+ const segmentIds = cached.handlesPending
206
+ ? []
207
+ : idsAboveBoundary(
208
+ cached.segments.map((s) => s.id),
209
+ boundary,
210
+ );
211
+ revalidate({
212
+ segmentIds,
213
+ intercept: { isIntercept, sourceUrl: historyState?.sourceUrl },
214
+ boundary,
215
+ });
216
+ });
217
+ }
218
+
143
219
  /**
144
220
  * Create a navigation bridge for handling client-side navigation
145
221
  *
@@ -236,6 +312,53 @@ export function createNavigationBridge(
236
312
  });
237
313
  }
238
314
 
315
+ /**
316
+ * Back's stale-revalidation lane: a background request for the page on
317
+ * screen (skipLoadingState: useNavigation() stays idle, useLinkStatus shows
318
+ * nothing) that holds `segmentIds` and an action error boundary on screen,
319
+ * re-caches the entry (cacheOnly) and commits as kind "revalidate". Used by
320
+ * a Back/Forward restore of a stale entry (handlePopstate) and after a click
321
+ * on a stale prefetch (scheduleStalePrefetchRevalidation).
322
+ */
323
+ function revalidateInBackground(
324
+ url: string,
325
+ segmentIds: string[],
326
+ intercept: { isIntercept: boolean; sourceUrl?: string },
327
+ boundary: string | undefined,
328
+ ): void {
329
+ const tx = createNavigationTransaction(store, eventController, url, {
330
+ skipLoadingState: true,
331
+ replace: true,
332
+ });
333
+
334
+ fetchPartialUpdate(
335
+ url,
336
+ segmentIds,
337
+ false,
338
+ tx.handle.signal,
339
+ tx.with({
340
+ url,
341
+ replace: true,
342
+ scroll: false,
343
+ intercept: intercept.isIntercept,
344
+ interceptSourceUrl: intercept.sourceUrl,
345
+ cacheOnly: true,
346
+ }),
347
+ {
348
+ type: "stale-revalidation",
349
+ interceptSourceUrl: intercept.sourceUrl,
350
+ boundary,
351
+ },
352
+ )
353
+ .catch((error) => {
354
+ if (isBackgroundSuppressible(error)) return;
355
+ console.error("[Browser] Background revalidation failed:", error);
356
+ })
357
+ .finally(() => {
358
+ tx[Symbol.dispose]();
359
+ });
360
+ }
361
+
239
362
  const bridge: NavigationBridge = {
240
363
  /**
241
364
  * Navigate to a URL
@@ -387,6 +510,17 @@ export function createNavigationBridge(
387
510
  store.updateCacheHandleData(sourceHistoryKey, currentHandleData);
388
511
  }
389
512
 
513
+ // Read the rango state BEFORE the target entry's stale bit, as
514
+ // handlePopstate does. Another tab's write reaches this tab only through
515
+ // a read (rango-state.ts write observer) when its broadcast does not:
516
+ // the pages share no segment, or crossTabSync is off. The first read
517
+ // otherwise comes from the navigation client, after the held ids below
518
+ // are chosen, and the server keeps what the client says it holds when
519
+ // the default allows (router/revalidation.ts: a route whose params and
520
+ // search are the entry's, a layout loader with no revalidate()), so the
521
+ // entry's pre-write copy would commit as fresh.
522
+ getRangoState();
523
+
390
524
  // Check if we have cached segments for target URL
391
525
  const historyKey = generateHistoryKey(url);
392
526
  const cached = store.getCachedSegments(historyKey);
@@ -441,6 +575,9 @@ export function createNavigationBridge(
441
575
  state: resolvedState,
442
576
  skipLoadingState: false,
443
577
  });
578
+ // This navigation's entry when its response was a prefetch an action
579
+ // marked stale (scheduleStalePrefetchRevalidation).
580
+ let stalePrefetchEntry: NavigationEntry | null = null;
444
581
  // A cross-route clientUrls() navigation presents its destination
445
582
  // optimistically at once: transition({ when }) decides THERE (the first
446
583
  // presentation), over the destination route's predicate and those of
@@ -520,8 +657,30 @@ export function createNavigationBridge(
520
657
  ...(clientUrlPresentation && {
521
658
  presentation: clientUrlPresentation,
522
659
  }),
660
+ // The last response decides: a fresh refetch after a stale
661
+ // adoption (a fill's start-over) clears it.
662
+ onStalePrefetch: (stale: boolean) => {
663
+ const entry = eventController.getUnsettledNavigation();
664
+ stalePrefetchEntry =
665
+ stale && entry?.abort === tx.handle.abort ? entry : null;
666
+ },
523
667
  },
524
668
  );
669
+ if (stalePrefetchEntry && !tx.handle.signal.aborted) {
670
+ scheduleStalePrefetchRevalidation(
671
+ store,
672
+ eventController,
673
+ stalePrefetchEntry,
674
+ store.getHistoryKey(),
675
+ ({ segmentIds, intercept, boundary }) =>
676
+ revalidateInBackground(
677
+ window.location.href,
678
+ segmentIds,
679
+ intercept,
680
+ boundary,
681
+ ),
682
+ );
683
+ }
525
684
  } catch (error) {
526
685
  // Aborted, or superseded by a newer navigation. A superseded nav may
527
686
  // reject with a non-AbortError (e.g. a Flight decode that fails after its
@@ -846,39 +1005,12 @@ export function createNavigationBridge(
846
1005
  // the deferred-Meta-aborted case, so action/cross-tab SWR keeps the
847
1006
  // cheap diff path.
848
1007
  const segmentIds = cached?.handlesPending ? [] : heldIds;
849
-
850
- const tx = createNavigationTransaction(
851
- store,
852
- eventController,
853
- url,
854
- { skipLoadingState: true, replace: true },
855
- );
856
-
857
- fetchPartialUpdate(
1008
+ revalidateInBackground(
858
1009
  url,
859
1010
  segmentIds,
860
- false,
861
- tx.handle.signal,
862
- tx.with({
863
- url,
864
- replace: true,
865
- scroll: false,
866
- intercept: isIntercept,
867
- interceptSourceUrl,
868
- cacheOnly: true,
869
- }),
870
- { type: "stale-revalidation", interceptSourceUrl, boundary },
871
- )
872
- .catch((error) => {
873
- if (isBackgroundSuppressible(error)) return;
874
- console.error(
875
- "[Browser] Background revalidation failed:",
876
- error,
877
- );
878
- })
879
- .finally(() => {
880
- tx[Symbol.dispose]();
881
- });
1011
+ { isIntercept, sourceUrl: interceptSourceUrl },
1012
+ boundary,
1013
+ );
882
1014
  }
883
1015
  };
884
1016
  // React commits a transition started while window.event is the