@rangojs/router 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/dist/bin/rango.js +10 -4
  2. package/dist/testing/vitest.js +31 -1
  3. package/dist/types/browser/defer.d.ts +48 -0
  4. package/dist/types/browser/document-revealed.d.ts +17 -0
  5. package/dist/types/browser/event-controller.d.ts +7 -1
  6. package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
  7. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  8. package/dist/types/browser/partial-update.d.ts +39 -8
  9. package/dist/types/browser/react/NavigationProvider.d.ts +19 -5
  10. package/dist/types/browser/react/context.d.ts +3 -2
  11. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  12. package/dist/types/browser/response-adapter.d.ts +7 -0
  13. package/dist/types/browser/types.d.ts +24 -10
  14. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  15. package/dist/types/build/generate-route-types.d.ts +1 -1
  16. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  17. package/dist/types/build/route-types/scan-filter.d.ts +22 -2
  18. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  19. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  20. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  21. package/dist/types/cache/document-cache.d.ts +2 -10
  22. package/dist/types/client-urls/client-urls.d.ts +1 -1
  23. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  24. package/dist/types/client-urls/navigation.d.ts +0 -37
  25. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  26. package/dist/types/client-urls/server-projection.d.ts +29 -1
  27. package/dist/types/client-urls/types.d.ts +34 -69
  28. package/dist/types/client.d.ts +0 -1
  29. package/dist/types/context-var.d.ts +12 -0
  30. package/dist/types/errors.d.ts +15 -0
  31. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  32. package/dist/types/handles/pushed-by.d.ts +31 -0
  33. package/dist/types/index.d.ts +4 -0
  34. package/dist/types/index.rsc.d.ts +2 -0
  35. package/dist/types/loader-store.d.ts +8 -0
  36. package/dist/types/prefetch-markers.d.ts +11 -0
  37. package/dist/types/response-utils.d.ts +7 -0
  38. package/dist/types/reverse.d.ts +8 -8
  39. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  40. package/dist/types/route-definition/rewrite.d.ts +56 -0
  41. package/dist/types/route-map-builder.d.ts +7 -2
  42. package/dist/types/router/basename.d.ts +7 -0
  43. package/dist/types/router/handler-context.d.ts +1 -5
  44. package/dist/types/router/internal-params.d.ts +8 -0
  45. package/dist/types/router/is-action.d.ts +2 -1
  46. package/dist/types/router/middleware-types.d.ts +15 -0
  47. package/dist/types/router/revalidation.d.ts +1 -2
  48. package/dist/types/router/route-trie-builder.d.ts +10 -0
  49. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  50. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  51. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +264 -0
  52. package/dist/types/rsc/full-payload.d.ts +11 -0
  53. package/dist/types/rsc/handler.d.ts +33 -0
  54. package/dist/types/rsc/helpers.d.ts +8 -0
  55. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  56. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  57. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  58. package/dist/types/server/context.d.ts +21 -1
  59. package/dist/types/server/handle-store.d.ts +9 -1
  60. package/dist/types/server/request-context.d.ts +52 -2
  61. package/dist/types/server/rewrite-trail.d.ts +49 -0
  62. package/dist/types/server/slot-accessors.d.ts +29 -0
  63. package/dist/types/server.d.ts +1 -0
  64. package/dist/types/testing/flight.entry.d.ts +1 -1
  65. package/dist/types/testing/index.d.ts +4 -4
  66. package/dist/types/testing/internal/action-id.d.ts +10 -1
  67. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  68. package/dist/types/testing/internal/test-action.d.ts +8 -0
  69. package/dist/types/testing/render-route.d.ts +8 -0
  70. package/dist/types/testing/run-loader.d.ts +2 -1
  71. package/dist/types/testing/run-middleware.d.ts +20 -0
  72. package/dist/types/testing/run-revalidate.d.ts +74 -0
  73. package/dist/types/testing/run-transition-when.d.ts +2 -3
  74. package/dist/types/testing/serve-shell-request.d.ts +40 -1
  75. package/dist/types/testing/vitest.d.ts +11 -0
  76. package/dist/types/thenable-status.d.ts +6 -0
  77. package/dist/types/types/boundaries.d.ts +9 -0
  78. package/dist/types/types/handler-context.d.ts +14 -0
  79. package/dist/types/types/loader-types.d.ts +30 -0
  80. package/dist/types/types/request-scope.d.ts +7 -0
  81. package/dist/types/types/segments.d.ts +21 -4
  82. package/dist/types/urls/path-helper-types.d.ts +25 -4
  83. package/dist/types/urls/type-extraction.d.ts +6 -2
  84. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  85. package/dist/types/vite/discovery/state.d.ts +14 -4
  86. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  87. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  88. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  89. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  90. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  91. package/dist/types/vite/router-discovery.d.ts +10 -0
  92. package/dist/vite/index.js +974 -379
  93. package/package.json +1 -1
  94. package/skills/caching/SKILL.md +54 -5
  95. package/skills/catalog.json +3 -3
  96. package/skills/client-urls/SKILL.md +197 -24
  97. package/skills/cloudflare/SKILL.md +14 -9
  98. package/skills/hooks/SKILL.md +16 -3
  99. package/skills/hooks/data.md +3 -1
  100. package/skills/links/SKILL.md +24 -0
  101. package/skills/loader/SKILL.md +199 -2
  102. package/skills/middleware/SKILL.md +183 -2
  103. package/skills/migrate-nextjs/SKILL.md +2 -2
  104. package/skills/migrate-react-router/SKILL.md +10 -7
  105. package/skills/migrate-react-router/data-and-actions.md +1 -1
  106. package/skills/ppr/SKILL.md +22 -0
  107. package/skills/prerender/SKILL.md +22 -8
  108. package/skills/rango/SKILL.md +13 -7
  109. package/skills/route/SKILL.md +139 -0
  110. package/skills/server-actions/SKILL.md +27 -0
  111. package/skills/testing/SKILL.md +3 -2
  112. package/skills/testing/client-components.md +2 -1
  113. package/skills/testing/middleware.md +31 -10
  114. package/skills/testing/predicates.md +67 -32
  115. package/skills/testing/response-routes.md +1 -0
  116. package/skills/testing/serve-shell-request.md +107 -19
  117. package/skills/testing/setup.md +2 -2
  118. package/skills/typesafety/route-types.md +2 -2
  119. package/skills/use-cache/SKILL.md +4 -2
  120. package/skills/view-transitions/SKILL.md +26 -0
  121. package/src/__augment-tests__/augmented.check.ts +20 -0
  122. package/src/browser/defer.ts +82 -0
  123. package/src/browser/document-revealed.ts +52 -0
  124. package/src/browser/event-controller.ts +46 -32
  125. package/src/browser/merge-segment-loaders.ts +31 -18
  126. package/src/browser/navigation-bridge.ts +82 -16
  127. package/src/browser/navigation-client.ts +48 -36
  128. package/src/browser/navigation-store.ts +13 -0
  129. package/src/browser/navigation-transaction.ts +25 -8
  130. package/src/browser/partial-update.ts +554 -146
  131. package/src/browser/prefetch/fetch.ts +2 -1
  132. package/src/browser/react/Link.tsx +7 -9
  133. package/src/browser/react/NavigationProvider.tsx +88 -49
  134. package/src/browser/react/context.ts +3 -2
  135. package/src/browser/react/filter-segment-order.ts +13 -2
  136. package/src/browser/react/use-router.ts +2 -4
  137. package/src/browser/response-adapter.ts +20 -0
  138. package/src/browser/rsc-router.tsx +17 -45
  139. package/src/browser/server-action-bridge.ts +25 -63
  140. package/src/browser/types.ts +24 -10
  141. package/src/browser/validate-redirect-origin.ts +16 -0
  142. package/src/build/generate-route-types.ts +1 -0
  143. package/src/build/route-types/router-processing.ts +3 -6
  144. package/src/build/route-types/scan-filter.ts +45 -11
  145. package/src/cache/cf/cf-cache-store.ts +59 -21
  146. package/src/cache/cf/cf-cache-types.ts +24 -2
  147. package/src/cache/document-cache-policy.ts +131 -0
  148. package/src/cache/document-cache.ts +21 -107
  149. package/src/client-urls/client-urls.ts +22 -6
  150. package/src/client-urls/hoisted-revalidate.ts +25 -0
  151. package/src/client-urls/navigation.ts +0 -96
  152. package/src/client-urls/revalidate-registry.ts +30 -0
  153. package/src/client-urls/server-projection.ts +151 -65
  154. package/src/client-urls/types.ts +39 -71
  155. package/src/client.tsx +9 -5
  156. package/src/context-var.ts +32 -2
  157. package/src/errors.ts +34 -0
  158. package/src/handles/deferred-resolution.ts +28 -5
  159. package/src/handles/pushed-by.ts +148 -0
  160. package/src/index.rsc.ts +8 -0
  161. package/src/index.ts +7 -0
  162. package/src/loader-store.ts +18 -7
  163. package/src/prefetch-markers.ts +13 -0
  164. package/src/response-utils.ts +19 -0
  165. package/src/reverse.ts +17 -14
  166. package/src/route-content-wrapper.tsx +18 -4
  167. package/src/route-definition/dsl-helpers.ts +27 -4
  168. package/src/route-definition/helpers-types.ts +27 -1
  169. package/src/route-definition/redirect.ts +2 -15
  170. package/src/route-definition/rewrite.ts +146 -0
  171. package/src/route-map-builder.ts +14 -11
  172. package/src/router/basename.ts +20 -0
  173. package/src/router/handler-context.ts +3 -13
  174. package/src/router/intercept-resolution.ts +35 -16
  175. package/src/router/internal-params.ts +16 -0
  176. package/src/router/is-action.ts +2 -1
  177. package/src/router/loader-resolution.ts +85 -4
  178. package/src/router/match-api.ts +36 -1
  179. package/src/router/match-middleware/background-revalidation.ts +16 -4
  180. package/src/router/match-middleware/cache-lookup.ts +35 -7
  181. package/src/router/match-middleware/cache-store.ts +15 -1
  182. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  183. package/src/router/match-result.ts +14 -2
  184. package/src/router/middleware-types.ts +16 -0
  185. package/src/router/middleware.ts +127 -49
  186. package/src/router/navigation-snapshot.ts +2 -1
  187. package/src/router/revalidation.ts +1 -1
  188. package/src/router/route-trie-builder.ts +28 -1
  189. package/src/router/segment-resolution/fresh.ts +22 -18
  190. package/src/router/segment-resolution/loader-cache.ts +2 -1
  191. package/src/router/segment-resolution/loader-mask.ts +5 -3
  192. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  193. package/src/router/segment-resolution/mask-nested.ts +8 -4
  194. package/src/router/segment-resolution/prefetch-deferral.ts +583 -0
  195. package/src/router/segment-resolution/revalidation.ts +215 -82
  196. package/src/rsc/full-payload.ts +37 -5
  197. package/src/rsc/handler.ts +82 -55
  198. package/src/rsc/helpers.ts +24 -0
  199. package/src/rsc/loader-fetch.ts +79 -24
  200. package/src/rsc/response-cache-serve.ts +18 -2
  201. package/src/rsc/response-route-handler.ts +4 -7
  202. package/src/rsc/rewrite-passes.ts +203 -0
  203. package/src/rsc/rsc-rendering.ts +54 -9
  204. package/src/rsc/shell-capture.ts +2 -1
  205. package/src/rsc/ssr-setup.ts +11 -5
  206. package/src/segment-loader-promise.ts +23 -20
  207. package/src/segment-system.tsx +60 -26
  208. package/src/server/context.ts +39 -5
  209. package/src/server/cookie-parse.ts +36 -12
  210. package/src/server/cookie-store.ts +7 -3
  211. package/src/server/handle-store.ts +152 -25
  212. package/src/server/request-context.ts +175 -64
  213. package/src/server/request-headers.ts +4 -14
  214. package/src/server/rewrite-trail.ts +190 -0
  215. package/src/server/slot-accessors.ts +57 -0
  216. package/src/server.ts +4 -0
  217. package/src/ssr/index.tsx +118 -16
  218. package/src/testing/action-form.ts +2 -11
  219. package/src/testing/dispatch.ts +138 -67
  220. package/src/testing/e2e/page-helpers.ts +3 -1
  221. package/src/testing/flight.entry.ts +1 -0
  222. package/src/testing/index.ts +7 -3
  223. package/src/testing/internal/action-id.ts +16 -1
  224. package/src/testing/internal/client-urls-projection.ts +70 -0
  225. package/src/testing/internal/test-action.ts +25 -0
  226. package/src/testing/render-route.tsx +16 -3
  227. package/src/testing/run-loader.ts +4 -1
  228. package/src/testing/run-middleware.ts +56 -11
  229. package/src/testing/run-revalidate.ts +142 -0
  230. package/src/testing/run-transition-when.ts +7 -8
  231. package/src/testing/serve-shell-request.ts +131 -8
  232. package/src/testing/vitest.ts +67 -1
  233. package/src/thenable-status.ts +11 -0
  234. package/src/types/boundaries.ts +12 -0
  235. package/src/types/handler-context.ts +14 -0
  236. package/src/types/loader-types.ts +30 -0
  237. package/src/types/request-scope.ts +8 -0
  238. package/src/types/segments.ts +26 -4
  239. package/src/urls/path-helper-types.ts +27 -5
  240. package/src/urls/type-extraction.ts +30 -10
  241. package/src/use-loader.tsx +18 -13
  242. package/src/vite/discovery/client-urls-projection.ts +20 -21
  243. package/src/vite/discovery/discover-routers.ts +94 -26
  244. package/src/vite/discovery/state.ts +28 -4
  245. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  246. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  247. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  248. package/src/vite/plugins/hoist/literal.ts +417 -0
  249. package/src/vite/plugins/hoist/scope.ts +370 -0
  250. package/src/vite/plugins/hoist/source.ts +84 -0
  251. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  252. package/src/vite/rango.ts +2 -0
  253. package/src/vite/router-discovery.ts +116 -53
  254. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  255. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  256. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  257. package/src/client-urls/revalidate-chain.ts +0 -83
  258. package/src/client-urls/revalidation-protocol.ts +0 -56
  259. package/src/testing/run-client-revalidate.ts +0 -92
