@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
@@ -116,6 +116,7 @@ stated, greppable contract.
116
116
  | set meta/breadcrumbs from loader data | `ctx.use(Handle)` in the loader | /loader |
117
117
  | guarantee loader output in the SSR HTML | `loader(L, { ssr: false })` | /loader |
118
118
  | pre-render a route at build time | `Prerender(...)` wrapper | /prerender |
119
+ | make a route ready before traffic | `router.prerender({ env })(url)` | /prerender |
119
120
  | feed live loaders from a cached shell | replayed handle + `ctx.rendered()` | /shell-manifest |
120
121
  | cache HTML shell, live loaders as holes | `ppr` path option | /ppr |
121
122
  | choose in-function vs CDN caching | deployment cache boundary | /deployment-caching |
@@ -422,11 +422,11 @@ path.json("/api/users", handler, { name: "users" }, () => [
422
422
  Response-route `cache()` stores whole `Response`s. The serve leaf
423
423
  (`serveResponseRouteWithCache`) applies the same safety gates as document cache:
424
424
 
425
- | Gate | Behavior |
426
- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
427
- | **GET/HEAD only** | Non-GET/HEAD methods skip the cache (handler runs uncached). |
428
- | **Per-client signals** | A response with `Set-Cookie` or `x-rango-keep-cache` is returned live but **not** stored (MISS + SWR revalidation also skip put). |
429
- | **Default key** | `response:{type}:{cacheKeyBase(host, path, searchParams)}` — sorted search, reserved `_rsc*` / allowlisted `__*` params excluded. Custom `key()` / store `keyGenerator` still override. |
425
+ | Gate | Behavior |
426
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
427
+ | **GET/HEAD only** | Non-GET/HEAD methods skip the cache (handler runs uncached). |
428
+ | **Per-client signals** | A response with `Set-Cookie` or `x-rango-keep-cache` is returned live but **not** stored (MISS + SWR revalidation also skip put). |
429
+ | **Default key** | `response:{type}:{routerId}@{host}{path}[?search]` — the serving router's id, sorted search, reserved `_rsc*` / allowlisted `__*` params excluded. Custom `key()` / store `keyGenerator` still override. |
430
430
 
431
431
  ## CSRF
432
432
 
@@ -404,6 +404,35 @@ path("/checkout", CheckoutPage, { name: "checkout" }, () => [
404
404
  ]);
405
405
  ```
406
406
 
407
+ ## Errors in a streamed handler
408
+
409
+ A handler under `loading()` (a route, or a parallel slot) streams: it settles
410
+ after the response started. When it rejects, the nearest `errorBoundary()`
411
+ renders in place of that segment; `notFound()` renders the nearest
412
+ `notFoundBoundary()`. A slot finds boundaries declared on its owning layout or
413
+ route. Layouts and nav stay, on the document load and on a client navigation.
414
+
415
+ ```typescript
416
+ path("/product/:slug", ProductPage, { name: "product" }, () => [
417
+ loading(<ProductSkeleton />),
418
+ errorBoundary(({ error }) => <ProductError message={error.message} />),
419
+ notFoundBoundary(() => <ProductNotFound />),
420
+ ]);
421
+ ```
422
+
423
+ - The HTTP status stays what the stream already sent (200), `notFound()`
424
+ included; a failure before the flush (no `loading()`, or
425
+ `loading(fallback, { ssr: false })` on a document load) still sets 500 / 404.
426
+ A loader's 404 is likewise only set when its rejection settles before the
427
+ response is built.
428
+ - `onError` fires once per failure.
429
+ - A failed render is never stored: `cache()` skips the write for the scope that
430
+ holds the failed segment, a `ppr` shell and the document cache refuse the
431
+ whole document, and a Prerender route still fails the build.
432
+ - With no declared boundary the rejection reaches the client and
433
+ `RootErrorBoundary` replaces the page. A `redirect()` thrown from a streamed
434
+ handler is not recovered either (see "Redirects").
435
+
407
436
  ## Redirects
408
437
 
409
438
  `redirect(url, statusOrOptions?)` returns a `Response` (default status `302`).
@@ -115,6 +115,7 @@ Each primitive links to its sub-file (API + recipe + caveats).
115
115
  | a loader's own `cache()` (HIT, `updateTag()` of a tag its body recorded) | RSC unit | [`runLoader` + `cache` + seeded `cacheStore`](./loader.md) | `@rangojs/router/testing` |
116
116
  | cache hit/miss/stale, prerender (= a cache hit by design) | e2e + signal | [`assertCacheStatus` (header) / `assertCacheDecision` (telemetry sink)](./cache-prerender.md) | `@rangojs/router/testing[/e2e]` |
117
117
  | a `ppr` route's shell: MISS -> capture -> HIT, what the shell froze vs what a HIT renders live, tag eviction, navigation replay | RSC unit | [`serveShellRequest`](./serve-shell-request.md) | `@rangojs/router/testing/flight` |
118
+ | a form submitted before hydration: a `useActionState` result the no-JS POST renders, the form state its payload carries | RSC unit | [`createActionForm` + `serveShellRequest({ form })`](./serve-shell-request.md) | `@rangojs/router/testing/flight` |
118
119
  | PPR shell HIT/MISS in real HTML (`ppr` path option), shell store key / getShell after flush | e2e + signal | [`assertShellStatus` / `shellCacheKey` + store](./cache-prerender.md#ppr-shell-and-navigation-replay) | `@rangojs/router/testing[/e2e]` |
119
120
  | generated route map drift vs runtime | unit (node) | [`assertGeneratedRoutesMatch`](./reverse-and-types.md) | `@rangojs/router/testing` |
120
121
  | a platform binding (`env.DB` / Durable Object / `env.R2`) | unit/integr. | [your own double via `env`](./bindings.md) | (any primitive's `env` option) |
@@ -158,7 +159,7 @@ beside their function):
158
159
  | `@rangojs/router/testing/flight` | `renderToFlightString`, `normalizeFlight`, `assertFlightRuntimeAvailable` | [`flight.md`](./flight.md) |
159
160
  | | `renderServerTree`, `findClientBoundaries`, `findElements`, `textContent`, `assertFlightTreeRuntimeAvailable` | [`server-tree.md`](./server-tree.md) |
160
161
  | | `renderHandler` | [`render-handler.md`](./render-handler.md) |
161
- | | `serveShellRequest`, `resetShellTestState` | [`serve-shell-request.md`](./serve-shell-request.md) |
162
+ | | `serveShellRequest`, `resetShellTestState`, `createActionForm` | [`serve-shell-request.md`](./serve-shell-request.md) |
162
163
  | `@rangojs/router/testing/flight-matchers` | `flightMatchers` (`toMatchFlight`, `toMatchFlightSnapshot`) | [`flight.md`](./flight.md) |
163
164
  | `@rangojs/router/testing/vitest` | `rangoTestConfig`, `rangoTestAliases`, `rangoInlineDeps`, `rangoUseClientTransform`, `rangoUseCacheTransform` | [`setup.md`](./setup.md) |
164
165
  | `@rangojs/router/testing/e2e` | `createRangoE2E` and its standalone helpers; the cache/shell status helpers above are re-exported here | [`e2e-parity.md`](./e2e-parity.md) |
@@ -121,25 +121,25 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
121
121
 
122
122
  **DSL:** `ppr: true | PartialPrerenderProps` on a page route (see `/ppr`). **Not** the same header as `X-Rango-Cache` — the shell is cached separately from the segments.
123
123
 
124
- | Helper | Import | Role |
125
- | ----------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
126
- | `assertShellStatus(res, "HIT" \| "MISS")` | `@rangojs/router/testing` or `…/e2e` | Assert `x-rango-shell` on a **real document** Response |
127
- | `parseShellStatus(res)` | same | `"HIT" \| "MISS" \| null` (null = header absent / unrecognized) |
128
- | `shellCacheKey(url, searchParams?, partition?)` | same | Production shell store key (`host+pathname+sorted search+:shell`, then `\|partition`) for `store.getShell` / custom stores. Pass your router's `cache.searchParams` config as the second argument when it sets one — the production key applies it. For a route whose `cache({ key })` partitions its record, pass that function's result for the request as `partition`: the shell is partitioned the same way, the result namespaced as production stores it (`key:` plus its URI encoding). Under nested keyed `cache()` boundaries, pass their `key()` results as an array, outermost first. For a store `keyGenerator` that partitions it, pass `{ keys, generated }` (`ShellCachePartition`): `generated` lists the `keyGenerator` results one per store, outermost first: leave them out when every one returns the default key, else keep each position and pass `""` for one that returns it. |
129
- | `SHELL_STATUS_HEADER` | same | `"x-rango-shell"` constant |
130
- | `assertPprReplayStatus(res, expected)` | same | Assert fresh/stale replay or a bounded bypass decision |
131
- | `parsePprReplayStatus(res)` | same | Structured replay/bypass status, or null for an absent/unrecognized header |
132
- | `PPR_REPLAY_STATUS_HEADER` | same | `"x-rango-ppr-replay"` constant |
124
+ | Helper | Import | Role |
125
+ | ------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
126
+ | `assertShellStatus(res, "HIT" \| "MISS")` | `@rangojs/router/testing` or `…/e2e` | Assert `x-rango-shell` on a **real document** Response |
127
+ | `parseShellStatus(res)` | same | `"HIT" \| "MISS" \| null` (null = header absent / unrecognized) |
128
+ | `shellCacheKey(router, url, searchParams?, partition?)` | same | Production shell store key (`routerId@host+pathname+sorted search+:shell`, then `\|partition`) for `store.getShell` / custom stores. `router` is the router that serves the URL, or `{ id }` where it cannot be imported (a Playwright test): a shell belongs to one router. Pass your router's `cache.searchParams` config as the second argument when it sets one — the production key applies it. For a route whose `cache({ key })` partitions its record, pass that function's result for the request as `partition`: the shell is partitioned the same way, the result namespaced as production stores it (`key:` plus its URI encoding). Under nested keyed `cache()` boundaries, pass their `key()` results as an array, outermost first. For a store `keyGenerator` that partitions it, pass `{ keys, generated }` (`ShellCachePartition`): `generated` lists the `keyGenerator` results one per store, outermost first: leave them out when every one returns the default key, else keep each position and pass `""` for one that returns it. |
129
+ | `SHELL_STATUS_HEADER` | same | `"x-rango-shell"` constant |
130
+ | `assertPprReplayStatus(res, expected)` | same | Assert fresh/stale replay or a bounded bypass decision |
131
+ | `parsePprReplayStatus(res)` | same | Structured replay/bypass status, or null for an absent/unrecognized header |
132
+ | `PPR_REPLAY_STATUS_HEADER` | same | `"x-rango-ppr-replay"` constant |
133
133
 
134
134
  ### What unit can prove vs e2e
135
135
 
136
- | Layer | What you assert | How |
137
- | -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
138
- | **Unit** | store family + key identity | `MemorySegmentCacheStore` + `shellCacheKey(url)` + `putShell`/`getShell` / tag eviction — dogfood in `e2e/mini/test/shell-store-family.test.ts` and `src/testing/__tests__/shell-status.test.ts` |
139
- | **Unit** | header helper contract | `assertShellStatus` on a Response that already carries the header (characterizes the helper; **never** invent a HIT to claim capture worked) |
140
- | **Unit** | real capture → HIT, replay | `serveShellRequest(router, url, { cacheStore })` (react-server project); only the HTML step is stubbed — [`./serve-shell-request.md`](./serve-shell-request.md) |
141
- | **E2E** | live MISS → capture → HIT | document GET, poll until `x-rango-shell: HIT` (background capture) — `e2e/shell-cache.test.ts` |
142
- | **E2E** | partial replay decision | soft navigation to a warmed shell and `assertPprReplayStatus(response, { outcome: "HIT", freshness: "fresh" })` |
136
+ | Layer | What you assert | How |
137
+ | -------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
138
+ | **Unit** | store family + key identity | `MemorySegmentCacheStore` + `shellCacheKey(router, url)` + `putShell`/`getShell` / tag eviction — dogfood in `e2e/mini/test/shell-store-family.test.ts` and `src/testing/__tests__/shell-status.test.ts` |
139
+ | **Unit** | header helper contract | `assertShellStatus` on a Response that already carries the header (characterizes the helper; **never** invent a HIT to claim capture worked) |
140
+ | **Unit** | real capture → HIT, replay | `serveShellRequest(router, url, { cacheStore })` (react-server project); only the HTML step is stubbed — [`./serve-shell-request.md`](./serve-shell-request.md) |
141
+ | **E2E** | live MISS → capture → HIT | document GET, poll until `x-rango-shell: HIT` (background capture) — `e2e/shell-cache.test.ts` |
142
+ | **E2E** | partial replay decision | soft navigation to a warmed shell and `assertPprReplayStatus(response, { outcome: "HIT", freshness: "fresh" })` |
143
143
 
144
144
  `dispatch` is RSC-free: it never runs shell serve/capture. `renderHandler` only surfaces `ctx.dynamic()` opt-out, not bake/serve. The HIT contract — no handler runs, the handler output is the capture's copy, live loaders re-run — is asserted through `serveShellRequest`: count your handler's runs and a live loader's across a MISS and the HITs after it, and check that only the loader's moves ([`./serve-shell-request.md`](./serve-shell-request.md)). A route partitioned by `cache({ key })` or a store `keyGenerator` has a shell per partition: `serveShellRequest` reports the key the serve path resolved (`result.key`), and `shellCacheKey` takes the partition as its third argument.
145
145
 
@@ -175,10 +175,11 @@ parityDescribe("product shell", (f) => {
175
175
  // Unit (Vitest): the key the serve path uses, against your own store instance
176
176
  import { expect, it } from "vitest";
177
177
  import { shellCacheKey } from "@rangojs/router/testing";
178
+ import { router } from "../src/router";
178
179
 
179
180
  it("stores the shell under the production key", async () => {
180
181
  // ...after your code under test captured a shell into `store`...
181
- const key = shellCacheKey(new URL("http://localhost/products/1"));
182
+ const key = shellCacheKey(router, new URL("http://localhost/products/1"));
182
183
  expect(await store.getShell(key)).not.toBeNull();
183
184
  });
184
185
  ```
@@ -14,6 +14,8 @@ The one stub is the HTML step (the SSR module), because `react-dom/server` does
14
14
 
15
15
  So `prelude` is the shell as captured (Flight text, not HTML): a value the shell froze appears there, and a hole (a live loader under `loading()`) does not.
16
16
 
17
+ A `Prerender` route is served as in production: from the artifact `router.matchForPrerender` bakes for the URL (on its first request, kept until `resetShellTestState()`), through the production prerender store, so its handler runs once, at the bake, and never on a request. Its first `ppr` request is a MISS with a runtime capture, as a URL without a build-time shell is in production. The shell `vite build` bakes (served first in production for a URL without a query string) is not reproduced: keep that in e2e. The `env` option doubles as the bake's `buildEnv`. An on-demand route (`Prerender(..., { onDemand })`) is baked only when it is wrapped in `Passthrough()` and its `getParams()` lists the param, as a build bakes it; a plain on-demand route is not baked here, so its pages come from the prerender store alone (`router.prerender()`), and "a removed page the build baked" is a case for a `Passthrough` route or for e2e.
18
+
17
19
  ## API
18
20
 
19
21
  ### Options — `ServeShellRequestOptions`
@@ -41,7 +43,7 @@ So `prelude` is the shell as captured (Flight text, not HTML): a value the shell
41
43
 
42
44
  ### `resetShellTestState(): Promise<void>`
43
45
 
44
- A worker keeps some PPR state across requests, and so does the test process across tests: the capture's stampede guard and backoff (a refused capture backs its URL off for later tests too), the capture's and the serve path's once-per-key warnings, the build-shell manifest memo, and `CFCacheStore`'s isolate memos (shells, tag markers, tag hints), which every `CFCacheStore` shares by namespace and URL — a later test's first request can be a HIT from an earlier test's shell. `resetShellTestState()` clears all of it. Call it in `beforeEach`, never while a request is in flight. `VercelCacheStore`'s memos live on the `cache` handle you pass it: a new handle per test starts empty.
46
+ A worker keeps some PPR state across requests, and so does the test process across tests: the capture's stampede guard and backoff (a refused capture backs its URL off for later tests too), the capture's and the serve path's once-per-key warnings, the build-shell manifest memo, the `Prerender` artifacts baked so far, and `CFCacheStore`'s isolate memos (shells, tag markers, tag hints), which every `CFCacheStore` shares by namespace and URL — a later test's first request can be a HIT from an earlier test's shell. `resetShellTestState()` clears all of it. Call it in `beforeEach`, never while a request is in flight. `VercelCacheStore`'s memos live on the `cache` handle you pass it: a new handle per test starts empty.
45
47
 
46
48
  ## Recipe
47
49
 
@@ -164,9 +166,12 @@ const silver = await serveShellRequest(router, "/pricing", tier("silver"));
164
166
  expect(silver.shellStatus).toBe("MISS"); // its own capture, never gold's shell
165
167
  const gold = await serveShellRequest(router, "/pricing", tier("gold"));
166
168
  expect(gold.shellStatus).toBe("HIT");
167
- expect(gold.key).toBe(shellCacheKey("/pricing", undefined, "tier:gold"));
168
- // "localhost/pricing:shell|key%3Atier%253Agold": the key() result is
169
- // namespaced as production stores it (issue #975).
169
+ expect(gold.key).toBe(
170
+ shellCacheKey(router, "/pricing", undefined, "tier:gold"),
171
+ );
172
+ // "{router.id}@localhost/pricing:shell|key%3Atier%253Agold": the router the
173
+ // shell belongs to, then the key() result namespaced as production stores it
174
+ // (issue #975).
170
175
  expect(await gold.readEntry()).not.toBeNull();
171
176
  ```
172
177
 
@@ -179,7 +184,7 @@ Under nested keyed `cache()` boundaries the partition is their `key()` results c
179
184
  // ]),
180
185
  // ])
181
186
  expect(result.key).toBe(
182
- shellCacheKey("/plans", undefined, ["tier:gold", "v:a"]),
187
+ shellCacheKey(router, "/plans", undefined, ["tier:gold", "v:a"]),
183
188
  );
184
189
  ```
185
190
 
@@ -189,14 +194,33 @@ A store `keyGenerator` result that partitions the record partitions the shell to
189
194
  // createRouter({ cache: { store: localeStore } }), where localeStore's
190
195
  // keyGenerator returns `${defaultKey}|${locale}`
191
196
  expect(result.key).toBe(
192
- shellCacheKey("/pricing", undefined, {
193
- generated: ["doc:localhost/pricing|de"],
197
+ shellCacheKey(router, "/pricing", undefined, {
198
+ generated: [`doc:${router.id}@localhost/pricing|de`],
194
199
  }),
195
200
  );
196
201
  ```
197
202
 
198
203
  A partial request has no HTML step, so its `key` is the URL's key without a partition: read a partitioned route's entry from a document request's result.
199
204
 
205
+ ## Two routers on one cache store
206
+
207
+ A shell's key starts with its router's id (`shellCacheKey(router, url)`), so two routers that share a store and serve the same host and path each read and write their own (why and when: `/host-router`, "Shared cache store"). Serve both against one store to pin it:
208
+
209
+ ```ts
210
+ const store = new MemorySegmentCacheStore();
211
+ // appA and appB: createRouter({ id: "app-a" | "app-b", cache: { store } }),
212
+ // each with path("/pricing", ..., { ppr: true })
213
+ const url = "http://preview.dev/pricing";
214
+
215
+ await serveShellRequest(appA, url); // MISS + capture
216
+ const fromB = await serveShellRequest(appB, url);
217
+ expect(fromB.shellStatus).toBe("MISS"); // never app A's shell
218
+ expect((await serveShellRequest(appA, url)).shellStatus).toBe("HIT");
219
+ expect((await serveShellRequest(appB, url)).shellStatus).toBe("HIT");
220
+ ```
221
+
222
+ Vitest runs no Vite id transform, so give each router an explicit `id` when a test builds more than one, and do not reuse one `id` for routers with different routes in the same file: the route manifest is registered per id.
223
+
200
224
  ## Handles and loader data
201
225
 
202
226
  `readHandles()` decodes the handle data the response carries, the way the browser reads it, with deferred values resolved. Each field is the raw handle data (`{ [handleId]: { [segmentId]: values[] } }`):
@@ -226,6 +250,78 @@ Loader data has no decoder: it rides the payload, so assert it as text in `prelu
226
250
  expect(hit.flight).toContain('"stock":"in stock"');
227
251
  ```
228
252
 
253
+ ## Warming a route: router.prerender()
254
+
255
+ `router.prerender()` warms a route that is not `Prerender(..., { onDemand })`
256
+ through `router.fetch` (`/prerender` → "Warm any route before traffic").
257
+ Importing `serveShellRequest` gives that handler the same HTML stub, so the
258
+ whole path runs here: warm, then serve the next visitor's request.
259
+
260
+ ```tsx
261
+ class SharedMemoryStore extends MemorySegmentCacheStore {
262
+ // The shipped store is "local" and refused; a test store says it is shared.
263
+ readonly scope = "global" as const;
264
+ }
265
+
266
+ const router = createRouter({
267
+ cache: { store: new SharedMemoryStore() },
268
+ }).routes(
269
+ urls(({ path }) => [path("/product/:id", ProductPage, { ppr: true })]),
270
+ );
271
+
272
+ const result = await router.prerender({ env: {} })(
273
+ "http://localhost/product/1",
274
+ );
275
+ expect(result).toMatchObject({ ok: true, path: "warm", status: "warmed" });
276
+
277
+ // No visitor has requested the URL: its first document is a HIT.
278
+ const hit = await serveShellRequest(router, "/product/1");
279
+ expect(hit.shellStatus).toBe("HIT");
280
+ ```
281
+
282
+ - Configure the store on the router. A warm writes to the store
283
+ `createRouter({ cache })` resolves, not to the `cacheStore` option of
284
+ `serveShellRequest`.
285
+ - Give the target the origin `serveShellRequest` uses (`http://localhost`), as
286
+ a full URL or with `router.prerender({ env, origin })`: cache keys carry the
287
+ host. A path target with neither returns `skipped-no-origin`, unless the
288
+ runner is called from inside a served request (a route handler that calls
289
+ `router.prerender()`), where it takes that request's origin.
290
+ - To assert a replace, count handler runs or render a per-run stamp: fill the
291
+ cache with one request, move the data, warm, then check the next request
292
+ shows the new value and ran no handler.
293
+ - `router.fetch` binds its handler's document version once, as a production
294
+ isolate does, so `setBuildVersions()` between two warms does not move it.
295
+
296
+ ## A form submitted before hydration
297
+
298
+ `serveShellRequest(router, url, { form })` POSTs a form the way a browser does before the page hydrates, through the production no-JS path: the action runs, the page re-renders, and for a `useActionState` form the document payload carries the form state. Build the body with `createActionForm(action, { id })` from the same entry; it writes React's hidden fields and registers `action` under `id` (`<module id>#<export name>`, any unique string of that shape in a test).
299
+
300
+ ```tsx
301
+ import {
302
+ createActionForm,
303
+ serveShellRequest,
304
+ } from "@rangojs/router/testing/flight";
305
+
306
+ const form = createActionForm(makeToken, { id: "actions/token#makeToken" });
307
+ const result = await serveShellRequest(router, "/token", { form });
308
+
309
+ // What the SSR render received, and what the browser entry hands hydrateRoot:
310
+ expect(result.formState).toEqual([
311
+ { token: "tok-1" },
312
+ "k0",
313
+ "actions/token#makeToken",
314
+ 0,
315
+ ]);
316
+ expect(await result.readPayloadFormState()).toEqual(result.formState);
317
+ ```
318
+
319
+ - `useActionState: false` builds a plain `<form action={fn}>` POST (`$ACTION_ID_`): the action runs as `(formData)` and no form state is carried. Default true runs `(prevState, formData)` with the hook's initial `null`. `fields` are the form's own fields (`formData.get("name")`).
320
+ - An action returning a state Flight cannot serialize (a class instance) still renders the document with status 200 and `formState` holds the raw value, but `readPayloadFormState()` rejects: the entry catches that and hydrates without it, as before the fix.
321
+ - A GET, a plain-form POST and a redirecting action carry no form state: `formState` and `readPayloadFormState()` are undefined, a redirect has no `flight`.
322
+ - Request headers come from `headers`. No `Origin` is sent by default, which the origin guard allows.
323
+ - Not with `partial`. The hydration itself (`hydrateRoot` taking the state, the hook not resetting) is e2e.
324
+
229
325
  ## Caveats
230
326
 
231
327
  What the HTML stub cannot reproduce, so keep it in e2e:
@@ -241,7 +337,7 @@ Other caveats:
241
337
  - Call `resetShellTestState()` in `beforeEach` (see above). Without it, a URL a previous test left backed off, or a shell memoized by another `CFCacheStore`, changes what the next test sees.
242
338
  - `readEntry()` reads the document entry under `key`. A partial request that finds no entry captures a navigation-only entry, which is stored apart from it.
243
339
  - `updateTag()` needs a request context with the store: call it through `runInRequestContext(fn, { cacheStore })` or your app's own endpoint (`dispatch`). A store refuses a capture that starts in the invalidation's millisecond (the invalidation wins) and backs the key off; `serveShellRequest` starts each request in a later millisecond than the call, so an invalidation made before the call cannot collide with it.
244
- - GET only. Server actions are not served; test their effects with `runInRequestContext`.
340
+ - GET, and one POST shape: a form submitted before hydration (see "A form submitted before hydration" above). Other server action requests are not served; test their effects with `runInRequestContext`.
245
341
  - Same setup as every Flight test: the react-server condition and `rangoTestAliases()` (see [`./setup.md`](./setup.md)).
246
342
 
247
343
  ## See also
@@ -151,11 +151,15 @@ argument. The same holds for a loader's own `cache()` (`/loader` → "Cache Key"
151
151
 
152
152
  The tainted `ctx` object is excluded from arg serialization (see below), but
153
153
  route-identifying fields read off it are extracted into `serializedArgs`:
154
- `url.host`, route name (`_routeName`), `pathname`, `params`, response type
155
- (`_responseType`), and the user-facing sorted search params (internal `_rsc*`/`__`
156
- params excluded). The same cached function called with `ctx` on different routes,
157
- param combinations, hosts, response types, or query variants therefore produces
158
- distinct cache entries -- not one shared entry.
154
+ the serving router's id and `url.host` (`{routerId}@{host}`), route name
155
+ (`_routeName`), `pathname`, `params`, response type (`_responseType`), and the
156
+ user-facing sorted search params (internal `_rsc*`/`__` params excluded). The
157
+ same cached function called with `ctx` on different routes, param combinations,
158
+ hosts, response types, or query variants therefore produces distinct cache
159
+ entries -- not one shared entry. Two routers that call it on one host and path
160
+ keep two entries as well: a `ctx` is one router's (`/host-router`, "Shared
161
+ cache store"). A bare `Request` argument folds in its URL only, so it names no
162
+ router.
159
163
 
160
164
  ## Request-Scoped Arguments (ctx, Request, env)
161
165
 
@@ -118,6 +118,8 @@ export const router = createRouter({ cache: resolveCache }).routes(/* ... */);
118
118
 
119
119
  The cache factory receives `(env, ctx)`: on Vercel `env` is `process.env` and `ctx` is `{ waitUntil }`; off-platform `ctx` is `undefined`. Cache semantics and tag invalidation: `/caching`, `/cache-guide`.
120
120
 
121
+ `router.prerender()` can warm a route into this store before traffic (`/prerender` → "Warm any route before traffic"). The Runtime Cache is per region, so `VercelCacheStore` declares `scope: "regional"`: a warm fills the region the call runs in. That is all traffic when the project's functions run in one region (the default `iad1` for new projects, and every Hobby project); on a multi-region project it warms one region and the others fill on first use. The router does not detect the region count. The memory store used off-platform above is refused in production and allowed under the dev server.
122
+
121
123
  `getCache()` takes no deployment id: `VercelCacheStore` puts a version in front of every key itself (data version for families `s`, `i`; document version for `r`, `h`; none for tag markers), computed per `createRouter()` from its built code. A deploy that does not change a router keeps its entries; a deploy that changes it reads under new keys. `version` on the store (or on `createRouter`) replaces both versions. To clear the cache on every deploy, set `version` to a per-deploy value or keep a deployment-scoped `getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID })`. Two environments built from the same code share entries if they share a cache namespace; give each its own namespace or `version`.
122
124
 
123
125
  ## Host routers (multi-app)
@@ -359,10 +359,15 @@ interface TransitionWhenContext {
359
359
  the route or loses its state
360
360
  ([#995](https://github.com/rangojs/rango/issues/995)).
361
361
  - That holds for every kind, actions and revalidations included: a gated-off
362
- action or revalidation commit is urgent too, so a segment it re-renders
363
- streams its `loading()`. The router commits an action's result after the
364
- response arrives, outside the transition React opens for a form action or
365
- `useActionState` call, so React does not hold it either.
362
+ action or revalidation commit is urgent and unanimated too, and a segment it
363
+ re-renders shows its `loading()` only while something in it is still pending
364
+ (a gated-off navigation whose data is still streaming shows it; an action
365
+ whose response already carries the data does not in a production build,
366
+ while the dev server still shows it briefly because it resolves each
367
+ response's client components asynchronously). The router commits an
368
+ action's result after the response arrives, outside the transition React
369
+ opens for a form action or `useActionState` call, so React does not hold it
370
+ either.
366
371
  - A predicate that throws counts as `false` and is logged with
367
372
  `console.error` (the conservative default: no hold).
368
373
  - Intercept (modal) commits skip the evaluation, including a back/forward
@@ -142,6 +142,16 @@ export interface HandleState {
142
142
  routeSegmentIds: string[];
143
143
  }
144
144
 
145
+ /**
146
+ * What the document's HTML was rendered with, kept for the hydration window
147
+ * (EventController.lockHydration).
148
+ */
149
+ export interface HydrationSnapshot {
150
+ state: DerivedNavigationState;
151
+ params: Record<string, string>;
152
+ handles: HandleState;
153
+ }
154
+
145
155
  /**
146
156
  * Result from starting a navigation
147
157
  * Implements Disposable for use with `using` keyword
@@ -263,22 +273,30 @@ export interface EventController {
263
273
  ): void;
264
274
  getHandleState(): HandleState;
265
275
  /**
266
- * Keep the current handle state as the one the document's HTML was rendered
267
- * with. initBrowserApp calls it before hydrateRoot, once the document's
268
- * handle snapshot is applied.
276
+ * Open the hydration window. Until releaseHydrationLock(), getHandleState()
277
+ * returns the state the document's HTML was rendered with, and handle writes
278
+ * (setHandleData, setRouteSegmentIds) wait in order. initBrowserApp opens it
279
+ * before hydrateRoot, once the document's handle snapshot is applied: a hook
280
+ * reading the handle state when its boundary hydrates, however late, reads
281
+ * what SSR rendered (issue #1035).
269
282
  */
270
- freezeHydrationHandleState(): void;
283
+ lockHydration(): void;
271
284
  /**
272
- * The handle state the document's HTML was rendered with, once the live
273
- * state has moved on from it; undefined while the live state is still that
274
- * state (nothing changed since the freeze, or nothing was frozen: a tree
275
- * that was mounted, an SSR pass). A render React is hydrating reads it
276
- * (useHandle), whenever its boundary hydrates: the late handle channel
277
- * (rsc-router.tsx) waits for the root to hydrate, not for a `<Suspense>`
278
- * boundary that hydrates after it, and by then the live state can hold
279
- * values that boundary's HTML does not (issue #1035).
285
+ * While the hydration window is open, the state the document's HTML was
286
+ * rendered with; undefined once it closes. A router hook's first render
287
+ * reads it: a component hydrating in a boundary that streamed in late
288
+ * renders what SSR rendered, even after a navigation or an action started.
289
+ * The hook's effect then catches up with the live state, and its
290
+ * subscription stays live, so pending UI is not held back.
280
291
  */
281
- getHydrationHandleState(): HandleState | undefined;
292
+ getHydrationSnapshot(): HydrationSnapshot | undefined;
293
+ /**
294
+ * Close the hydration window: apply the writes that waited, in order, and
295
+ * notify handle subscribers. NavigationProvider calls it after the commit
296
+ * that made React hydrate every boundary still dehydrated (its barrier).
297
+ * Idempotent.
298
+ */
299
+ releaseHydrationLock(): void;
282
300
  /**
283
301
  * Update ONLY `routeSegmentIds` (what `useSegments` reads) from `matched`,
284
302
  * leaving `data` and `segmentOrder` (what `useHandle` collects over) untouched.
@@ -498,8 +516,10 @@ export function createEventController(
498
516
  let handleData: HandleData = {};
499
517
  let handleSegmentOrder: string[] = [];
500
518
  let routeSegmentIds: string[] = [];
501
- // freezeHydrationHandleState: what the document's HTML was rendered with.
502
- let hydrationHandleState: HandleState | undefined;
519
+ // The hydration window (lockHydration): what the document's HTML was
520
+ // rendered with, and the handle writes waiting for its release.
521
+ let hydrationSnapshot: HydrationSnapshot | undefined;
522
+ let writesAfterHydration: (() => void)[] = [];
503
523
 
504
524
  let routeParams: Record<string, string> = {};
505
525
 
@@ -1028,6 +1048,12 @@ export function createEventController(
1028
1048
  isPartial?: boolean,
1029
1049
  resolvedIds?: string[],
1030
1050
  ): void {
1051
+ if (hydrationSnapshot) {
1052
+ writesAfterHydration.push(() =>
1053
+ setHandleData(data, matched, isPartial, resolvedIds),
1054
+ );
1055
+ return;
1056
+ }
1031
1057
  const rawMatched = matched ?? [];
1032
1058
  const newSegmentOrder = filterSegmentOrder(rawMatched);
1033
1059
  // Separate list for useSegments(): "layouts and routes only" — strip
@@ -1036,9 +1062,8 @@ export function createEventController(
1036
1062
 
1037
1063
  if (isPartial && newSegmentOrder.length > 0) {
1038
1064
  // Partial update: merge new data with existing, into a copy of the
1039
- // containers. The object a reader was handed is never written to: the
1040
- // hydration snapshot (freezeHydrationHandleState) and a history entry
1041
- // restored from the store's cache can be that object.
1065
+ // containers. The object a reader was handed is never written to: a
1066
+ // history entry restored from the store's cache can be that object.
1042
1067
  handleData = cloneHandleData(handleData);
1043
1068
  for (const handleName of Object.keys(data)) {
1044
1069
  if (!handleData[handleName]) {
@@ -1076,6 +1101,7 @@ export function createEventController(
1076
1101
  }
1077
1102
 
1078
1103
  function getHandleState(): HandleState {
1104
+ if (hydrationSnapshot) return hydrationSnapshot.handles;
1079
1105
  return {
1080
1106
  data: handleData,
1081
1107
  segmentOrder: handleSegmentOrder,
@@ -1083,7 +1109,29 @@ export function createEventController(
1083
1109
  };
1084
1110
  }
1085
1111
 
1112
+ function lockHydration(): void {
1113
+ if (hydrationSnapshot) return;
1114
+ hydrationSnapshot = {
1115
+ state: getState(),
1116
+ params: routeParams,
1117
+ handles: getHandleState(),
1118
+ };
1119
+ }
1120
+
1121
+ function releaseHydrationLock(): void {
1122
+ if (!hydrationSnapshot) return;
1123
+ hydrationSnapshot = undefined;
1124
+ const writes = writesAfterHydration;
1125
+ writesAfterHydration = [];
1126
+ for (const write of writes) write();
1127
+ notifyHandles.flush();
1128
+ }
1129
+
1086
1130
  function setRouteSegmentIds(matched: string[]): void {
1131
+ if (hydrationSnapshot) {
1132
+ writesAfterHydration.push(() => setRouteSegmentIds(matched));
1133
+ return;
1134
+ }
1087
1135
  const next = filterRouteSegmentIds(matched);
1088
1136
  if (
1089
1137
  next.length === routeSegmentIds.length &&
@@ -1177,13 +1225,9 @@ export function createEventController(
1177
1225
  // Handles
1178
1226
  setHandleData,
1179
1227
  getHandleState,
1180
- freezeHydrationHandleState: () => {
1181
- hydrationHandleState = getHandleState();
1182
- },
1183
- getHydrationHandleState: () =>
1184
- hydrationHandleState?.data === handleData
1185
- ? undefined
1186
- : hydrationHandleState,
1228
+ lockHydration,
1229
+ getHydrationSnapshot: () => hydrationSnapshot,
1230
+ releaseHydrationLock,
1187
1231
  setRouteSegmentIds,
1188
1232
 
1189
1233
  // Params
@@ -4,7 +4,7 @@ import {
4
4
  type EventController,
5
5
  } from "./event-controller.js";
6
6
  import {
7
- getDefaultPrefetchStrategy,
7
+ ENVIRONMENT_DEFAULT_PREFETCH,
8
8
  resolveAdaptiveStrategy,
9
9
  subscribeToAdaptiveStrategyChange,
10
10
  } from "./prefetch/default-strategy.js";
@@ -383,7 +383,7 @@ export function setupDelegatedLinkPrefetch(
383
383
  options: DelegatedPrefetchOptions,
384
384
  ): () => void {
385
385
  const defaultStrategy =
386
- options.defaultPrefetch ?? getDefaultPrefetchStrategy();
386
+ options.defaultPrefetch ?? ENVIRONMENT_DEFAULT_PREFETCH;
387
387
  if (defaultStrategy === "none") return () => {};
388
388
 
389
389
  const basename = canonicalizeBasenamePathname(options.basename);