@rangojs/router 0.21.0 → 0.22.1

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 (244) hide show
  1. package/dist/bin/rango.js +13 -1
  2. package/dist/types/browser/event-controller.d.ts +30 -13
  3. package/dist/types/browser/navigation-store.d.ts +9 -0
  4. package/dist/types/browser/partial-update.d.ts +2 -1
  5. package/dist/types/browser/prefetch/default-strategy.d.ts +16 -20
  6. package/dist/types/browser/react/NavigationProvider.d.ts +14 -1
  7. package/dist/types/browser/react/context.d.ts +14 -0
  8. package/dist/types/browser/rsc-router.d.ts +13 -2
  9. package/dist/types/browser/types.d.ts +10 -0
  10. package/dist/types/build/generate-manifest.d.ts +2 -0
  11. package/dist/types/cache/background-task.d.ts +22 -0
  12. package/dist/types/cache/cache-key-utils.d.ts +50 -5
  13. package/dist/types/cache/cache-policy.d.ts +17 -0
  14. package/dist/types/cache/cache-scope.d.ts +1 -1
  15. package/dist/types/cache/cf/cf-cache-store.d.ts +7 -1
  16. package/dist/types/cache/document-cache.d.ts +8 -5
  17. package/dist/types/cache/handle-snapshot.d.ts +23 -0
  18. package/dist/types/cache/index.d.ts +1 -1
  19. package/dist/types/cache/invalidation-order.d.ts +14 -0
  20. package/dist/types/cache/memory-segment-store.d.ts +33 -1
  21. package/dist/types/cache/shell-snapshot.d.ts +10 -1
  22. package/dist/types/cache/store-scope.d.ts +22 -0
  23. package/dist/types/cache/tag-invalidation.d.ts +7 -6
  24. package/dist/types/cache/types.d.ts +63 -14
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +7 -1
  26. package/dist/types/errors.d.ts +6 -0
  27. package/dist/types/handles/deferred-resolution.d.ts +7 -0
  28. package/dist/types/internal-suspense-audit.d.ts +1 -0
  29. package/dist/types/loader-store.d.ts +4 -8
  30. package/dist/types/outlet-provider.d.ts +2 -1
  31. package/dist/types/prerender/cloudflare.d.ts +49 -0
  32. package/dist/types/prerender/create-prerender-trigger.d.ts +121 -0
  33. package/dist/types/prerender/index.d.ts +12 -0
  34. package/dist/types/prerender/memory-prerender-store.d.ts +29 -0
  35. package/dist/types/prerender/on-demand.d.ts +272 -0
  36. package/dist/types/prerender/producer-guard.d.ts +33 -0
  37. package/dist/types/prerender/shell-manifest-key.d.ts +1 -18
  38. package/dist/types/prerender/warm-request.d.ts +65 -0
  39. package/dist/types/prerender/warm.d.ts +65 -0
  40. package/dist/types/prerender/writable-store.d.ts +170 -0
  41. package/dist/types/prerender.d.ts +27 -0
  42. package/dist/types/reverse.d.ts +1 -1
  43. package/dist/types/route-content-wrapper.d.ts +3 -1
  44. package/dist/types/router/error-handling.d.ts +2 -0
  45. package/dist/types/router/handler-context.d.ts +1 -1
  46. package/dist/types/router/match-middleware/cache-lookup.d.ts +14 -0
  47. package/dist/types/router/pattern-matching.d.ts +2 -0
  48. package/dist/types/router/prefetch-default.d.ts +4 -4
  49. package/dist/types/router/prerender-match.d.ts +23 -1
  50. package/dist/types/router/route-trie-builder.d.ts +8 -1
  51. package/dist/types/router/router-context.d.ts +1 -0
  52. package/dist/types/router/router-interfaces.d.ts +30 -0
  53. package/dist/types/router/router-options.d.ts +24 -0
  54. package/dist/types/router/segment-resolution/fresh.d.ts +7 -0
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +2 -1
  56. package/dist/types/router/segment-resolution/revalidation.d.ts +5 -1
  57. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +43 -0
  58. package/dist/types/router/trie-matching.d.ts +2 -0
  59. package/dist/types/rsc/shell-build-manifest.d.ts +6 -4
  60. package/dist/types/rsc/shell-capture-constants.d.ts +20 -5
  61. package/dist/types/rsc/shell-capture.d.ts +14 -1
  62. package/dist/types/rsc/shell-serve.d.ts +2 -2
  63. package/dist/types/rsc/ssr-module-loader.d.ts +15 -0
  64. package/dist/types/rsc/types.d.ts +8 -0
  65. package/dist/types/segment-boundary-content.d.ts +17 -0
  66. package/dist/types/segment-loader-promise.d.ts +2 -6
  67. package/dist/types/server/build-version-table.d.ts +9 -0
  68. package/dist/types/server/context.d.ts +12 -4
  69. package/dist/types/server/handle-store.d.ts +2 -0
  70. package/dist/types/server/request-context.d.ts +63 -2
  71. package/dist/types/suspense-audit-react.d.ts +26 -0
  72. package/dist/types/suspense-audit.d.ts +157 -0
  73. package/dist/types/testing/action-form.d.ts +34 -0
  74. package/dist/types/testing/flight.entry.d.ts +2 -0
  75. package/dist/types/testing/index.d.ts +4 -0
  76. package/dist/types/testing/internal/server-modules.d.ts +8 -0
  77. package/dist/types/testing/serve-shell-request.d.ts +34 -4
  78. package/dist/types/testing/shell-status.d.ts +14 -6
  79. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +9 -6
  80. package/dist/types/thenable-status.d.ts +6 -0
  81. package/dist/types/types/handler-context.d.ts +7 -0
  82. package/dist/types/types/route-entry.d.ts +5 -0
  83. package/dist/types/urls/path-helper.d.ts +18 -0
  84. package/dist/types/vite/discovery/state.d.ts +11 -0
  85. package/dist/types/vite/inject-client-debug.d.ts +6 -25
  86. package/dist/types/vite/utils/bundle-analysis.d.ts +6 -2
  87. package/dist/vite/index.js +180 -497
  88. package/package.json +13 -3
  89. package/skills/cache-guide/SKILL.md +18 -17
  90. package/skills/caching/SKILL.md +40 -4
  91. package/skills/catalog.json +1 -1
  92. package/skills/cloudflare/SKILL.md +131 -0
  93. package/skills/comparison/references/framework-comparison.md +1 -1
  94. package/skills/deployment-caching/SKILL.md +33 -12
  95. package/skills/document-cache/SKILL.md +16 -14
  96. package/skills/hooks/SKILL.md +22 -0
  97. package/skills/hooks/handle-and-actions.md +3 -4
  98. package/skills/hooks/navigation.md +10 -0
  99. package/skills/hooks/state.md +5 -4
  100. package/skills/host-router/SKILL.md +18 -0
  101. package/skills/loader/SKILL.md +6 -4
  102. package/skills/middleware/SKILL.md +1 -1
  103. package/skills/migrate-nextjs/SKILL.md +82 -9
  104. package/skills/ppr/SKILL.md +31 -8
  105. package/skills/prerender/SKILL.md +645 -8
  106. package/skills/rango/SKILL.md +1 -0
  107. package/skills/response-routes/SKILL.md +5 -5
  108. package/skills/route/SKILL.md +29 -0
  109. package/skills/testing/SKILL.md +2 -1
  110. package/skills/testing/cache-prerender.md +18 -17
  111. package/skills/testing/serve-shell-request.md +104 -8
  112. package/skills/use-cache/SKILL.md +9 -5
  113. package/skills/vercel/SKILL.md +2 -0
  114. package/skills/view-transitions/SKILL.md +9 -4
  115. package/src/browser/event-controller.ts +69 -25
  116. package/src/browser/link-interceptor.ts +2 -2
  117. package/src/browser/navigation-bridge.ts +75 -35
  118. package/src/browser/navigation-store.ts +26 -0
  119. package/src/browser/navigation-transaction.ts +9 -2
  120. package/src/browser/network-error-handler.ts +2 -0
  121. package/src/browser/partial-update.ts +48 -12
  122. package/src/browser/prefetch/default-strategy.ts +22 -42
  123. package/src/browser/react/Link.tsx +37 -42
  124. package/src/browser/react/NavigationProvider.tsx +63 -3
  125. package/src/browser/react/context.ts +17 -0
  126. package/src/browser/react/location-state.ts +5 -19
  127. package/src/browser/react/use-action.ts +4 -1
  128. package/src/browser/react/use-handle.ts +4 -20
  129. package/src/browser/react/use-link-status.ts +4 -3
  130. package/src/browser/react/use-navigation.ts +4 -1
  131. package/src/browser/react/use-params.ts +4 -1
  132. package/src/browser/react/use-pathname.ts +4 -1
  133. package/src/browser/react/use-search-params.ts +5 -3
  134. package/src/browser/react/use-segments.ts +36 -54
  135. package/src/browser/rsc-router.tsx +79 -39
  136. package/src/browser/segment-reconciler.ts +3 -3
  137. package/src/browser/server-action-bridge.ts +26 -6
  138. package/src/browser/types.ts +11 -0
  139. package/src/build/generate-manifest.ts +17 -0
  140. package/src/build/merge-full-manifests.ts +5 -0
  141. package/src/build/route-trie.ts +1 -0
  142. package/src/cache/background-task.ts +61 -0
  143. package/src/cache/cache-key-utils.ts +91 -12
  144. package/src/cache/cache-policy.ts +11 -2
  145. package/src/cache/cache-runtime.ts +23 -23
  146. package/src/cache/cache-scope.ts +38 -36
  147. package/src/cache/cf/cf-cache-store.ts +16 -11
  148. package/src/cache/document-cache.ts +35 -14
  149. package/src/cache/handle-snapshot.ts +50 -0
  150. package/src/cache/index.ts +1 -0
  151. package/src/cache/invalidation-order.ts +53 -0
  152. package/src/cache/memory-segment-store.ts +52 -1
  153. package/src/cache/shell-snapshot.ts +17 -0
  154. package/src/cache/store-scope.ts +36 -0
  155. package/src/cache/tag-invalidation.ts +7 -5
  156. package/src/cache/types.ts +69 -14
  157. package/src/cache/vercel/vercel-cache-store.ts +20 -6
  158. package/src/client.tsx +40 -3
  159. package/src/errors.ts +13 -6
  160. package/src/handles/deferred-resolution.ts +7 -2
  161. package/src/internal-suspense-audit.ts +12 -0
  162. package/src/loader-store.ts +4 -9
  163. package/src/outlet-provider.tsx +25 -1
  164. package/src/prerender/cloudflare.ts +130 -0
  165. package/src/prerender/create-prerender-trigger.ts +780 -0
  166. package/src/prerender/index.ts +39 -0
  167. package/src/prerender/memory-prerender-store.ts +115 -0
  168. package/src/prerender/on-demand.ts +339 -0
  169. package/src/prerender/producer-guard.ts +66 -0
  170. package/src/prerender/shell-manifest-key.ts +12 -17
  171. package/src/prerender/warm-request.ts +154 -0
  172. package/src/prerender/warm.ts +193 -0
  173. package/src/prerender/writable-store.ts +278 -0
  174. package/src/prerender.ts +29 -0
  175. package/src/reverse.ts +1 -1
  176. package/src/route-content-wrapper.tsx +107 -13
  177. package/src/router/error-handling.ts +12 -12
  178. package/src/router/find-match.ts +1 -0
  179. package/src/router/handler-context.ts +2 -0
  180. package/src/router/intercept-resolution.ts +15 -8
  181. package/src/router/loader-resolution.ts +2 -10
  182. package/src/router/match-middleware/background-revalidation.ts +2 -0
  183. package/src/router/match-middleware/cache-lookup.ts +230 -38
  184. package/src/router/pattern-matching.ts +14 -0
  185. package/src/router/prefetch-default.ts +4 -4
  186. package/src/router/prerender-match.ts +460 -352
  187. package/src/router/route-trie-builder.ts +9 -0
  188. package/src/router/router-context.ts +1 -0
  189. package/src/router/router-interfaces.ts +33 -0
  190. package/src/router/router-options.ts +30 -0
  191. package/src/router/segment-resolution/fresh.ts +20 -19
  192. package/src/router/segment-resolution/loader-cache.ts +15 -17
  193. package/src/router/segment-resolution/revalidation.ts +34 -24
  194. package/src/router/segment-resolution/streamed-handler-boundary.ts +133 -0
  195. package/src/router/trie-matching.ts +3 -0
  196. package/src/router.ts +107 -1
  197. package/src/rsc/handler.ts +51 -4
  198. package/src/rsc/progressive-enhancement.ts +5 -1
  199. package/src/rsc/response-cache-serve.ts +10 -4
  200. package/src/rsc/rsc-rendering.ts +53 -11
  201. package/src/rsc/shell-build-manifest.ts +10 -8
  202. package/src/rsc/shell-capture-constants.ts +39 -7
  203. package/src/rsc/shell-capture.ts +108 -19
  204. package/src/rsc/shell-serve.ts +12 -6
  205. package/src/rsc/ssr-module-loader.ts +26 -0
  206. package/src/rsc/types.ts +8 -0
  207. package/src/segment-boundary-content.ts +25 -0
  208. package/src/segment-loader-promise.ts +28 -38
  209. package/src/segment-system.tsx +117 -57
  210. package/src/server/build-version-table.ts +15 -0
  211. package/src/server/context.ts +40 -17
  212. package/src/server/cookie-store.ts +5 -0
  213. package/src/server/handle-store.ts +13 -0
  214. package/src/server/loader-registry.ts +6 -1
  215. package/src/server/request-context.ts +87 -1
  216. package/src/ssr/ssr-root.tsx +20 -13
  217. package/src/suspense-audit-react.tsx +98 -0
  218. package/src/suspense-audit.ts +1030 -0
  219. package/src/testing/action-form.ts +66 -0
  220. package/src/testing/flight-runtime.d.ts +27 -0
  221. package/src/testing/flight.entry.ts +3 -0
  222. package/src/testing/index.ts +19 -0
  223. package/src/testing/internal/server-modules.ts +32 -0
  224. package/src/testing/render-route.tsx +25 -12
  225. package/src/testing/serve-shell-request.ts +190 -34
  226. package/src/testing/shell-status.ts +17 -6
  227. package/src/testing/vitest-stubs/plugin-rsc.ts +37 -11
  228. package/src/thenable-status.ts +10 -0
  229. package/src/types/handler-context.ts +7 -0
  230. package/src/types/route-entry.ts +6 -0
  231. package/src/urls/path-helper.ts +69 -9
  232. package/src/use-loader.tsx +57 -30
  233. package/src/vite/discovery/bundle-postprocess.ts +4 -0
  234. package/src/vite/discovery/discover-routers.ts +15 -0
  235. package/src/vite/discovery/shell-prerender-phase.ts +5 -5
  236. package/src/vite/discovery/state.ts +17 -1
  237. package/src/vite/inject-client-debug.ts +42 -20
  238. package/src/vite/plugins/virtual-entries.ts +8 -1
  239. package/src/vite/router-discovery.ts +14 -0
  240. package/src/vite/utils/bundle-analysis.ts +39 -7
  241. package/dist/types/browser/react/subscribe-to-nothing.d.ts +0 -7
  242. package/dist/types/segment-content-promise.d.ts +0 -13
  243. package/src/browser/react/subscribe-to-nothing.ts +0 -7
  244. package/src/segment-content-promise.ts +0 -67
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type {
9
+ CacheStoreScope,
9
10
  SegmentCacheStore,
10
11
  CachedEntryData,
11
12
  CacheDefaults,
@@ -23,6 +24,7 @@ import {
23
24
  DEFAULT_FUNCTION_TTL,
24
25
  } from "./cache-policy.js";
25
26
  import { reportCacheError } from "./cache-error.js";
27
+ import { responseStartedAt } from "./invalidation-order.js";
26
28
 
27
29
  const CACHE_REGISTRY_KEY = "__rsc_router_segment_cache_registry__";
28
30
  const RESPONSE_CACHE_REGISTRY_KEY = "__rsc_router_response_cache_registry__";
@@ -146,6 +148,26 @@ export interface MemorySegmentCacheStoreOptions<TEnv = unknown> {
146
148
  * ```
147
149
  */
148
150
  maxEntries?: number;
151
+
152
+ /**
153
+ * Where an entry this store writes can be read; what `router.prerender()`
154
+ * checks before warming a route. Defaults to `"local"`: the maps live in one
155
+ * process, and the router cannot tell one long-running server from several
156
+ * replicas or serverless instances, so a warm into it is refused in
157
+ * production. Set `"global"` only when exactly one process serves the app
158
+ * (a single Node server); a warm then reports `warmed`. Under the Vite dev
159
+ * server a `"local"` instance already counts as shared.
160
+ *
161
+ * Like `defaults` and `keyGenerator`, the scope is per instance: two
162
+ * instances with the same `name` share their maps but each declares its own
163
+ * scope. Keep them identical.
164
+ *
165
+ * @example
166
+ * ```typescript
167
+ * const store = new MemorySegmentCacheStore({ scope: "global" });
168
+ * ```
169
+ */
170
+ scope?: CacheStoreScope;
149
171
  }
150
172
 
151
173
  /** Default per-family entry cap for MemorySegmentCacheStore (FIFO eviction). */
@@ -188,6 +210,13 @@ const DEFAULT_MAX_ENTRIES = 1000;
188
210
  export class MemorySegmentCacheStore<
189
211
  TEnv = unknown,
190
212
  > implements SegmentCacheStore<TEnv> {
213
+ /**
214
+ * SegmentCacheStore.scope: "local" unless `options.scope` says otherwise, so
215
+ * router.prerender() refuses to warm into these maps in production. Under
216
+ * the Vite dev server the gate counts a "local" instance as shared
217
+ * (store-scope.ts resolveWarmStoreScope).
218
+ */
219
+ readonly scope: CacheStoreScope;
191
220
  readonly supportsPassiveShellReads: true = true;
192
221
  private cache: Map<string, CachedEntryData>;
193
222
  private responseCache: Map<string, CachedResponseEntry>;
@@ -253,6 +282,7 @@ export class MemorySegmentCacheStore<
253
282
  this.defaults = options?.defaults;
254
283
  this.keyGenerator = options?.keyGenerator;
255
284
  this.maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
285
+ this.scope = options?.scope ?? "local";
256
286
  }
257
287
 
258
288
  /**
@@ -300,6 +330,7 @@ export class MemorySegmentCacheStore<
300
330
  ttl: number,
301
331
  _swr?: number,
302
332
  ): Promise<void> {
333
+ if (this.invalidatedAfter(data.tags, data.taggedAt)) return;
303
334
  const entry: CachedEntryData = {
304
335
  ...data,
305
336
  expiresAt: Date.now() + ttl * 1000,
@@ -358,6 +389,7 @@ export class MemorySegmentCacheStore<
358
389
  tags?: string[],
359
390
  ): Promise<void> {
360
391
  try {
392
+ const startedAt = responseStartedAt(response);
361
393
  const body = await response.clone().arrayBuffer();
362
394
  const headers: [string, string][] = [];
363
395
  response.headers.forEach((value, name) => {
@@ -368,6 +400,8 @@ export class MemorySegmentCacheStore<
368
400
  const swrWindow = resolveSwrWindow(swr, this.defaults);
369
401
  const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
370
402
 
403
+ // After the last await before the write.
404
+ if (this.invalidatedAfter(tags, startedAt)) return;
371
405
  const prefixedKey = `res:${key}`;
372
406
  this.unregisterTags(prefixedKey);
373
407
  this.evictIfNeeded(this.responseCache, key, "res");
@@ -415,6 +449,7 @@ export class MemorySegmentCacheStore<
415
449
  value: string,
416
450
  options?: CacheItemOptions,
417
451
  ): Promise<void> {
452
+ if (this.invalidatedAfter(options?.tags, options?.startedAt)) return;
418
453
  const ttl = resolveTtl(options?.ttl, this.defaults, DEFAULT_FUNCTION_TTL);
419
454
  const swrWindow = resolveSwrWindow(options?.swr, this.defaults);
420
455
  const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
@@ -486,7 +521,23 @@ export class MemorySegmentCacheStore<
486
521
  return this.tagsInvalidatedSince(tags, sinceMs);
487
522
  }
488
523
 
489
- private tagsInvalidatedSince(tags: string[], sinceMs: number): boolean {
524
+ /**
525
+ * A tag invalidated in a millisecond after the write's execution started
526
+ * (#1068). Strictly after: the start's own millisecond is ambiguous, and
527
+ * invalidation-order.ts orders those exactly.
528
+ */
529
+ private invalidatedAfter(
530
+ tags: readonly string[] | undefined,
531
+ startedAt: number | undefined,
532
+ ): boolean {
533
+ if (!tags || tags.length === 0 || startedAt === undefined) return false;
534
+ return this.tagsInvalidatedSince(tags, startedAt + 1);
535
+ }
536
+
537
+ private tagsInvalidatedSince(
538
+ tags: readonly string[],
539
+ sinceMs: number,
540
+ ): boolean {
490
541
  for (const tag of tags) {
491
542
  const at = this.tagInvalidatedAt.get(tag);
492
543
  // >= so a same-millisecond invalidation wins (freshness over staleness),
@@ -32,6 +32,7 @@ import type {
32
32
  ShellCacheEntry,
33
33
  ShellSnapshotRecord,
34
34
  ShellSnapshotLoaderValue,
35
+ ShellSnapshotHandlesValue,
35
36
  CacheReadError,
36
37
  } from "./types.js";
37
38
  import { settleGrowing } from "./background-task.js";
@@ -452,6 +453,22 @@ export async function buildShellLoaderSeed(
452
453
  return seed.size > 0 ? seed : undefined;
453
454
  }
454
455
 
456
+ /** The key of a snapshot's one `handles` record (ShellSnapshotHandlesValue). */
457
+ export const SHELL_HANDLES_RECORD_KEY = "handles";
458
+
459
+ /**
460
+ * The `handles` record of a prerender-served entry, which a HIT tail restores
461
+ * after the prerender store's handles (cache-lookup.ts yieldFromStore), or
462
+ * undefined: an entry with a doc record, or one written before the record
463
+ * existed (v0.21), which serves as it did.
464
+ */
465
+ export function shellPrerenderHandles(
466
+ snapshot: readonly ShellSnapshotRecord[],
467
+ ): ShellSnapshotHandlesValue | undefined {
468
+ const record = snapshot.find((r) => r?.family === "handles");
469
+ return record?.value as ShellSnapshotHandlesValue | undefined;
470
+ }
471
+
455
472
  /**
456
473
  * The implicit doc scope's store on a HIT tail and on a partial replay: it
457
474
  * serves the snapshot's segment records, the doc record among them, and
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The store scope `router.prerender()` gates a warm on
3
+ * (prerender/create-prerender-trigger.ts). See
4
+ * docs/design/prerender-every-route.md, "The store declaration".
5
+ */
6
+
7
+ import type { CacheStoreScope, SegmentCacheStore } from "./types.js";
8
+ import { MemorySegmentCacheStore } from "./memory-segment-store.js";
9
+
10
+ /**
11
+ * True under the Vite dev server: only its routes-manifest virtual module sets
12
+ * the dev origin (vite/discovery/virtual-module-codegen.ts). A build, a preview
13
+ * server and a test runner answer false.
14
+ */
15
+ export function isViteDevServer(): boolean {
16
+ return typeof globalThis.__PRERENDER_DEV_URL === "string";
17
+ }
18
+
19
+ /**
20
+ * The scope a warm treats `store` as having: its own declaration, with none
21
+ * or an unknown value being `"local"`.
22
+ *
23
+ * One exception: under the Vite dev server a MemorySegmentCacheStore answers
24
+ * `"global"`, because that one process serves every request. Production reads
25
+ * the class's own `scope` (`"local"`): changing that declaration is what
26
+ * would admit it there.
27
+ */
28
+ export function resolveWarmStoreScope(
29
+ store: SegmentCacheStore<any>,
30
+ devServer: boolean = isViteDevServer(),
31
+ ): CacheStoreScope {
32
+ const declared = store.scope;
33
+ if (declared === "global" || declared === "regional") return declared;
34
+ if (devServer && store instanceof MemorySegmentCacheStore) return "global";
35
+ return "local";
36
+ }
@@ -49,6 +49,7 @@ import {
49
49
  export {
50
50
  executionStart,
51
51
  invalidatedSince,
52
+ markResponseStart,
52
53
  type ExecutionStart,
53
54
  } from "./invalidation-order.js";
54
55
 
@@ -147,11 +148,12 @@ function markFreshReads(stores: SegmentCacheStore[]): void {
147
148
  * The write gate of every cache execution (#977): whether one of `tags` was
148
149
  * invalidated after the execution that produced the value started.
149
150
  *
150
- * Every built-in store stamps an entry when it is written (CFCacheStore's
151
- * `taggedAt`, VercelCacheStore's `ta`) or checks nothing on write
152
- * (MemorySegmentCacheStore), so a value read before an invalidation and
153
- * written after it would be served as newer than the invalidation until it
154
- * expires. Two answers, either one skips the write:
151
+ * A store stamps an entry with the execution's start (CFCacheStore's
152
+ * `taggedAt`, VercelCacheStore's `ta`, #1068), but only CFCacheStore with KV
153
+ * markers rejects it on a later request's read, and MemorySegmentCacheStore
154
+ * refuses the write. The gate saves the write everywhere and is the only
155
+ * guard where a read compares nothing. Two answers, either one skips the
156
+ * write:
155
157
  *
156
158
  * - this isolate's order (invalidatedSince), which sees every request's
157
159
  * updateTag()/revalidateTag() here, the calling one included;
@@ -42,6 +42,18 @@ export interface CacheGetResult {
42
42
  shouldRevalidate: boolean;
43
43
  }
44
44
 
45
+ /**
46
+ * Where an entry a store writes during one request can be read by a later
47
+ * request. `router.prerender()` warms a route only when the app store answers
48
+ * `"global"` or `"regional"`: a warm fills the store where it runs, which
49
+ * serves other traffic only when the store is shared beyond that place.
50
+ * - `"global"`: every location reads the same entries.
51
+ * - `"regional"`: each region has its own copy; a warm fills the region it
52
+ * runs in.
53
+ * - `"local"`: only the process, isolate or edge location that wrote it.
54
+ */
55
+ export type CacheStoreScope = "global" | "regional" | "local";
56
+
45
57
  /**
46
58
  * Low-level segment cache store interface.
47
59
  *
@@ -59,6 +71,12 @@ export interface CacheGetResult {
59
71
  * @typeParam TEnv - Platform bindings type (e.g., Cloudflare env)
60
72
  */
61
73
  export interface SegmentCacheStore<TEnv = unknown> {
74
+ /**
75
+ * Where an entry this store writes can be read ({@link CacheStoreScope}).
76
+ * Absent: unknown to the router, treated as `"local"`.
77
+ */
78
+ readonly scope?: CacheStoreScope;
79
+
62
80
  /**
63
81
  * The store honors getShell(..., { claimRevalidation: false }) without
64
82
  * claiming an SWR lock. Navigation replay requires this opt-in because it
@@ -168,6 +186,11 @@ export interface SegmentCacheStore<TEnv = unknown> {
168
186
  * @param ttl - Time-to-live in seconds
169
187
  * @param swr - Optional stale-while-revalidate window in seconds
170
188
  * @param tags - Optional cache tags for invalidation
189
+ *
190
+ * The built-in stores read the start of the render from the `Response`
191
+ * object itself (#1068). A custom store, or a wrapper that rebuilds the
192
+ * `Response` before delegating, stamps write time. The write gate
193
+ * (`predatesInvalidation`) runs before this call either way.
171
194
  */
172
195
  putResponse?(
173
196
  key: string,
@@ -451,9 +474,10 @@ export interface ShellCacheEntry {
451
474
  * bake-lane loader pins (pruned to what a HIT reads, issue #941). Replaying
452
475
  * them on a HIT keeps the freshly rendered hydration payload equal to the
453
476
  * frozen prelude after the underlying cache entries have drifted; every
454
- * other read on a HIT, a hole's included, reads the store. Empty when the capture
455
- * recorded nothing (a prerender-served capture without bake-lane loaders,
456
- * a tombstone). A custom store returns it as putShell received it. See
477
+ * other read on a HIT, a hole's included, reads the store. A
478
+ * prerender-served capture holds a `handles` record instead of a doc record
479
+ * (ShellSnapshotHandlesValue). Empty for a tombstone. A custom store
480
+ * returns it as putShell received it. See
457
481
  * docs/design/ppr-shell-resume.md ("the capture data snapshot").
458
482
  */
459
483
  snapshot: ShellSnapshotRecord[];
@@ -614,12 +638,26 @@ export interface ShellReadStats {
614
638
 
615
639
  /**
616
640
  * The families a shell snapshot pins: the doc segment record (recorded by
617
- * RecordingShellStore) and the settled CONTAINER of each bake-lane loader
618
- * (lane rule: see resolveLoaderData, loader-cache.ts). No cache read is
619
- * pinned: a HIT's holes, and a bake-lane loader body that runs on the HIT,
620
- * read the store.
641
+ * RecordingShellStore), the settled CONTAINER of each bake-lane loader
642
+ * (lane rule: see resolveLoaderData, loader-cache.ts), and a prerender-served
643
+ * entry's `handles` record. No cache read is pinned: a HIT's holes, and a bake-lane
644
+ * loader body that runs on the HIT, read the store.
621
645
  */
622
- export type ShellSnapshotFamily = "segment" | "loader";
646
+ export type ShellSnapshotFamily = "segment" | "loader" | "handles";
647
+
648
+ /**
649
+ * The stored form of the `handles` family: the handle arrays of a
650
+ * prerender-served capture that hold a loader-owned push, in the doc record's
651
+ * format (CachedEntryData `handles` + `handleOwners`). A Prerender + ppr
652
+ * route's handler layer comes from the prerender store, whose build-time
653
+ * entry has no loader push, so the entry keeps these for the HIT to restore
654
+ * as a doc record's (issue #1057, yieldFromStore in cache-lookup.ts). An
655
+ * entry written before it (v0.21) has none and serves as it did.
656
+ */
657
+ export type ShellSnapshotHandlesValue = Pick<
658
+ CachedEntryData,
659
+ "handles" | "handleOwners"
660
+ >;
623
661
 
624
662
  /**
625
663
  * The stored form of a loader-family snapshot value: the bake-lane loader's
@@ -654,17 +692,18 @@ export interface ShellSnapshotLoaderValue {
654
692
  }
655
693
 
656
694
  /**
657
- * One snapshot record: the doc segment record or a bake-lane loader pin.
658
- * `value` carries it in its stored/serialized shape so it round-trips
659
- * through a JSON-serializing store (KV, CF, Vercel) with the rest of the
660
- * ShellCacheEntry:
695
+ * One snapshot record: the doc segment record, a bake-lane loader pin, or a
696
+ * prerender-served entry's handle record. `value` carries it in its
697
+ * stored/serialized shape so it round-trips through a JSON-serializing store
698
+ * (KV, CF, Vercel) with the rest of the ShellCacheEntry:
661
699
  * - `segment` -> {@link CachedEntryData} (already JSON-able)
662
700
  * - `loader` -> {@link ShellSnapshotLoaderValue}
701
+ * - `handles` -> {@link ShellSnapshotHandlesValue}
663
702
  */
664
703
  export interface ShellSnapshotRecord {
665
704
  family: ShellSnapshotFamily;
666
705
  key: string;
667
- value: CachedEntryData | ShellSnapshotLoaderValue;
706
+ value: CachedEntryData | ShellSnapshotLoaderValue | ShellSnapshotHandlesValue;
668
707
  }
669
708
 
670
709
  /**
@@ -679,6 +718,17 @@ export interface CacheItemOptions {
679
718
  swr?: number;
680
719
  /** Cache tags for invalidation */
681
720
  tags?: string[];
721
+ /**
722
+ * Ms since epoch when the execution that produced the value started (#1068).
723
+ * A store stamps a tagged entry with it instead of its write time. Later
724
+ * requests then reject an entry an invalidation landed under in
725
+ * CFCacheStore with KV markers; MemorySegmentCacheStore refuses the write.
726
+ * Not rejected: VercelCacheStore (a read compares the stamp with the
727
+ * invalidating request's mask only) and CFCacheStore L1 hits in purge mode
728
+ * or without KV. Absent or not positive: write time. A custom store may
729
+ * ignore it.
730
+ */
731
+ startedAt?: number;
682
732
  }
683
733
 
684
734
  /**
@@ -726,7 +776,12 @@ export interface CachedEntryData {
726
776
  expiresAt: number;
727
777
  /** Cache tags for invalidation */
728
778
  tags?: string[];
729
- /** Timestamp (ms since epoch) when tags were attached, for distributed invalidation */
779
+ /**
780
+ * Timestamp (ms since epoch) the entry's tags are valid as of, for
781
+ * distributed invalidation. The writer sets it to the start of the render
782
+ * that produced the record; a store stamps write time when it is absent
783
+ * (#1068). Which reads reject on it: see `CacheItemOptions.startedAt`.
784
+ */
730
785
  taggedAt?: number;
731
786
  }
732
787
 
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  import type {
38
+ CacheStoreScope,
38
39
  SegmentCacheStore,
39
40
  CachedEntryData,
40
41
  CacheDefaults,
@@ -106,6 +107,7 @@ import {
106
107
  type MarkerFetch,
107
108
  type MarkerMemoOutcome,
108
109
  } from "../isolate-tag-memo.js";
110
+ import { responseStartedAt, taggedStamp } from "../invalidation-order.js";
109
111
 
110
112
  /**
111
113
  * Minimal structural shape of the Vercel Runtime Cache returned by `getCache()`
@@ -454,9 +456,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
454
456
  return typeof value === "object" && value !== null;
455
457
  }
456
458
 
457
- /** The envelope's `ta` write stamp, on tagged entries only. */
458
- function tagStamp(tags: string[]): { ta?: number } {
459
- return tags.length > 0 ? { ta: Date.now() } : {};
459
+ /**
460
+ * The envelope's `ta` stamp, on tagged entries only: the producing execution's
461
+ * start when the writer passed it, else write time (#1068). Only the request
462
+ * mask reads it (expireTag keeps no queryable history), so it decides whether
463
+ * the invalidating request's own late write is masked.
464
+ */
465
+ function tagStamp(tags: string[], startedAt?: number): { ta?: number } {
466
+ const ta = taggedStamp(tags, startedAt);
467
+ return ta === undefined ? {} : { ta };
460
468
  }
461
469
 
462
470
  /**
@@ -512,6 +520,12 @@ function tagStamp(tags: string[]): { ta?: number } {
512
520
  export class VercelCacheStore<
513
521
  TEnv = unknown,
514
522
  > implements SegmentCacheStore<TEnv> {
523
+ /**
524
+ * SegmentCacheStore.scope: the Runtime Cache is per region. A
525
+ * router.prerender() warm fills the region it runs in, which is all traffic
526
+ * only when the project's functions run in one region.
527
+ */
528
+ readonly scope: CacheStoreScope = "regional";
515
529
  readonly supportsPassiveShellReads: true = true;
516
530
  readonly defaults?: CacheDefaults;
517
531
  readonly keyGenerator?: (
@@ -649,7 +663,7 @@ export class VercelCacheStore<
649
663
  d,
650
664
  s: staleAt,
651
665
  e: expiresAt,
652
- ...tagStamp(safeTags),
666
+ ...tagStamp(safeTags, data.taggedAt),
653
667
  };
654
668
  await this.write(
655
669
  this.toStoreKey(key, "s"),
@@ -797,7 +811,7 @@ export class VercelCacheStore<
797
811
  s: staleAt,
798
812
  e: expiresAt,
799
813
  t: safeTags.length > 0 ? safeTags : undefined,
800
- ...tagStamp(safeTags),
814
+ ...tagStamp(safeTags, responseStartedAt(response)),
801
815
  };
802
816
  await this.write(
803
817
  this.toStoreKey(key, "r"),
@@ -903,7 +917,7 @@ export class VercelCacheStore<
903
917
  s: staleAt,
904
918
  e: expiresAt,
905
919
  t: safeTags.length > 0 ? safeTags : undefined,
906
- ...tagStamp(safeTags),
920
+ ...tagStamp(safeTags, options?.startedAt),
907
921
  };
908
922
  await this.write(
909
923
  this.toStoreKey(key, "i"),
package/src/client.tsx CHANGED
@@ -3,9 +3,11 @@
3
3
  import {
4
4
  Component,
5
5
  createElement,
6
+ isValidElement,
6
7
  useContext,
7
8
  useMemo,
8
9
  Suspense,
10
+ type ComponentProps,
9
11
  type ReactNode,
10
12
  } from "react";
11
13
  // Not `export * as Html`: plugin-rsc's "use client" proxy transform only
@@ -19,12 +21,15 @@ import {
19
21
  type ResolvedSegment,
20
22
  } from "./types";
21
23
  import {
24
+ AuditedLoaderBoundary,
25
+ AuditedRouteContent,
22
26
  RouteContentWrapper,
23
27
  LoaderBoundary,
24
28
  } from "./route-content-wrapper.js";
25
- import { OutletProvider } from "./outlet-provider.js";
29
+ import { AuditedOutletProvider, OutletProvider } from "./outlet-provider.js";
30
+ import { INTERNAL_RANGO_SUSPENSE_AUDIT } from "./internal-suspense-audit.js";
26
31
  import { MountContextProvider } from "./browser/react/mount-context.js";
27
- import { getMemoizedContentPromise } from "./segment-content-promise.js";
32
+ import { getBoundaryContent } from "./segment-boundary-content.js";
28
33
 
29
34
  /**
30
35
  * Render the content for a named parallel/intercept slot segment.
@@ -39,7 +44,7 @@ function renderSlotContent(segment: ResolvedSegment | null): ReactNode {
39
44
  const content: ReactNode =
40
45
  segment.loading || segment.component instanceof Promise ? (
41
46
  <RouteContentWrapper
42
- content={getMemoizedContentPromise(segment.component)}
47
+ content={getBoundaryContent(segment.component)}
43
48
  fallback={segment.loading}
44
49
  segmentId={segment.id}
45
50
  />
@@ -83,6 +88,10 @@ function renderSlotContent(segment: ResolvedSegment | null): ReactNode {
83
88
  result = content;
84
89
  }
85
90
 
91
+ if (process.env.NODE_ENV !== "production" && INTERNAL_RANGO_SUSPENSE_AUDIT) {
92
+ result = auditedSlot(result);
93
+ }
94
+
86
95
  if (segment.mountPath) {
87
96
  return (
88
97
  <MountContextProvider value={segment.mountPath}>
@@ -94,6 +103,34 @@ function renderSlotContent(segment: ResolvedSegment | null): ReactNode {
94
103
  return result;
95
104
  }
96
105
 
106
+ // Dev only (suspense-audit.ts): the boundaries renderSlotContent built, as
107
+ // their audited components with the same props. After the fact, not a choice
108
+ // per JSX element: a build must fold to the product elements alone (see
109
+ // AuditedRouteContent, route-content-wrapper.tsx).
110
+ function auditedSlot(node: ReactNode): ReactNode {
111
+ if (!isValidElement(node)) return node;
112
+ const props = node.props as Record<string, any>;
113
+ if (node.type === OutletProvider) {
114
+ return createElement(AuditedOutletProvider, {
115
+ ...(props as ComponentProps<typeof OutletProvider>),
116
+ content: auditedSlot(props.content),
117
+ });
118
+ }
119
+ if (node.type === LoaderBoundary) {
120
+ return createElement(AuditedLoaderBoundary, {
121
+ ...(props as ComponentProps<typeof LoaderBoundary>),
122
+ children: auditedSlot(props.children),
123
+ });
124
+ }
125
+ if (node.type === RouteContentWrapper) {
126
+ return createElement(
127
+ AuditedRouteContent,
128
+ props as ComponentProps<typeof RouteContentWrapper>,
129
+ );
130
+ }
131
+ return node;
132
+ }
133
+
97
134
  function useSlotSegment(
98
135
  context: OutletContextValue | null,
99
136
  name: `@${string}` | undefined,
package/src/errors.ts CHANGED
@@ -356,6 +356,18 @@ export function invariant(condition: any, message: string): asserts condition {
356
356
  * @param error - Error to sanitize
357
357
  * @returns Response suitable for sending to client
358
358
  */
359
+ /**
360
+ * Vite replaces import.meta.env.DEV at compile time. The fallback covers
361
+ * non-Vite environments (plain Node, test runners without Vite transforms).
362
+ * SECURITY: fail closed — default to production when the environment is ambiguous.
363
+ */
364
+ export function isDevEnvironment(): boolean {
365
+ return (
366
+ (import.meta as any).env?.DEV ??
367
+ globalThis.process?.env?.NODE_ENV === "development"
368
+ );
369
+ }
370
+
359
371
  export function sanitizeError(error: unknown): Response {
360
372
  // CRITICAL: Consume stack trace to prevent memory leaks
361
373
  if (error && typeof error === "object" && "stack" in error) {
@@ -367,12 +379,7 @@ export function sanitizeError(error: unknown): Response {
367
379
  return error;
368
380
  }
369
381
 
370
- // Vite replaces import.meta.env.DEV at compile time. The fallback covers
371
- // non-Vite environments (plain Node, test runners without Vite transforms).
372
- // SECURITY: fail closed — default to production when the environment is ambiguous.
373
- const isDev =
374
- (import.meta as any).env?.DEV ??
375
- globalThis.process?.env?.NODE_ENV === "development";
382
+ const isDev = isDevEnvironment();
376
383
 
377
384
  if (isDev) {
378
385
  // Development: Send full error details for debugging
@@ -124,8 +124,13 @@ export async function* resolvedHandleStream(
124
124
  yield await resolveDeferredHandleValues(snapshot);
125
125
  }
126
126
 
127
- /** `snapshot` without the pushes a record leaves out (resolvedHandleStream). */
128
- function recordedHandleData(
127
+ /**
128
+ * `snapshot` without the pushes a record leaves out. A shell capture renders
129
+ * (resolvedHandleStream) and waits (settleCaptureRecord) on this cut only: a
130
+ * promise the record drops has no slot in the prelude, and waiting for it
131
+ * could refuse a capture for a value the shell does not hold.
132
+ */
133
+ export function recordedHandleData(
129
134
  handleStore: HandleStore,
130
135
  snapshot: HandleData,
131
136
  ): HandleData {
@@ -0,0 +1,12 @@
1
+ // Turns on the suspense audit (suspense-audit.ts) in a dev build. Only this
2
+ // repo sets INTERNAL_RANGO_SUSPENSE_AUDIT: the e2e webServers and fixtures of
3
+ // the router test-app and cloudflare-basic, and the router's vitest config.
4
+ // A consumer's dev server and tests never print the audit's errors.
5
+ //
6
+ // Runtime fallback for non-Vite contexts (vitest, Node). In the Vite pipeline
7
+ // the discovery plugin replaces this module with the build-time-resolved flag
8
+ // (vite/inject-client-debug.ts), as it does for internal-debug.ts. Keep this
9
+ // module to the single export: the transform replaces the whole file.
10
+ export const INTERNAL_RANGO_SUSPENSE_AUDIT: boolean =
11
+ typeof process !== "undefined" &&
12
+ Boolean((process as any).env?.INTERNAL_RANGO_SUSPENSE_AUDIT);
@@ -29,9 +29,10 @@
29
29
  * reclaim a bucket that is about to be reused, and is held until any
30
30
  * in-flight load settles.
31
31
  *
32
- * The store is intentionally module-level: each browser tab is its own JS
33
- * realm, so there is no cross-request pollution. Server renders never mutate
34
- * the store — the hook falls back to `OutletContext.loaderData`.
32
+ * Each router's NavigationStore owns one (navigation-store.ts), and hooks reach
33
+ * it through NavigationStoreContext: the router store is the single source of
34
+ * truth. Server renders have no store — the hook falls back to
35
+ * `OutletContext.loaderData`.
35
36
  */
36
37
 
37
38
  import { unwrapsSynchronously } from "./thenable-status.js";
@@ -597,10 +598,4 @@ export class LoaderStore {
597
598
  }
598
599
  }
599
600
 
600
- /**
601
- * Module-level singleton. Each browser tab gets its own; SSR never mutates it.
602
- * The hook falls through to `OutletContext.loaderData` during the server render.
603
- */
604
- export const loaderStore: LoaderStore = new LoaderStore();
605
-
606
601
  export const EMPTY_LOADER_SNAPSHOT: LoaderEntry = EMPTY_SNAPSHOT;
@@ -1,8 +1,14 @@
1
1
  "use client";
2
2
 
3
- import { useContext, useMemo, type ReactNode } from "react";
3
+ import {
4
+ useContext,
5
+ useMemo,
6
+ type ComponentProps,
7
+ type ReactNode,
8
+ } from "react";
4
9
  import { OutletContext, type OutletContextValue } from "./outlet-context.js";
5
10
  import type { ResolvedSegment } from "./types.js";
11
+ import { AuditedOutlet } from "./suspense-audit-react.js";
6
12
 
7
13
  /**
8
14
  * Outlet content provider — stores parent context for useLoader chain walking.
@@ -57,3 +63,21 @@ export function OutletProvider({
57
63
  <OutletContext.Provider value={value}>{children}</OutletContext.Provider>
58
64
  );
59
65
  }
66
+
67
+ // Dev only (suspense-audit.ts): OutletProvider with the segment's mount
68
+ // tracked under it. Chosen where the element is created, inside a NODE_ENV
69
+ // test: see AuditedRouteContent (route-content-wrapper.tsx).
70
+ export function AuditedOutletProvider({
71
+ children,
72
+ ...props
73
+ }: ComponentProps<typeof OutletProvider>): ReactNode {
74
+ return (
75
+ <OutletProvider {...props}>
76
+ {props.segment ? (
77
+ <AuditedOutlet segmentId={props.segment.id}>{children}</AuditedOutlet>
78
+ ) : (
79
+ children
80
+ )}
81
+ </OutletProvider>
82
+ );
83
+ }