@@ -14,32 +14,34 @@ 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.
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. A partial navigation an `intercept()` of the route answers is served from the intercept variant the same bake keeps, as the build writes it: the intercept's handler output is the bake's, its loaders run per 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
18
 
19
19
  ## API
20
20
 
21
21
  ### Options — `ServeShellRequestOptions`
22
22
 
23
- | Field | Type | Meaning |
24
- | ------------ | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
26
- | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
27
- | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
28
- | `partial` | `true \| { from?: string; segments?: string[] }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). |
23
+ | Field | Type | Meaning |
24
+ | ------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
26
+ | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
27
+ | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
28
+ | `partial` | `true \| { from?: string; segments?: string[]; prefetch?: boolean; fill?: boolean }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends it as a `<Link>` prefetch (`X-Rango-Prefetch`), so `prefetch: false` work is deferred. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. |
29
+ | `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[] }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none). Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
29
30
 
30
31
  ### Returns — `ServeShellRequestResult`
31
32
 
32
- | Field | Type | Meaning |
33
- | -------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
34
- | `shellStatus` | `"HIT" \| "MISS" \| null` | `x-rango-shell`; null when the serve path did not consider the request (not a `ppr` route, a nonce'd request, a partial request). |
35
- | `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`. |
36
- | `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`. |
37
- | `flight` | `string \| undefined` | The Flight payload this request rendered: a HIT's tail, a document render, or a partial response. `undefined` when no Flight rendered (a redirect, a middleware response). |
38
- | `key` | `string` | The shell key the serve path resolved for a document it read (MISS or HIT), request partition included. Otherwise (no `ppr`, a partial request) the URL's key without a partition. |
39
- | `readEntry` | `() => Promise<ShellCacheEntry \| null>` | Reads the document entry under `key` from the request's store (a passive `getShell`). A read: on a store with a shell memo it warms the memo, so call it after the reads you count. |
40
- | `readHandles` | `() => Promise<ShellRequestHandles \| undefined>` | Decodes the response's handle data as the browser reads it: `{ hydration, late }` (see "Handles and loader data"). `undefined` when no Flight rendered. |
41
- | `response` | `Response` | Status and headers. Its body is already read. |
42
- | `body` | `string` | The body text. |
33
+ | Field | Type | Meaning |
34
+ | -------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `shellStatus` | `"HIT" \| "MISS" \| null` | `x-rango-shell`; null when the serve path did not consider the request (not a `ppr` route, a nonce'd request, a partial request). |
36
+ | `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`. |
37
+ | `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`. |
38
+ | `flight` | `string \| undefined` | The Flight payload this request rendered: a HIT's tail, a document render, or a partial response. `undefined` when no Flight rendered (a redirect, a middleware response). |
39
+ | `key` | `string` | The shell key the serve path resolved for a document it read (MISS or HIT), request partition included. Otherwise (no `ppr`, a partial request) the URL's key without a partition. |
40
+ | `readEntry` | `() => Promise<ShellCacheEntry \| null>` | Reads the document entry under `key` from the request's store (a passive `getShell`). A read: on a store with a shell memo it warms the memo, so call it after the reads you count. |
41
+ | `readHandles` | `() => Promise<ShellRequestHandles \| undefined>` | Decodes the response's handle data as the browser reads it: `{ hydration, late }` (see "Handles and loader data"). `undefined` when no Flight rendered. |
42
+ | `readDeferred` | `() => Promise<string[] \| undefined>` | The ids of the segments the payload marks deferred (a prefetch skipped them; the fill renders them), in payload order. `[]` when nothing was deferred, `undefined` when no Flight rendered. |
43
+ | `response` | `Response` | Status and headers. Its body is already read. |
44
+ | `body` | `string` | The body text. |
43
45
 
44
46
  ### `resetShellTestState(): Promise<void>`
45
47
 
@@ -250,6 +252,53 @@ Loader data has no decoder: it rides the payload, so assert it as text in `prelu
250
252
  expect(hit.flight).toContain('"stock":"in stock"');
251
253
  ```
252
254
 
255
+ ## Testing `prefetch: false`
256
+
257
+ Send the prefetch with `partial: { prefetch: true }` and read which segments it deferred, then send the fill the browser would send:
258
+
259
+ ```ts
260
+ const prefetch = await serveShellRequest(router, "/product/1", {
261
+ cacheStore,
262
+ partial: { from: "/", prefetch: true },
263
+ });
264
+ // One id per skipped segment: here the flagged loader's. A flagged loading()
265
+ // lists its entry, every entry and slot its fallback covers, and their loaders.
266
+ expect(await prefetch.readDeferred()).toHaveLength(1);
267
+
268
+ // The ids the prefetch delivered, minus the deferred ones: what the page holds.
269
+ const held = deliveredIds.filter((id) => !deferredIds.includes(id));
270
+ const fill = await serveShellRequest(router, "/product/1", {
271
+ cacheStore,
272
+ partial: { from: "/product/1", segments: held, fill: true },
273
+ });
274
+ expect(await fill.readDeferred()).toEqual([]); // a fill defers nothing
275
+ ```
276
+
277
+ `deliveredIds` is the payload's `metadata.matched`, read from `prefetch.flight`. Count the loader's runs in your own fixture: the prefetch ran it zero times, the fill once.
278
+
279
+ ### `clientUrls()` routes
280
+
281
+ A router that mounts a `clientUrls()` group serves its routes here as in your app. The group is the default export of a `"use client"` module, a client reference in this project, and `rangoUseClientTransform()` ([`./setup.md`](./setup.md)) installs its server projection as the rango plugin's discovery does. Mount the module itself:
282
+
283
+ ```tsx
284
+ import shopUrls from "../src/shop/urls"; // "use client"; export default clientUrls(...)
285
+
286
+ const router = createRouter().routes(
287
+ urls(({ include }) => [include("/shop", shopUrls, { name: "shop" })]),
288
+ );
289
+
290
+ // /shop/product/:id declares loader(PriceLoader) and
291
+ // loader(ReviewsLoader, { prefetch: false }).
292
+ const prefetch = await serveShellRequest(router, "/shop/product/1", {
293
+ partial: { from: "/shop", segments: held, prefetch: true },
294
+ });
295
+ expect(await prefetch.readDeferred()).toHaveLength(1); // ReviewsLoader's segment
296
+ ```
297
+
298
+ `held` is the `metadata.matched` of a navigation to `/shop`. On a group route only a loader's own flag defers (the group's, and a server layout's above the group); a flagged `loading()` defers nothing there. Loaders inside the group's `intercept()` ignore the flag, as in `urls()`. `dispatch` matches the group's routes through the same projection.
299
+
300
+ The transform also registers each group loader's `revalidate()` predicates, which Vitest leaves as plain functions in the module, so the server runs them as the hoisted predicates run in your app. A `partial` navigation, or an `action` with the page's `segments`, reaches them with the `urls()` arguments, `actionResult` and `formData` included.
301
+
253
302
  ## Warming a route: router.prerender()
254
303
 
255
304
  `router.prerender()` warms a route that is not `Prerender(..., { onDemand })`
@@ -322,6 +371,45 @@ expect(await result.readPayloadFormState()).toEqual(result.formState);
322
371
  - Request headers come from `headers`. No `Origin` is sent by default, which the origin guard allows.
323
372
  - Not with `partial`. The hydration itself (`hydrateRoot` taking the state, the hook not resetting) is e2e.
324
373
 
374
+ ## A server action called from the page
375
+
376
+ `serveShellRequest(router, url, { action: { ref, id, args, segments } })` sends the request a JS server action call makes from the page at `url`, as `browser/server-action-bridge.ts` sends it: a POST of `args` with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments`. `ref` is registered under `id` (`<module id>#<export name>`, any unique string of that shape in a test) so the handler loads it by id. The response is the action's Flight payload: its return value and the segments it re-rendered, `flight` as for any other request. Pass the segment ids the page holds as `segments`, so the request has segments to revalidate; without them the server holds none. Read them from the `metadata.matched` of a `partial` request's payload (`flight`), as in "Testing `prefetch: false`" above.
377
+
378
+ ```tsx
379
+ const page = await serveShellRequest(router, "/shop", {
380
+ partial: { from: "/" },
381
+ });
382
+ const held: string[] = readMatched(page.flight); // your parse of metadata.matched
383
+ const result = await serveShellRequest(router, "/shop", {
384
+ action: {
385
+ ref: checkout,
386
+ id: "src/actions/cart.ts#checkout",
387
+ args: [formData],
388
+ segments: held,
389
+ },
390
+ });
391
+ ```
392
+
393
+ This is the request a `clientUrls()` group's `revalidate()` sees on an action: the action's return value as `actionResult`, its last `FormData` argument as `formData`. The pinned example is `src/testing/__tests__/client-urls-revalidate.rsc-test.tsx`. Not with `partial` or `form`.
394
+
395
+ ## A page middleware rewrites
396
+
397
+ A request a middleware rewrites (`throw rewrite()`, see `/middleware`) runs through the same production handler: the visitor's pass, then the target's, with one request context. Assert what the visitor gets: the target's body and status, `x-rango-rewrite`, the visitor's pathname in the payload, and on a `ppr` target the target's shell key.
398
+
399
+ ```ts
400
+ it("a locked page renders the maintenance route at its own URL", async () => {
401
+ const result = await serveShellRequest(router, "/shop?lock=on");
402
+ expect(result.response.status).toBe(503); // set by the target's middleware
403
+ expect(result.response.headers.get("x-rango-rewrite")).toBe("/maintenance");
404
+ expect(result.body).toContain("Down for maintenance");
405
+
406
+ const nav = await serveShellRequest(router, "/shop?lock=on", {
407
+ partial: { from: "/" },
408
+ });
409
+ expect(nav.flight).toContain("Down for maintenance"); // the target's segments
410
+ });
411
+ ```
412
+
325
413
  ## Caveats
326
414
 
327
415
  What the HTML stub cannot reproduce, so keep it in e2e:
@@ -337,7 +425,7 @@ Other caveats:
337
425
  - 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.
338
426
  - `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.
339
427
  - `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.
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`.
428
+ - GET, and two POST shapes: a form submitted before hydration (see "A form submitted before hydration" above) and a JS server action call (`action`, see "A server action called from the page" above). Test an action's own effects (cookies, headers) with `runInRequestContext`.
341
429
  - Same setup as every Flight test: the react-server condition and `rangoTestAliases()` (see [`./setup.md`](./setup.md)).
342
430
 
343
431
  ## See also
@@ -19,7 +19,7 @@ Real machinery: Vite transpiles `@rangojs/router`'s shipped TS source and resolv
19
19
  | `rangoTestConfig(opts?)` | `{ alias, server: { deps: { inline } } }` | Recommended. Spread into the node/DOM project's `test` block. Bundles the resolve aliases AND `server.deps.inline`. |
20
20
  | `rangoTestAliases(opts?)` | `TestAlias[]` (`{ find, replacement }[]`) | Lower-level. The bare `@rangojs/router` -> `index.rsc.ts` alias plus the `:version` / `@vitejs/plugin-rsc/rsc` (`/rsc/server`, `/rsc/client`) stubs (and CF stubs under `preset:"cloudflare"`). Used in the rsc project's `resolve.alias`. |
21
21
  | `rangoInlineDeps` | `RegExp[]` (`[/@rangojs[/\\]router/]`) | The `server.deps.inline` patterns on their own. Use in the rsc project (`test.server.deps.inline`), or wherever you call `rangoTestAliases` directly instead of `rangoTestConfig`. |
22
- | `rangoUseClientTransform()` | a Vite plugin (`{ name, transform }`) | Add to the rsc project `plugins`. Applies the `"use client"` transform so `renderServerTree` / `renderHandler` auto-discover client islands from the server tree's imports. |
22
+ | `rangoUseClientTransform()` | a Vite plugin (`resolveId`, `transform`) | Add to the rsc project `plugins`. Applies the `"use client"` transform so `renderServerTree` / `renderHandler` auto-discover client islands, and projects `"use client"` `clientUrls()` modules for `serveShellRequest` / `dispatch`. |
23
23
  | `rangoUseCacheTransform()` | a Vite plugin (`enforce: "post"`) | Add to the rsc project `plugins`. Runs the rango plugin's `"use cache"` transform with dev ids (`<path>#<name>`, the path relative to the root its `configResolved` reads) so a directive function caches. |
24
24
 
25
25
  ### Returns — `RangoTestConfig` (from `rangoTestConfig`)
@@ -117,7 +117,7 @@ Scripts:
117
117
  - The forked rsc worker (`pool: "forks"`) must force the condition via `execArgv: ["--conditions=react-server"]`, or React throws "the react-server condition must be enabled".
118
118
  - The `@rangojs/router:version` and `@vitejs/plugin-rsc/rsc` (`/rsc/server`, `/rsc/client`) virtuals must be stubbed; the preset does it. A bare router import without stubbing throws.
119
119
  - The rango fragment goes under `test` (`test.alias` + `test.server.deps.inline`, both returned by `rangoTestConfig`), NOT under top-level `resolve`.
120
- - Wire `rangoUseClientTransform()` into the rsc project `plugins` so islands auto-discover from the server tree imports (see `./server-tree.md`); without it, register islands explicitly with `clientComponents`. The transform's `registerClientReference` import is resolved from `@rangojs/router`'s `@vitejs/plugin-rsc` — the consumer does not need a direct plugin-rsc dependency.
120
+ - Wire `rangoUseClientTransform()` into the rsc project `plugins` so islands auto-discover from the server tree imports (see `./server-tree.md`); without it, register islands explicitly with `clientComponents`. It also installs the server projection of every `"use client"` module whose default export is a `clientUrls()` definition, as the rango plugin's discovery does in your app, so `include("/shop", shopUrls)` and `.routes(shopUrls)` serve the group's routes through `serveShellRequest` and `dispatch`; without it the include throws `could not resolve the server projection`. The node project needs nothing: there the module's default export is the definition itself. The transform's `registerClientReference` import is resolved from `@rangojs/router`'s `@vitejs/plugin-rsc` — the consumer does not need a direct plugin-rsc dependency.
121
121
  - Wire `rangoUseCacheTransform()` into the rsc project `plugins` so a function written with `"use cache"` is wrapped as in dev. `rango()` runs that transform only in its `rsc` environment and Vitest transforms in `ssr`, so without it the directive is inert and the function runs on every call. See [`./render-handler.md`](./render-handler.md#a-use-cache-hit).
122
122
 
123
123
  ## See also
@@ -246,5 +246,5 @@ changes nothing, so narrow with the ternary above. Avoid a bare
246
246
  the reference the same way the router derives `actionId` (`$id ?? $$id`: the
247
247
  file-path `$id` injected in a production RSC build, otherwise React's `$$id`),
248
248
  so matching works in dev and production. `actionId` stays available for
249
- advanced cases. In `clientUrls()` groups the predicate runs in the browser,
250
- where `actionId` is the hashed form (see `/client-urls`).
249
+ advanced cases. In `clientUrls()` groups the loader's predicate runs on the
250
+ server too, with these same arguments (see `/client-urls`).
@@ -236,8 +236,10 @@ this guard, one user's data would be served to another.
236
236
  The raw reads throw the same way: `ctx.request.headers` on any `ctx` passed in
237
237
  (handler, loader, middleware; it is one `Request` object, also
238
238
  `getRequestContext().request`), and `getRequestContext().cookie()` /
239
- `.cookies()`. A `ctx` or a `Request` argument keys the entry by its route and
240
- URL only, never by its headers.
239
+ `.cookies()`. `ctx.rewrittenFrom` (the visitor's URL of a request middleware
240
+ rewrote) throws too, on a request nothing rewrote as well: it is not in the
241
+ key. A `ctx` or a `Request` argument keys the entry by its route and URL
242
+ only, never by its headers.
241
243
 
242
244
  Extract the value before the cached function and pass it as an argument:
243
245
 
@@ -433,6 +433,8 @@ function ProductPage() {
433
433
 
434
434
  This is the rango analogue of the "router triggers, you place the names" model used by React Router / TanStack: rango guarantees navigations run inside a React transition; you own the boundaries.
435
435
 
436
+ On a route with `loading()` and slow loaders, `viewTransition: false` is also what removes the view transition that changes nothing; see [A view transition that changes nothing under `loading()`](#a-view-transition-that-changes-nothing-under-loading).
437
+
436
438
  **App-wide default.** Flip the default for every `transition()` segment at the router level. A per-segment `viewTransition` still overrides it.
437
439
 
438
440
  ```ts
@@ -463,6 +465,30 @@ On React 19.2 (no `<ViewTransition>` export) the "VT" column is a no-op, so ever
463
465
 
464
466
  > On React 19.2 there is no `<ViewTransition>` at all, so `viewTransition: false` is visually a no-op there — but the startTransition driving and content-hold still apply, identical to `transition({})`.
465
467
 
468
+ ## A view transition that changes nothing under `loading()`
469
+
470
+ On a cold click, a route with `transition()` and `loading()` can start a view transition that changes nothing on screen, and its content shows only after that transition ends ([#1078](https://github.com/rangojs/rango/issues/1078)). React causes it, not a router update, and the router does not steer or cancel it. Two shapes hit it:
471
+
472
+ - **One loader slower than about 320 ms, on a navigation where the route's client component module is still loading when the navigation first renders.** In a production build that is the first navigation to that module; in dev it is every navigation, because the dev server resolves each response's client references asynchronously.
473
+ - **Two or more loaders under one `loading()` whose data lands at different times, the last one later than about 320 ms.** Module state does not matter.
474
+
475
+ Not affected: a fully prefetched click (it commits complete), and a loader that lands before the fallback has been up for 300 ms (the 100 ms and 280 ms cells in #1078 ran two view transitions, the navigation's and the reveal's).
476
+
477
+ The cost is the browser's view-transition duration before the content shows: Chromium's default animation is 250 ms, and the idle transitions measured took 265 to 285 ms from `ready` to `finished`. The View Transitions guide on the docs site ("When a view transition changes nothing") has the start counts and content times per shape, in dev and in production.
478
+
479
+ **Why.** When the first thing the route's `loading()` boundary waits on lands (the client module, or the first loader), React retries the boundary. The retry suspends again on the slower loader and commits with the fallback still in place, throttled to 300 ms after the fallback committed. That commit is view-transition eligible and the router's `<ViewTransition>` is on its path, so React calls `document.startViewTransition`; nothing changes on screen, the browser still runs its default animation, and the slower loader's reveal waits: React holds the next view-transition commit until the running transition's `finished` resolves. React 19.3.0 and the canary and experimental builds of 2026-10-06 behave the same.
480
+
481
+ ### Escape hatches
482
+
483
+ Two options remove it, in dev and in production:
484
+
485
+ - **`transition({ viewTransition: false })`** places no router `<ViewTransition>` for the segment that declares it, so React has nothing of that segment's to animate on the retry commit. The navigation still commits through `startTransition`, so same-route content is still held; what you give up is the router's cross-fade. It only covers the segment it is set on: a layout above that declares its own `transition()` still wraps the route's outlet content in a router `<ViewTransition>` (`wrapDefaultOutletContent` in [segment-system.tsx](../../src/segment-system.tsx)), and the idle transition stays. Set it on every segment in the matched chain that declares `transition()`, or app-wide with `createRouter({ viewTransition: false })`.
486
+ - **`transition({ when })` returning `false`** commits the navigation urgently and renders every router `<ViewTransition>` of the navigation with every class `"none"` (`GATED_OFF_CLASSES` in [segment-system.tsx](../../src/segment-system.tsx)); React starts no transition for it. One `when` on one segment is enough, because a `false` gates the whole navigation off: on a same-route navigation the `loading()` skeleton streams instead of the previous content being held, and nothing animates. Use it when `kind`, `from` and `to` tell you which navigations hit the shape.
487
+
488
+ ### Your own `<ViewTransition>`
489
+
490
+ A `<ViewTransition>` that you place and that stays on screen across the navigation (for example around a layout's `<Outlet />`) takes part in the same commits and starts the same idle transition, whichever router option is set. The router only controls its own boundaries, so this one is yours to configure. `update="none"` on it started no view transition for the click, but the boundary then no longer animates the real navigation's update either (no cross-fade of the outlet). Measured in Chromium only; other engines were not measured.
491
+
466
492
  ## Testing a transition() route
467
493
 
468
494
  On React 19.3+ a transition commit briefly keeps the exiting `<ViewTransition>`
@@ -19,6 +19,7 @@ import type {
19
19
  import type { DefaultRouteName } from "../types/global-namespace.js";
20
20
  import type { InterceptWhenFn } from "../server/context.js";
21
21
  import { href } from "../href-client.js";
22
+ import { rewrite, type Rewrite } from "../route-definition/rewrite.js";
22
23
  import type { Money, TestBindings } from "./augment.js";
23
24
 
24
25
  type Expect<T extends true> = T;
@@ -68,6 +69,25 @@ function middlewareReverse(ctx: MiddlewareContext): void {
68
69
  }
69
70
  void middlewareReverse;
70
71
 
72
+ // rewrite() takes a path, or a global name with the params and search
73
+ // ctx.reverse takes; ctx.isRewrite narrows a caught value to a Rewrite.
74
+ function middlewareRewrite(ctx: MiddlewareContext, caught: unknown): void {
75
+ void rewrite("/maintenance?from=shop");
76
+ void rewrite("home");
77
+ void rewrite("blog.post", { slug: "hello" });
78
+ void rewrite("search", {}, { q: "hats" });
79
+ // @ts-expect-error - a route with params needs them
80
+ void rewrite("blog.post");
81
+ // @ts-expect-error - unknown global name is rejected
82
+ void rewrite("nope");
83
+ // @ts-expect-error - dot-local name; a rewrite resolves global names only
84
+ void rewrite(".home");
85
+ if (ctx.isRewrite(caught)) {
86
+ type _narrowed = Expect<Equal<typeof caught, Rewrite>>;
87
+ }
88
+ }
89
+ void middlewareRewrite;
90
+
71
91
  // routeName narrows to the generated route names.
72
92
  type _routeName = Expect<
73
93
  Equal<DefaultRouteName, "home" | "blog.post" | "search">
@@ -0,0 +1,82 @@
1
+ import { use } from "react";
2
+ import { SETTLED_THENABLE } from "../thenable-status.js";
3
+
4
+ /**
5
+ * One hole of a `prefetch: false` adoption (browser/partial-update.ts
6
+ * armHoles): the promise every reader of the hole waits on, and what the
7
+ * router knows of it. The router sets `status` and `value` here; it never
8
+ * writes React's thenable fields on `promise`.
9
+ *
10
+ * Resolve-only: a hole is filled or released (releaseHoles), never rejected.
11
+ * A failure arrives as a fulfilled value (a loader's error envelope, an
12
+ * error segment's node).
13
+ */
14
+ export class Defer<T> {
15
+ status: "pending" | "fulfilled" = "pending";
16
+ value: T | undefined;
17
+ /** A reader has handed React `promise` (readDefer). */
18
+ read?: boolean;
19
+ /**
20
+ * Resolved with a value the router holds (a node), the hole is
21
+ * "fulfilled" at once. Resolved with a stream (a Flight chunk), its promise
22
+ * follows the stream, and so does the hole: "fulfilled" with the stream's
23
+ * value once it arrives, so a reader that mounts after that reads the value
24
+ * as it would read the chunk Flight marked. A rejected stream leaves the
25
+ * hole pending; its promise carries the rejection.
26
+ */
27
+ resolve!: (value: T) => void;
28
+ readonly promise: Promise<T> = new Promise<T>((settle) => {
29
+ this.resolve = (value) => {
30
+ settle(value);
31
+ if (value instanceof Promise) {
32
+ value.then(
33
+ (arrived: T) => {
34
+ this.status = "fulfilled";
35
+ this.value = arrived;
36
+ },
37
+ () => {},
38
+ );
39
+ } else {
40
+ this.status = "fulfilled";
41
+ this.value = value;
42
+ }
43
+ };
44
+ });
45
+ }
46
+
47
+ /**
48
+ * The read rule, for a component that reads a hole (Suspender, useLoader).
49
+ * A hole that has fulfilled and that no reader has handed React is its
50
+ * value: use() of a promise React has not seen suspends an urgent render
51
+ * once, and the boundary shows its fallback. Once a reader has handed React
52
+ * the promise, every read is use() of that promise: React has marked it and
53
+ * returns its value at once, and a replay of the reader must hand use() the
54
+ * thenable it read before ("A component was suspended by an uncached
55
+ * promise"). Either way use() is called once, as at every useLoader read.
56
+ */
57
+ export function readDefer<T>(hole: Defer<T>): T {
58
+ if (hole.status === "fulfilled" && !hole.read) {
59
+ use(SETTLED_THENABLE);
60
+ return hole.value as T;
61
+ }
62
+ hole.read = true;
63
+ return use(hole.promise);
64
+ }
65
+
66
+ /**
67
+ * A hole as the loader-streams convention has it: its promise while pending,
68
+ * its value once fulfilled. Anything else is returned as it is. For an
69
+ * aggregate, the pending-stream registry, and a node React reads itself (a
70
+ * segment or slot with no `loading()`): a router component there would be a
71
+ * fiber the filled page's node does not have, and the next render from the
72
+ * filled entry would remount the segment. React suspends such a read on the
73
+ * component above it, which commits nothing until the hole fulfils, so the
74
+ * value can stand where React read the promise.
75
+ */
76
+ export function asStream(value: unknown): unknown {
77
+ return value instanceof Defer
78
+ ? value.status === "fulfilled"
79
+ ? value.value
80
+ : value.promise
81
+ : value;
82
+ }
@@ -0,0 +1,52 @@
1
+ /** Poll interval; setTimeout so a tab hidden after first paint still settles. */
2
+ const REVEAL_POLL_MS = 50;
3
+
4
+ /**
5
+ * A Suspense boundary of the document React has not revealed yet: Fizz marks
6
+ * one `$?` while its content streams and `$~` once its reveal is queued.
7
+ */
8
+ function isUnrevealed(node: Comment): boolean {
9
+ return node.isConnected && (node.data === "$?" || node.data === "$~");
10
+ }
11
+
12
+ function unrevealedBoundaries(): Comment[] {
13
+ const found: Comment[] = [];
14
+ const walker = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT);
15
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
16
+ if (isUnrevealed(node as Comment)) found.push(node as Comment);
17
+ }
18
+ return found;
19
+ }
20
+
21
+ /**
22
+ * Resolves once the document has finished parsing and no Suspense boundary of
23
+ * it is unrevealed.
24
+ *
25
+ * Parsing ends with the stream, but React reveals a streamed boundary in
26
+ * batches up to 300 ms later (Fizz's $RC marks it `$~` and reveals it from a
27
+ * timer), so the boundary is still pending at DOMContentLoaded. A transition
28
+ * then would make React client-render it over its server HTML.
29
+ *
30
+ * A reveal rewrites the same comment's data, or React removes the node; a
31
+ * boundary nested in revealed content joins the document only then, so it
32
+ * resolves only once a full walk finds none. Polls with setTimeout, not
33
+ * requestAnimationFrame: a hidden tab never runs rAF, which would hold late
34
+ * handle pushes and location state until the tab is shown again. A tab hidden
35
+ * from load still waits: Fizz reveals boundaries from a rAF-gated script.
36
+ */
37
+ export function awaitDocumentRevealed(): Promise<void> {
38
+ return new Promise<void>((resolve) => {
39
+ const start = (): void => {
40
+ let pending = unrevealedBoundaries();
41
+ const check = (): void => {
42
+ pending = pending.filter(isUnrevealed);
43
+ if (pending.length === 0) pending = unrevealedBoundaries();
44
+ if (pending.length > 0) setTimeout(check, REVEAL_POLL_MS);
45
+ else resolve();
46
+ };
47
+ check();
48
+ };
49
+ if (document.readyState !== "loading") start();
50
+ else document.addEventListener("DOMContentLoaded", start, { once: true });
51
+ });
52
+ }
@@ -15,7 +15,7 @@ import {
15
15
  filterRouteSegmentIds,
16
16
  } from "./react/filter-segment-order.js";
17
17
  import { notifyListeners } from "./notify-listeners.js";
18
- import { cloneHandleData } from "./navigation-store.js";
18
+ import { mergePartialHandleData } from "../handles/pushed-by.js";
19
19
  import {
20
20
  locationStateSnapshot,
21
21
  type LocationStateSnapshot,
@@ -223,7 +223,13 @@ export interface EventController {
223
223
  url: string,
224
224
  options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
225
225
  ): NavigationHandle;
226
- abortNavigation(): void;
226
+ /**
227
+ * Abort the pending navigation, and the committed one whose stream is
228
+ * still open. `pendingOnly` leaves the committed one alone: a page
229
+ * restored from the back/forward cache is not being left
230
+ * (navigation-bridge.ts handlePageShow).
231
+ */
232
+ abortNavigation(pendingOnly?: boolean): void;
227
233
 
228
234
  // Action operations
229
235
  startAction(
@@ -476,6 +482,14 @@ export function createEventController(
476
482
  config?: EventControllerConfig,
477
483
  ): EventController {
478
484
  let currentNavigation: NavigationEntry | null = null;
485
+ // The navigation that committed last, while its stream is still open. A
486
+ // newer navigation or a back/forward aborts it as it aborts one that has
487
+ // not committed: what a navigation does after its commit (the fill of a
488
+ // prefetch: false adoption, partial-update.ts fillHoles) reads its signal.
489
+ // The abort never cancels a response that has arrived (navigation-client.ts
490
+ // links a request to its signal only until then): the navigation stops
491
+ // reading it, and it streams to its end.
492
+ let streamingNavigation: AbortController | null = null;
479
493
 
480
494
  const inflightActions = new Map<string, ActionEntry>();
481
495
 
@@ -698,6 +712,7 @@ export function createEventController(
698
712
  options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
699
713
  ): NavigationHandle {
700
714
  // Cancel existing navigation (switchMap semantics)
715
+ abortStreamingNavigation();
701
716
  if (currentNavigation) {
702
717
  currentNavigation.abort.abort();
703
718
  currentNavigation = null;
@@ -716,6 +731,7 @@ export function createEventController(
716
731
  notify();
717
732
 
718
733
  let completed = false;
734
+ let openStreams = 0;
719
735
 
720
736
  return {
721
737
  abort,
@@ -729,6 +745,7 @@ export function createEventController(
729
745
  let ended = false;
730
746
  entry.phase = "streaming";
731
747
  activeStreamCount++;
748
+ openStreams++;
732
749
  notify();
733
750
 
734
751
  return {
@@ -736,6 +753,9 @@ export function createEventController(
736
753
  if (ended) return;
737
754
  ended = true;
738
755
  activeStreamCount = Math.max(0, activeStreamCount - 1);
756
+ if (--openStreams === 0 && streamingNavigation === abort) {
757
+ streamingNavigation = null;
758
+ }
739
759
  notify();
740
760
  },
741
761
  };
@@ -746,6 +766,7 @@ export function createEventController(
746
766
  completed = true;
747
767
  location = newLocation;
748
768
  currentNavigation = null;
769
+ if (openStreams > 0) streamingNavigation = abort;
749
770
  notify();
750
771
  }
751
772
  },
@@ -764,7 +785,14 @@ export function createEventController(
764
785
  };
765
786
  }
766
787
 
767
- function abortNavigation() {
788
+ function abortStreamingNavigation(): void {
789
+ const streaming = streamingNavigation;
790
+ streamingNavigation = null;
791
+ streaming?.abort();
792
+ }
793
+
794
+ function abortNavigation(pendingOnly?: boolean) {
795
+ if (!pendingOnly) abortStreamingNavigation();
768
796
  if (currentNavigation) {
769
797
  currentNavigation.abort.abort();
770
798
  currentNavigation = null;
@@ -1009,6 +1037,9 @@ export function createEventController(
1009
1037
  // If not settled and not completed, this is an error case - force finalize
1010
1038
  if (!settled && inflightActions.has(id)) {
1011
1039
  actionCompleted = true;
1040
+ // Done, as complete()/fail() would mark it: an action started in its
1041
+ // settle window is sequential, not concurrent (#675).
1042
+ entry.completed = true;
1012
1043
  streamingEnded = true;
1013
1044
  tryFinalize();
1014
1045
  }
@@ -1061,35 +1092,18 @@ export function createEventController(
1061
1092
  const newRouteSegmentIds = filterRouteSegmentIds(rawMatched);
1062
1093
 
1063
1094
  if (isPartial && newSegmentOrder.length > 0) {
1064
- // Partial update: merge new data with existing, into a copy of the
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.
1067
- handleData = cloneHandleData(handleData);
1068
- for (const handleName of Object.keys(data)) {
1069
- if (!handleData[handleName]) {
1070
- handleData[handleName] = {};
1071
- }
1072
- for (const segmentId of Object.keys(data[handleName])) {
1073
- handleData[handleName][segmentId] = data[handleName][segmentId];
1074
- }
1075
- }
1076
- const resolvedIdSet =
1077
- resolvedIds && resolvedIds.length > 0 ? new Set(resolvedIds) : null;
1078
- // Cleanup pass:
1079
- // a) segment dropped from the match list — delete its bucket.
1080
- // b) segment was re-resolved this request but pushed nothing for
1081
- // this handle — its previous bucket is stale.
1082
- // (a) is the existing behavior; (b) requires resolvedIds.
1083
- for (const handleName of Object.keys(handleData)) {
1084
- for (const segmentId of Object.keys(handleData[handleName])) {
1085
- const droppedFromMatch = !newSegmentOrder.includes(segmentId);
1086
- const reresolvedWithoutPush =
1087
- resolvedIdSet?.has(segmentId) && !data[handleName]?.[segmentId];
1088
- if (droppedFromMatch || reresolvedWithoutPush) {
1089
- delete handleData[handleName][segmentId];
1090
- }
1091
- }
1092
- }
1095
+ // Partial update: a segment dropped from the match loses its buckets,
1096
+ // and in each other bucket only the handler and loaders that ran for
1097
+ // the response replace their pushes (#1103). Into new containers: a
1098
+ // history entry restored from the store's cache can be the object a
1099
+ // reader was handed.
1100
+ handleData = mergePartialHandleData(
1101
+ handleData,
1102
+ data,
1103
+ newSegmentOrder,
1104
+ rawMatched,
1105
+ resolvedIds,
1106
+ );
1093
1107
  } else {
1094
1108
  // Full update: replace all data
1095
1109
  handleData = data;