@timber-js/app 0.2.0-alpha.197 → 0.2.0-alpha.199

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 (249) hide show
  1. package/dist/_chunks/{actions-BS-m5SLv.js → actions-d1hCqnU3.js} +35 -8
  2. package/dist/_chunks/actions-d1hCqnU3.js.map +1 -0
  3. package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -1
  4. package/dist/_chunks/{cache-api-DqzgTEqk.js → cache-api-ByagcC-J.js} +2 -2
  5. package/dist/_chunks/{cache-api-DqzgTEqk.js.map → cache-api-ByagcC-J.js.map} +1 -1
  6. package/dist/_chunks/canonicalize-CgHoscYO.js +66 -0
  7. package/dist/_chunks/canonicalize-CgHoscYO.js.map +1 -0
  8. package/dist/_chunks/{chains-CZG7E5zg.js → chains-Bpb0W4ax.js} +3 -3
  9. package/dist/_chunks/{chains-CZG7E5zg.js.map → chains-Bpb0W4ax.js.map} +1 -1
  10. package/dist/_chunks/{cli-check-bE3H5Bjr.js → cli-check-D6VolrDV.js} +3 -3
  11. package/dist/_chunks/{cli-check-bE3H5Bjr.js.map → cli-check-D6VolrDV.js.map} +1 -1
  12. package/dist/_chunks/{cli-schema-sync-DTy_-Msq.js → cli-schema-sync-D6rO-VcS.js} +2 -2
  13. package/dist/_chunks/{cli-schema-sync-DTy_-Msq.js.map → cli-schema-sync-D6rO-VcS.js.map} +1 -1
  14. package/dist/_chunks/{convention-lint-n3RJLgww.js → convention-lint-fRkwVwEH.js} +27 -4
  15. package/dist/_chunks/convention-lint-fRkwVwEH.js.map +1 -0
  16. package/dist/_chunks/error-boundary-BfPHZjm0.js +1050 -0
  17. package/dist/_chunks/error-boundary-BfPHZjm0.js.map +1 -0
  18. package/dist/_chunks/{live-graph-BXDsdzBv.js → live-graph-D_2D32Ad.js} +3 -3
  19. package/dist/_chunks/{live-graph-BXDsdzBv.js.map → live-graph-D_2D32Ad.js.map} +1 -1
  20. package/dist/_chunks/{logger-DDirEsn7.js → logger-uLBuGKDI.js} +471 -440
  21. package/dist/_chunks/logger-uLBuGKDI.js.map +1 -0
  22. package/dist/_chunks/{navigation-root-B00jjGd5.js → navigation-context-D0TU0Jog.js} +3 -101
  23. package/dist/_chunks/navigation-context-D0TU0Jog.js.map +1 -0
  24. package/dist/_chunks/navigation-root-mHSK9psY.js +126 -0
  25. package/dist/_chunks/{navigation-root-B00jjGd5.js.map → navigation-root-mHSK9psY.js.map} +1 -1
  26. package/dist/_chunks/{poison-scan-BoDLgbix.js → poison-scan-Bm9Yyqk9.js} +2 -2
  27. package/dist/_chunks/{poison-scan-BoDLgbix.js.map → poison-scan-Bm9Yyqk9.js.map} +1 -1
  28. package/dist/_chunks/{scanner-tdFPvDYi.js → scanner-AiazgH_f.js} +6 -5
  29. package/dist/_chunks/scanner-AiazgH_f.js.map +1 -0
  30. package/dist/_chunks/{segment-keys-BhqoHiLc.js → segment-keys-lqtdookO.js} +2 -65
  31. package/dist/_chunks/segment-keys-lqtdookO.js.map +1 -0
  32. package/dist/_chunks/{ssr-data-BQGhTPAK.js → ssr-data-D6T6Y3ef.js} +4 -26
  33. package/dist/_chunks/ssr-data-D6T6Y3ef.js.map +1 -0
  34. package/dist/_chunks/state-FippDgxN.js +52 -0
  35. package/dist/_chunks/state-FippDgxN.js.map +1 -0
  36. package/dist/_chunks/status-page-marker-DwQBrLBz.js +496 -0
  37. package/dist/_chunks/status-page-marker-DwQBrLBz.js.map +1 -0
  38. package/dist/_chunks/{walkers-DNX05dC0.js → walkers-B6XUtmqK.js} +2 -2
  39. package/dist/_chunks/{walkers-DNX05dC0.js.map → walkers-B6XUtmqK.js.map} +1 -1
  40. package/dist/analyze/crawl-entry.js +2 -2
  41. package/dist/analyze/graph-command.js +2 -2
  42. package/dist/cache/index.js +1 -1
  43. package/dist/cli.js +2 -2
  44. package/dist/client/browser-entry/action-dispatch.d.ts +6 -4
  45. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  46. package/dist/client/browser-entry/action-queue.d.ts +44 -0
  47. package/dist/client/browser-entry/action-queue.d.ts.map +1 -0
  48. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  49. package/dist/client/deny-last-resort.d.ts +29 -0
  50. package/dist/client/deny-last-resort.d.ts.map +1 -0
  51. package/dist/client/error-boundary.d.ts +48 -2
  52. package/dist/client/error-boundary.d.ts.map +1 -1
  53. package/dist/client/error-boundary.js +2 -2
  54. package/dist/client/history.d.ts +21 -2
  55. package/dist/client/history.d.ts.map +1 -1
  56. package/dist/client/index.js +34 -14
  57. package/dist/client/index.js.map +1 -1
  58. package/dist/client/internal.d.ts +1 -0
  59. package/dist/client/internal.d.ts.map +1 -1
  60. package/dist/client/internal.js +272 -1225
  61. package/dist/client/internal.js.map +1 -1
  62. package/dist/client/link.d.ts.map +1 -1
  63. package/dist/client/navigation-commit.d.ts +12 -19
  64. package/dist/client/navigation-commit.d.ts.map +1 -1
  65. package/dist/client/navigation-transition.d.ts +62 -11
  66. package/dist/client/navigation-transition.d.ts.map +1 -1
  67. package/dist/client/router-effects.d.ts +9 -8
  68. package/dist/client/router-effects.d.ts.map +1 -1
  69. package/dist/client/router-lifecycle.d.ts +60 -17
  70. package/dist/client/router-lifecycle.d.ts.map +1 -1
  71. package/dist/client/router-pipeline.d.ts +7 -4
  72. package/dist/client/router-pipeline.d.ts.map +1 -1
  73. package/dist/client/router-types.d.ts +63 -8
  74. package/dist/client/router-types.d.ts.map +1 -1
  75. package/dist/client/router.d.ts.map +1 -1
  76. package/dist/client/rsc-fetch.d.ts +0 -9
  77. package/dist/client/rsc-fetch.d.ts.map +1 -1
  78. package/dist/client/segment-cache.d.ts +23 -8
  79. package/dist/client/segment-cache.d.ts.map +1 -1
  80. package/dist/client/state.d.ts +16 -0
  81. package/dist/client/state.d.ts.map +1 -1
  82. package/dist/client/status-page-marker.d.ts +25 -0
  83. package/dist/client/status-page-marker.d.ts.map +1 -0
  84. package/dist/cookies/index.js +1 -1
  85. package/dist/dev-tools/holding-server.d.ts +4 -17
  86. package/dist/dev-tools/holding-server.d.ts.map +1 -1
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +74 -175
  89. package/dist/index.js.map +1 -1
  90. package/dist/plugins/dev-server.d.ts.map +1 -1
  91. package/dist/routing/codegen-write.d.ts.map +1 -1
  92. package/dist/routing/index.js +2 -2
  93. package/dist/routing/interception.d.ts +2 -2
  94. package/dist/routing/slot-placement.d.ts +2 -2
  95. package/dist/server/access-gate.d.ts +73 -1
  96. package/dist/server/access-gate.d.ts.map +1 -1
  97. package/dist/server/action-handler.d.ts.map +1 -1
  98. package/dist/server/actions.d.ts +16 -1
  99. package/dist/server/actions.d.ts.map +1 -1
  100. package/dist/server/als-registry.d.ts +3 -9
  101. package/dist/server/als-registry.d.ts.map +1 -1
  102. package/dist/server/children-interception.d.ts +1 -1
  103. package/dist/server/default-status-page.d.ts +2 -2
  104. package/dist/server/default-status-page.d.ts.map +1 -1
  105. package/dist/server/deny-boundary.d.ts +15 -9
  106. package/dist/server/deny-boundary.d.ts.map +1 -1
  107. package/dist/server/deny-renderer.d.ts.map +1 -1
  108. package/dist/server/error-boundary-wrapper.d.ts +21 -4
  109. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  110. package/dist/server/error-response-headers.d.ts +3 -0
  111. package/dist/server/error-response-headers.d.ts.map +1 -0
  112. package/dist/server/index.js +3 -3
  113. package/dist/server/index.js.map +1 -1
  114. package/dist/server/internal.d.ts +1 -2
  115. package/dist/server/internal.d.ts.map +1 -1
  116. package/dist/server/internal.js +2339 -2506
  117. package/dist/server/internal.js.map +1 -1
  118. package/dist/server/metadata-collector.d.ts +2 -5
  119. package/dist/server/metadata-collector.d.ts.map +1 -1
  120. package/dist/server/param-coercion.d.ts +10 -3
  121. package/dist/server/param-coercion.d.ts.map +1 -1
  122. package/dist/server/pipeline-outcome.d.ts.map +1 -1
  123. package/dist/server/pipeline-phases.d.ts +11 -0
  124. package/dist/server/pipeline-phases.d.ts.map +1 -1
  125. package/dist/server/port-resolution.d.ts +3 -89
  126. package/dist/server/port-resolution.d.ts.map +1 -1
  127. package/dist/server/primitives.d.ts +38 -10
  128. package/dist/server/primitives.d.ts.map +1 -1
  129. package/dist/server/response-cache-policy.d.ts +3 -0
  130. package/dist/server/response-cache-policy.d.ts.map +1 -0
  131. package/dist/server/route-element-builder.d.ts +11 -41
  132. package/dist/server/route-element-builder.d.ts.map +1 -1
  133. package/dist/server/route-element-helpers.d.ts +12 -0
  134. package/dist/server/route-element-helpers.d.ts.map +1 -0
  135. package/dist/server/route-module-loader.d.ts +37 -0
  136. package/dist/server/route-module-loader.d.ts.map +1 -0
  137. package/dist/server/rsc-cache-key-guard.d.ts.map +1 -1
  138. package/dist/server/rsc-entry/action-middleware-runner.d.ts.map +1 -1
  139. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  140. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  141. package/dist/server/rsc-entry/rsc-payload.d.ts +22 -1
  142. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  143. package/dist/server/rsc-entry/rsc-stream.d.ts +4 -11
  144. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  145. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  146. package/dist/server/rsc-error-envelope.d.ts +11 -0
  147. package/dist/server/rsc-error-envelope.d.ts.map +1 -0
  148. package/dist/server/skippable-prefix.d.ts +18 -15
  149. package/dist/server/skippable-prefix.d.ts.map +1 -1
  150. package/dist/server/slot-resolver.d.ts.map +1 -1
  151. package/dist/server/slot-subtree-contain.d.ts +54 -0
  152. package/dist/server/slot-subtree-contain.d.ts.map +1 -0
  153. package/dist/server/stream-utils.d.ts.map +1 -1
  154. package/dist/server/utils/element-type.d.ts +10 -0
  155. package/dist/server/utils/element-type.d.ts.map +1 -1
  156. package/dist/shared/rsc-error-envelope.d.ts +0 -9
  157. package/dist/shared/rsc-error-envelope.d.ts.map +1 -1
  158. package/dist/shared/status-reason-phrase.d.ts +26 -0
  159. package/dist/shared/status-reason-phrase.d.ts.map +1 -0
  160. package/docs/api/30-api-server.mdx +4 -2
  161. package/docs/api/31-api-client.mdx +5 -1
  162. package/docs/api/36-cli.mdx +5 -3
  163. package/docs/learn/12-error-handling.mdx +5 -1
  164. package/package.json +10 -10
  165. package/src/client/browser-entry/action-dispatch.ts +166 -99
  166. package/src/client/browser-entry/action-queue.ts +90 -0
  167. package/src/client/browser-entry/router-init.ts +60 -35
  168. package/src/client/deny-last-resort.tsx +54 -0
  169. package/src/client/error-boundary.tsx +144 -42
  170. package/src/client/history.ts +52 -3
  171. package/src/client/internal.ts +1 -0
  172. package/src/client/link.tsx +70 -35
  173. package/src/client/navigation-commit.ts +79 -27
  174. package/src/client/navigation-transition.ts +176 -127
  175. package/src/client/router-effects.ts +14 -17
  176. package/src/client/router-lifecycle.ts +181 -115
  177. package/src/client/router-pipeline.ts +94 -71
  178. package/src/client/router-types.ts +61 -7
  179. package/src/client/router.ts +147 -74
  180. package/src/client/rsc-fetch.ts +0 -13
  181. package/src/client/segment-cache.ts +43 -10
  182. package/src/client/state.ts +26 -0
  183. package/src/client/status-page-marker.tsx +32 -0
  184. package/src/dev-tools/holding-server.ts +4 -17
  185. package/src/index.ts +18 -34
  186. package/src/plugins/dev-server.ts +2 -1
  187. package/src/react-canary.d.ts +2 -0
  188. package/src/routing/codegen-write.ts +2 -0
  189. package/src/routing/interception.ts +2 -2
  190. package/src/routing/slot-placement.ts +2 -2
  191. package/src/server/access-gate.tsx +89 -21
  192. package/src/server/action-client.ts +2 -2
  193. package/src/server/action-handler.ts +23 -10
  194. package/src/server/actions.ts +81 -34
  195. package/src/server/als-registry.ts +3 -9
  196. package/src/server/children-interception.ts +1 -1
  197. package/src/server/default-status-page.ts +7 -47
  198. package/src/server/deny-boundary.ts +45 -28
  199. package/src/server/deny-renderer.ts +6 -2
  200. package/src/server/error-boundary-wrapper.ts +23 -4
  201. package/src/server/error-response-headers.ts +18 -0
  202. package/src/server/internal.ts +2 -10
  203. package/src/server/metadata-collector.ts +3 -18
  204. package/src/server/param-coercion.ts +13 -4
  205. package/src/server/pipeline-outcome.ts +35 -13
  206. package/src/server/pipeline-phases.ts +22 -16
  207. package/src/server/port-resolution.ts +3 -165
  208. package/src/server/prebuilt-builder.ts +4 -4
  209. package/src/server/primitives.ts +75 -11
  210. package/src/server/response-cache-policy.ts +45 -0
  211. package/src/server/route-element-builder.ts +149 -412
  212. package/src/server/route-element-helpers.ts +37 -0
  213. package/src/server/route-handler.ts +2 -2
  214. package/src/server/route-module-loader.ts +161 -0
  215. package/src/server/rsc-cache-key-guard.ts +2 -42
  216. package/src/server/rsc-entry/action-middleware-runner.ts +4 -4
  217. package/src/server/rsc-entry/api-handler.ts +5 -5
  218. package/src/server/rsc-entry/error-renderer.ts +3 -4
  219. package/src/server/rsc-entry/helpers.ts +1 -1
  220. package/src/server/rsc-entry/index.ts +3 -3
  221. package/src/server/rsc-entry/render-route.ts +4 -8
  222. package/src/server/rsc-entry/rsc-payload.ts +59 -42
  223. package/src/server/rsc-entry/rsc-stream.ts +48 -27
  224. package/src/server/rsc-entry/ssr-renderer.ts +6 -10
  225. package/src/server/rsc-error-envelope.ts +18 -0
  226. package/src/server/skippable-prefix.ts +105 -7
  227. package/src/server/slot-resolver.ts +43 -12
  228. package/src/server/slot-subtree-contain.ts +255 -0
  229. package/src/server/stream-utils.ts +12 -8
  230. package/src/server/utils/element-type.ts +18 -2
  231. package/src/shared/rsc-error-envelope.ts +0 -15
  232. package/src/shared/status-reason-phrase.ts +61 -0
  233. package/dist/_chunks/actions-BS-m5SLv.js.map +0 -1
  234. package/dist/_chunks/convention-lint-n3RJLgww.js.map +0 -1
  235. package/dist/_chunks/error-boundary-BvRCCmbN.js +0 -353
  236. package/dist/_chunks/error-boundary-BvRCCmbN.js.map +0 -1
  237. package/dist/_chunks/logger-DDirEsn7.js.map +0 -1
  238. package/dist/_chunks/mdx-file-CXyHGUpS.js +0 -25
  239. package/dist/_chunks/mdx-file-CXyHGUpS.js.map +0 -1
  240. package/dist/_chunks/router-ref-8gr8qsxN.js +0 -28
  241. package/dist/_chunks/router-ref-8gr8qsxN.js.map +0 -1
  242. package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js +0 -40
  243. package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js.map +0 -1
  244. package/dist/_chunks/scanner-tdFPvDYi.js.map +0 -1
  245. package/dist/_chunks/segment-keys-BhqoHiLc.js.map +0 -1
  246. package/dist/_chunks/ssr-data-BQGhTPAK.js.map +0 -1
  247. package/dist/server/tree-builder.d.ts +0 -150
  248. package/dist/server/tree-builder.d.ts.map +0 -1
  249. package/src/server/tree-builder.ts +0 -313
@@ -1,341 +1,12 @@
1
- import { n as isRscContentType, t as RSC_CONTENT_TYPE } from "../_chunks/rsc-media-type-DDc7duTD.js";
2
- import { a as randomRscCacheKey, n as RSC_KEY_PARAM, o as recordLookup, s as rscCacheKey } from "../_chunks/rsc-cache-key-ClUiXQnK.js";
3
- import { i as withBasePath, r as stripBasePath } from "../_chunks/base-path-DaQrzbez.js";
4
- import { t as rscPayloadPath } from "../_chunks/rsc-payload-path-B_LBodc2.js";
5
- import { n as createSingleflight, t as SingleflightTimeoutError } from "../_chunks/singleflight-2lUWfcAk.js";
6
- import { c as cachedSearchParams, i as _setCachedSearch, n as getSsrData, r as setSsrData, s as cachedSearch, t as clearSsrData } from "../_chunks/ssr-data-BQGhTPAK.js";
7
- import { n as getRouterOrNull, r as setGlobalRouter, t as getRouter } from "../_chunks/router-ref-8gr8qsxN.js";
8
- import { i as markStaleFromError, n as isClientStale, r as markClientStale, t as TimberErrorBoundary } from "../_chunks/error-boundary-BvRCCmbN.js";
1
+ import { t as SingleflightTimeoutError } from "../_chunks/singleflight-2lUWfcAk.js";
2
+ import { a as SegmentCache, i as PrefetchCache, n as createNavigationCommitter, o as prefetchScopeOf, r as isPartialNavigation } from "../_chunks/status-page-marker-DwQBrLBz.js";
3
+ import { i as useNavigationContext, n as getNavigationState, r as setNavigationState, t as NavigationProvider } from "../_chunks/navigation-context-D0TU0Jog.js";
4
+ import { c as cachedSearchParams, s as cachedSearch, t as _setCachedSearch } from "../_chunks/state-FippDgxN.js";
5
+ import { a as setGlobalRouter, i as getRouterOrNull, r as getRouter } from "../_chunks/navigation-root-mHSK9psY.js";
6
+ import { n as getSsrData, r as setSsrData, t as clearSsrData } from "../_chunks/ssr-data-D6T6Y3ef.js";
9
7
  import { n as useSegmentContext, t as SegmentProvider } from "../_chunks/segment-context-D9_89u34.js";
10
- import "../_chunks/rsc-error-envelope-tT5PJs4q.js";
11
- import { a as useNavigationContext, i as setNavigationState, n as NavigationProvider, r as getNavigationState, t as setHardNavigating } from "../_chunks/navigation-root-B00jjGd5.js";
8
+ import { a as createSpaExits, c as NonRscResponse, d as readPublishedParams, i as createScrollEffects, l as fetchRscPayload, n as TimberErrorBoundary, o as recordSkew, r as createNavigationRecovery, s as isClientStale, u as readPayloadTree } from "../_chunks/error-boundary-BfPHZjm0.js";
12
9
  import { t as bindUseQueryStates } from "../_chunks/use-query-states-I3JMng6J.js";
13
- //#region src/shared/payload-root.ts
14
- /**
15
- * What a reader gets when the value it was handed is not a payload root.
16
- *
17
- * In the browser this is unreachable for a real response: all four producers
18
- * of a route payload go through `withPublishedParams`, and a client talking to
19
- * a different build is answered with 204 at Stage 1c
20
- * (`server/pipeline-phases.ts`) before any payload exists. It is reachable in
21
- * the router's test/fallback path, where `decodeRsc` is absent and the
22
- * "payload" is the raw response text.
23
- */
24
- var NO_PUBLISHED_PARAMS = {
25
- params: {},
26
- slotParams: null
27
- };
28
- /** True when `value` carries published params — a root, or a read of one. */
29
- function hasPublishedParams(value) {
30
- return typeof value === "object" && value !== null && "params" in value && typeof value.params === "object" && value.params !== null;
31
- }
32
- /**
33
- * Read the params published beside a tree.
34
- *
35
- * Accepts a payload root *or* a `PublishedParams` already split off one, so
36
- * the client can pre-resolve on the navigation path and still hand the same
37
- * value to the same provider. Never throws.
38
- */
39
- function readPublishedParams(source) {
40
- if (!hasPublishedParams(source)) return NO_PUBLISHED_PARAMS;
41
- return {
42
- params: source.params,
43
- slotParams: source.slotParams ?? null
44
- };
45
- }
46
- /**
47
- * Read the renderable tree out of a payload root.
48
- *
49
- * Returns the value unchanged when it is not a root — the router's fallback
50
- * path stores raw response text under the same name, and a test asserting on
51
- * that text should see the text.
52
- */
53
- function readPayloadTree(root) {
54
- if (typeof root === "object" && root !== null && "tree" in root && hasPublishedParams(root)) return root.tree;
55
- return root;
56
- }
57
- /**
58
- * Split a decoded payload root into its tree and its params.
59
- *
60
- * Accepts a settled root or a thenable of one, and preserves which it was: a
61
- * settled root splits synchronously, so no code path gains a suspend point it
62
- * did not already have.
63
- */
64
- function splitPayloadRoot(root) {
65
- if (typeof root !== "object" || root === null || typeof root.then !== "function") return {
66
- tree: readPayloadTree(root),
67
- params: readPublishedParams(root)
68
- };
69
- const settled = root;
70
- const tree = Promise.resolve(settled).then(readPayloadTree);
71
- tree.catch(() => {});
72
- return {
73
- tree,
74
- params: Promise.resolve(settled).then(readPublishedParams, () => NO_PUBLISHED_PARAMS)
75
- };
76
- }
77
- //#endregion
78
- //#region src/client/segment-cache.ts
79
- /**
80
- * Maintains the client-side segment tree representing currently mounted
81
- * layouts and pages. Used for navigation reconciliation — the router diffs
82
- * new routes against this tree to determine which segments to re-fetch.
83
- */
84
- var SegmentCache = class {
85
- root;
86
- get(segment) {
87
- if (segment === "/" || segment === this.root?.segment) return this.root;
88
- }
89
- set(segment, node) {
90
- if (segment === "/" || !this.root) this.root = node;
91
- }
92
- clear() {
93
- this.root = void 0;
94
- }
95
- /**
96
- * Serialize the mounted segment tree for the X-Timber-State-Tree header.
97
- * Only includes sync segments — async segments are excluded because the
98
- * server must always re-render them (they may depend on request context).
99
- *
100
- * When mergeableFilter is provided, only segments whose paths are in the
101
- * set are included. This ensures the server only skips segments that the
102
- * client can actually merge (i.e., segments whose cached element tree
103
- * contains an inner SegmentProvider the merger can splice into).
104
- *
105
- * `treePaths` is collected UNFILTERED, unlike `segments` and `slots`. Those
106
- * two answer "what may the server skip re-rendering?", so every reason a
107
- * segment cannot be reused is a reason to leave it out. `treePaths` answers
108
- * "what is mounted right now?" for interception scoping — a request-dependent
109
- * layout is still on screen, and dropping it would silently narrow the scope
110
- * the server sees and cancel a modal that should open (TIM-1282).
111
- *
112
- * This is a performance optimization only, NOT a security boundary.
113
- * The server always runs all access.ts files regardless of the state tree.
114
- */
115
- serializeStateTree(mergeableFilter) {
116
- const segments = [];
117
- const slots = [];
118
- const treePaths = [];
119
- if (this.root) {
120
- collectSyncSegments(this.root, segments, mergeableFilter);
121
- collectSyncSlots(this.root, slots);
122
- collectTreePaths(this.root, treePaths);
123
- }
124
- const tree = { segments };
125
- if (slots.length > 0) tree.slots = slots;
126
- if (treePaths.length > 0) tree.treePaths = treePaths;
127
- return tree;
128
- }
129
- };
130
- /** Recursively collect sync segment paths from the tree */
131
- function collectSyncSegments(node, out, mergeableFilter) {
132
- if (!node.isRequestDependent && (!mergeableFilter || mergeableFilter.has(node.segment))) out.push(node.segment);
133
- for (const child of node.children.values()) collectSyncSegments(child, out, mergeableFilter);
134
- }
135
- /**
136
- * Recursively collect the `app/` directory path of every mounted segment.
137
- *
138
- * Slots are not walked: a slot's own sub-tree can never own an intercepting
139
- * slot's scope, because scopes are always ordinary segments (`interception.ts`
140
- * derives one from the slot owner's ancestor chain).
141
- */
142
- function collectTreePaths(node, out) {
143
- if (node.treePath) out.push(node.treePath);
144
- for (const child of node.children.values()) collectTreePaths(child, out);
145
- }
146
- /**
147
- * Recursively collect content keys from cacheable slots.
148
- *
149
- * The server advertises a content key per slot on every render (TIM-1370),
150
- * encoding the owner's URL parts, the slot name, the matched entry file,
151
- * and the slot's params. The client stores this key and sends it back on
152
- * the next navigation so the server can decide skips by key membership —
153
- * no departing URL reconstruction needed.
154
- */
155
- function collectSyncSlots(node, out) {
156
- if (node.slots) {
157
- for (const slot of node.slots.values()) if (!slot.isRequestDependent && !slot.denied && slot.contentKey) out.push(slot.contentKey);
158
- }
159
- for (const child of node.children.values()) collectSyncSlots(child, out);
160
- }
161
- /**
162
- * Build a SegmentNode tree from flat segment metadata.
163
- *
164
- * Takes an ordered list of segment descriptors (root → leaf) from the
165
- * server's X-Timber-Segments header and constructs the hierarchical
166
- * tree structure that SegmentCache expects.
167
- *
168
- * Each segment is nested as a child of the previous one, forming a
169
- * linear chain from root to leaf. The leaf segment (page) is excluded
170
- * from the tree — pages are never cached across navigations.
171
- */
172
- function buildSegmentTree(segments) {
173
- if (segments.length === 0) return void 0;
174
- const segmentEntries = [];
175
- const slotEntries = [];
176
- for (const info of segments) if (info.slot) slotEntries.push(info);
177
- else segmentEntries.push(info);
178
- let root;
179
- let parent;
180
- const nodeById = /* @__PURE__ */ new Map();
181
- for (const info of segmentEntries) {
182
- const id = info.segmentId ?? info.path;
183
- const node = {
184
- segment: id,
185
- treePath: info.treePath,
186
- payload: null,
187
- isRequestDependent: info.isRequestDependent,
188
- children: /* @__PURE__ */ new Map()
189
- };
190
- nodeById.set(id, node);
191
- if (!root) root = node;
192
- if (parent) parent.children.set(id, node);
193
- parent = node;
194
- }
195
- for (const slotInfo of slotEntries) {
196
- const parentId = slotInfo.parentSegment;
197
- const parentNode = parentId ? nodeById.get(parentId) : root;
198
- if (!parentNode) continue;
199
- const slotId = slotInfo.segmentId ?? slotInfo.path;
200
- const slotNode = {
201
- segment: slotId,
202
- payload: null,
203
- isRequestDependent: slotInfo.isRequestDependent,
204
- children: /* @__PURE__ */ new Map(),
205
- denied: slotInfo.denied,
206
- contentKey: slotInfo.contentKey
207
- };
208
- if (!parentNode.slots) parentNode.slots = /* @__PURE__ */ new Map();
209
- parentNode.slots.set(slotId, slotNode);
210
- }
211
- return root;
212
- }
213
- /** Sentinel value for negative cache entries (URL is not a route). */
214
- var NEGATIVE_ENTRY = Object.freeze({ payload: null });
215
- /**
216
- * Timeout for the in-flight singleflight (TIM-1438). Per CLAUDE.md's
217
- * singleflight rule: "Never write a coalescing Map without a timeout."
218
- * A hung prefetch cannot block clicks or suppress hovers forever.
219
- */
220
- var PREFETCH_SINGLEFLIGHT_TIMEOUT_MS = 5e3;
221
- /**
222
- * Compose the map key. Length-prefixed rather than delimiter-joined for the
223
- * same reason `shared/rsc-cache-key.ts` is: any delimiter is forgeable by a
224
- * value containing it, and a URL may contain any character a delimiter could.
225
- */
226
- function prefetchMapKey(key) {
227
- return `${key.from.length}:${key.from}:${key.scope.length}:${key.scope}:${key.url}`;
228
- }
229
- /**
230
- * The `scope` half of a `PrefetchKey`, derived from the state tree that will
231
- * actually be sent. Taking it from the header value rather than re-walking the
232
- * cache is the point: the key varies by exactly what the request varies by.
233
- */
234
- function prefetchScopeOf(stateTree) {
235
- return stateTree?.treePaths?.join("\0") ?? "";
236
- }
237
- /**
238
- * Short-lived cache for hover-triggered prefetches. Entries expire after
239
- * 30 seconds. When a link is clicked, the prefetched payload is consumed
240
- * (moved to the history stack) and removed from this cache.
241
- *
242
- * In-flight dedup (TIM-1438): concurrent fetches for the same key are
243
- * coalesced by a `createSingleflight` instance. A hover starts a flight;
244
- * a click for the same key joins it instead of issuing a duplicate. The
245
- * singleflight enforces a 5-second timeout and cleans up automatically
246
- * on settlement.
247
- *
248
- * timber.js does NOT prefetch on viewport intersection — only explicit
249
- * hover on <Link prefetch> triggers a prefetch.
250
- */
251
- var PrefetchCache = class PrefetchCache {
252
- static TTL_MS = 3e4;
253
- entries = /* @__PURE__ */ new Map();
254
- flights = createSingleflight({ timeoutMs: PREFETCH_SINGLEFLIGHT_TIMEOUT_MS });
255
- set(key, result) {
256
- this.entries.set(prefetchMapKey(key), {
257
- result,
258
- expiresAt: Date.now() + PrefetchCache.TTL_MS
259
- });
260
- }
261
- get(key) {
262
- const mapKey = prefetchMapKey(key);
263
- const entry = this.entries.get(mapKey);
264
- if (!entry) return void 0;
265
- if (Date.now() >= entry.expiresAt) {
266
- this.entries.delete(mapKey);
267
- return;
268
- }
269
- return entry.result;
270
- }
271
- /** True if a ready or negative entry exists for this key. */
272
- has(key) {
273
- return this.get(key) !== void 0;
274
- }
275
- /** Get and remove the entry (used when navigation consumes a prefetch) */
276
- consume(key) {
277
- const result = this.get(key);
278
- if (result !== void 0) this.entries.delete(prefetchMapKey(key));
279
- return result;
280
- }
281
- /**
282
- * Fetch or coalesce with an in-flight fetch for this key (TIM-1438).
283
- *
284
- * Concurrent callers (hover + click, repeated hovers) get the same
285
- * promise. On success the result is stored as a ready entry. On
286
- * NonRscResponse a negative entry is stored and the outcome is
287
- * `{ kind: 'non-route' }`. Other errors (network, version skew)
288
- * reject — the singleflight cleans up the key and subsequent callers
289
- * retry.
290
- *
291
- * The singleflight signal is passed to `doFetch` so a timed-out flight
292
- * aborts the underlying fetch rather than leaving it running.
293
- */
294
- fetchOrCoalesce(key, doFetch, isNonRoute) {
295
- return this.flights.do(prefetchMapKey(key), async (signal) => {
296
- try {
297
- const result = await doFetch(signal);
298
- if (!signal.aborted) this.set(key, result);
299
- return {
300
- kind: "ready",
301
- result
302
- };
303
- } catch (err) {
304
- if (isNonRoute(err)) {
305
- if (!signal.aborted) this.setNegative(key);
306
- return { kind: "non-route" };
307
- }
308
- throw err;
309
- }
310
- });
311
- }
312
- /**
313
- * Join an in-flight singleflight fetch if one exists for this key.
314
- * Returns the in-flight promise or undefined. Used by the click path to
315
- * coalesce with a hover prefetch without starting a new flight — when no
316
- * flight exists, the click issues its own fetch with the navigation's
317
- * abort signal so superseded navigations abort immediately (TIM-1438).
318
- */
319
- joinInflight(key) {
320
- return this.flights.get(prefetchMapKey(key));
321
- }
322
- /**
323
- * Store a negative entry — the URL is not a route (non-RSC Content-Type).
324
- *
325
- * Keyed like every other entry even though "not a route" does not actually
326
- * vary by departing URL: a source-independent negative would be a second
327
- * keying rule to keep correct, and the only cost of the uniform one is a
328
- * repeated fetch for a link hovered from a second page.
329
- */
330
- setNegative(key) {
331
- this.set(key, NEGATIVE_ENTRY);
332
- }
333
- /** Check if the entry is a negative cache entry (URL is not a route). */
334
- isNegative(key) {
335
- return this.get(key) === NEGATIVE_ENTRY;
336
- }
337
- };
338
- //#endregion
339
10
  //#region src/client/history.ts
340
11
  /**
341
12
  * Session-lived history stack keyed by URL. Enables instant back/forward
@@ -350,767 +21,85 @@ var PrefetchCache = class PrefetchCache {
350
21
  * Scroll positions are stored in history.state or Navigation API entry
351
22
  * state, not in this stack — see design/19-client-navigation.md §Scroll Restoration.
352
23
  *
353
- * Entries persist for the session duration (no expiry) and are cleared
354
- * when the tab is closed — matching browser back-button behavior.
24
+ * Entries have no expiry, but only the 50 most recently used URLs are kept
25
+ * by default. Traversing to an evicted entry fetches a fresh payload.
355
26
  */
356
27
  var HistoryStack = class {
357
28
  entries = /* @__PURE__ */ new Map();
29
+ maxEntries;
30
+ constructor({ maxEntries = 50 } = {}) {
31
+ if (!Number.isInteger(maxEntries) || maxEntries < 1) throw new RangeError("HistoryStack maxEntries must be a positive integer");
32
+ this.maxEntries = maxEntries;
33
+ }
358
34
  push(url, entry) {
35
+ this.entries.delete(url);
359
36
  this.entries.set(url, entry);
37
+ if (this.entries.size > this.maxEntries) {
38
+ const oldest = this.entries.keys().next();
39
+ if (!oldest.done) this.entries.delete(oldest.value);
40
+ }
360
41
  }
361
42
  get(url) {
362
- return this.entries.get(url);
43
+ const entry = this.entries.get(url);
44
+ if (entry) {
45
+ this.entries.delete(url);
46
+ this.entries.set(url, entry);
47
+ }
48
+ return entry;
363
49
  }
50
+ /** Presence checks (such as hover prefetch probes) do not promote entries. */
364
51
  has(url) {
365
52
  return this.entries.has(url);
366
53
  }
367
- };
368
- //#endregion
369
- //#region src/client/navigation-commit.ts
370
- /**
371
- * What it means for a page to be "current" on the client.
372
- *
373
- * One module owns every write that makes a navigation's destination the
374
- * current page, and the read that describes the current page back to the
375
- * server. Keeping them together is the point: `X-Timber-State-Tree` is
376
- * derived from the segment cache, so the thing that publishes and the thing
377
- * that reports have to agree about when a page becomes current.
378
- *
379
- * See design/19-client-navigation.md §"State Update Invariants".
380
- */
381
- /** Whether a response skipped layouts — its payload merges over the tree on screen. */
382
- function isPartialNavigation(skippedSegments) {
383
- return skippedSegments != null && skippedSegments.length > 0;
384
- }
385
- /**
386
- * Whether a payload can stand on its own when replayed from the history
387
- * stack. See `NavigationCommitInput.skippedSegments`.
388
- */
389
- function isReplayable(opts) {
390
- return !isPartialNavigation(opts.skippedSegments) && !hasSkippedSlot(opts.segmentInfo);
391
- }
392
- function hasSkippedSlot(segmentInfo) {
393
- return segmentInfo?.some((s) => s.slot && s.skipped) ?? false;
394
- }
395
- /**
396
- * The metadata a history entry keeps. `skipped` describes the *response*
397
- * — "this slot's content was omitted" — and the entry stores no such
398
- * payload (it stores null). Left on the entry, the flag outlives the
399
- * response: `applyRevalidation()` reuses the entry's metadata beside a full
400
- * re-render and would have that judged non-replayable too.
401
- */
402
- function storedSegmentInfo(segmentInfo) {
403
- if (!hasSkippedSlot(segmentInfo)) return segmentInfo;
404
- return segmentInfo.map(({ skipped: _skipped, ...rest }) => rest);
405
- }
406
- /**
407
- * Derive the navigation state (pathname + search) a URL renders with.
408
- *
409
- * Pure: publishing it — the module-level fallback for tests and SSR, and the
410
- * globalThis bridge — is `prepareNavigation`'s commit, which runs only once
411
- * the render is known to have won (TIM-1301). Callers pass the returned value
412
- * explicitly to renderRoot/wrapPayload, so a render never depends on the
413
- * publish having happened first.
414
- */
415
- function deriveNavigationState(url) {
416
- const parsed = new URL(url, "http://localhost");
417
- return {
418
- pathname: parsed.pathname || "/",
419
- search: parsed.search
420
- };
421
- }
422
- function createNavigationCommitter(deps) {
423
- const { segmentCache, historyStack } = deps;
54
+ delete(url) {
55
+ return this.entries.delete(url);
56
+ }
424
57
  /**
425
- * Update the segment cache from server-provided segment metadata.
58
+ * Evict all cached payloads (TIM-1476). Called after a server action
59
+ * that revalidated data — history entries are equally stale since they
60
+ * replay on back/forward without a server check. Clearing forces a
61
+ * fresh fetch on the next traversal.
426
62
  *
427
- * Runs whether or not `clientSegmentCache` is enabled: the tree is also
428
- * where the mounted tree paths come from, and those cross on every
429
- * navigation. What the flag gates is which parts get serialized — see
430
- * `currentStateTree`.
63
+ * The current URL's `segmentInfo` is preserved (payload nulled) so
64
+ * `applyActionResult` can read it to maintain the segment cache
65
+ * across the piggybacked revalidation. Other branches (reval.paths-
66
+ * only, redirect) do not call `applyActionResult`, so their entries
67
+ * are fully removed (codex on #1131 round 2).
431
68
  */
432
- function updateSegmentCache(segmentInfo) {
433
- if (!segmentInfo || segmentInfo.length === 0) return;
434
- const tree = buildSegmentTree(segmentInfo);
435
- if (tree) segmentCache.set("/", tree);
436
- }
437
- return {
438
- updateSegmentCache,
439
- /**
440
- * The X-Timber-State-Tree to send with the next RSC request.
441
- *
442
- * Two different things travel on one header. `segments`/`slots` are the
443
- * caching opt-in and are omitted unless `clientSegmentCache` is on.
444
- * `treePaths` is how the server learns which route is actually mounted,
445
- * which stops being derivable from the address bar as soon as a modal is
446
- * open — so it crosses regardless (TIM-1282). Undefined when there is
447
- * nothing to say, so a no-op header never enters the `_rsc` cache key.
448
- *
449
- * Reads what the last *committed* navigation published, so a superseded
450
- * navigation's destination can never end up describing the mounted tree
451
- * (TIM-1301).
452
- */
453
- currentStateTree() {
454
- const tree = segmentCache.serializeStateTree();
455
- if (deps.clientSegmentCache()) return tree;
456
- return tree.treePaths ? {
457
- segments: [],
458
- treePaths: tree.treePaths
459
- } : void 0;
460
- },
461
- /**
462
- * Prepare all navigation-owned state for a new page — without publishing
463
- * any of it. Every code path that changes the "current page" must go
464
- * through this function, which is what makes "forgot a field" impossible
465
- * by construction.
466
- *
467
- * Returns the destination's `NavigationState`, which is a pure function of
468
- * the URL and is what the incoming tree is *rendered* with, plus a
469
- * `commit` thunk that performs the writes that make a page current:
470
- * 1. Segment cache — update from server-provided segment metadata
471
- * 2. Navigation state — pathname/search for usePathname/useSearchParams
472
- * 3. History stack — store the payload for instant back/forward replay
473
- *
474
- * Nothing is written until `commit()` runs, and on a transitioned
475
- * navigation it runs only once the transition is known to have won — see
476
- * `renderViaTransition` and `NavigationRoot`. A navigation superseded
477
- * while its payload was in flight never calls it, so the cache, the
478
- * pathname the client reports and the history stack all keep describing
479
- * the route still on screen (TIM-1301).
480
- *
481
- * Callers with state of their own to publish — the address bar, the
482
- * client's record of the mounted tree — wrap this thunk rather than
483
- * commit beside it, so there stays exactly one moment at which a page
484
- * becomes current.
485
- */
486
- prepareNavigation(url, opts) {
487
- const navState = deriveNavigationState(url);
488
- const segmentInfo = opts.status === void 0 || opts.status < 400 ? opts.segmentInfo : [];
489
- const payload = isReplayable(opts) ? opts.payload : null;
490
- return {
491
- navState,
492
- commit() {
493
- if (segmentInfo && segmentInfo.length > 0) updateSegmentCache(segmentInfo);
494
- else if (segmentInfo?.length === 0 || opts.clearSegmentCacheOnEmpty) segmentCache.clear();
495
- setNavigationState(navState);
496
- historyStack.push(url, {
497
- payload,
498
- params: opts.params,
499
- segmentInfo: storedSegmentInfo(segmentInfo)
500
- });
501
- }
502
- };
503
- }
504
- };
505
- }
506
- //#endregion
507
- //#region src/client/rsc-fetch.ts
508
- /**
509
- * RSC Fetch — handles fetching and parsing RSC Flight payloads.
510
- *
511
- * Extracted from router.ts to keep both files under the 500-line limit.
512
- * This module handles:
513
- * - Cache-busting URL generation for RSC requests
514
- * - Building RSC request headers (Accept, X-Timber-State-Tree)
515
- * - Extracting metadata from RSC response headers
516
- * - Fetching and decoding RSC payloads
517
- *
518
- * See design/19-client-navigation.md §"RSC Payload Handling"
519
- */
520
- /**
521
- * Append a `_rsc=<key>` query parameter to the URL (TIM-1268).
522
- *
523
- * The key is a deterministic hash of the RSC request headers, so identical
524
- * requests share a URL and the payload becomes cacheable. It also keeps the
525
- * document and the payload on different URLs, so a shared cache cannot
526
- * return HTML for an RSC request (or vice versa) even where `Vary` is
527
- * opt-in configuration rather than default behaviour — Cloudflare and
528
- * CloudFront both. See GHSA-wfc6-r584-vfw7, design/13-security.md.
529
- *
530
- * The origin recomputes this key from the received headers and refuses to
531
- * let a mismatched response be shared-cached, so a caller cannot claim
532
- * another client's key while sending its own headers.
533
- *
534
- * Falls back to a random value when no key can be derived — `crypto.subtle`
535
- * is absent in non-secure browsing contexts (plain `http://` on a LAN
536
- * address). Dropping the parameter instead would put the payload back on
537
- * the document URL, where a query-keyed cache that ignores `Vary` could
538
- * store a Flight response under `/about` and serve it to an HTML
539
- * navigation. The random parameter this feature replaced did separate those
540
- * representations, and the fallback must not be worse than what it
541
- * replaced. The origin cannot verify a random key, so it refuses to
542
- * share-cache the response — which is exactly the old behaviour, where a
543
- * unique URL was never a cache hit anyway.
544
- *
545
- * Strips any #fragment before appending — fragments are client-only and
546
- * fetch() discards them, so _rsc would land inside the hash and be lost.
547
- */
548
- async function appendRscParam(url, headers) {
549
- const hashIndex = url.indexOf("#");
550
- const urlWithoutHash = hashIndex === -1 ? url : url.slice(0, hashIndex);
551
- const key = await rscCacheKey(recordLookup(headers)) ?? randomRscCacheKey();
552
- return `${urlWithoutHash}${urlWithoutHash.includes("?") ? "&" : "?"}${RSC_KEY_PARAM}=${key}`;
553
- }
554
- /**
555
- * The client's deployment ID, set at bootstrap from the runtime config.
556
- * Sent with every RSC/action request for version skew detection.
557
- * Null in dev mode. See TIM-446.
558
- */
559
- var clientDeploymentId = null;
560
- /**
561
- * The deployment base path (Vite's resolved `base`), normalized.
562
- * Set at bootstrap from `virtual:timber-config`; `'/'` unless the app is
563
- * deployed under a sub-path. See design/11-platform.md, TIM-1261.
564
- *
565
- * **The base is applied in exactly one place: at fetch time**
566
- * (`toStaticRscUrl`). The static build writes the RSC manifest with
567
- * root-relative keys *and* root-relative URLs, so the generated site stays
568
- * relocatable and there is no second copy of the base to drift from this one.
569
- */
570
- var basePath = "/";
571
- /**
572
- * When true, RSC fetches use _rsc/*.rsc file URLs instead of
573
- * the route URL with Accept headers. Static hosts ignore Accept
574
- * headers, so the client must fetch the pre-generated .rsc files
575
- * directly. Set at bootstrap from virtual:timber-config output mode.
576
- */
577
- var staticMode = false;
578
- /**
579
- * RSC manifest mapping unhashed → content-hashed `.rsc` URLs. Populated from
580
- * `window.__TIMBER_RSC_MANIFEST__` (injected into HTML during static
581
- * generation). Shape shared with the static build that writes it — see
582
- * `shared/rsc-manifest.ts`. See TIM-1254.
583
- */
584
- var rscManifest = null;
585
- function getRscManifest() {
586
- if (rscManifest) return rscManifest;
587
- if (typeof window !== "undefined" && window.__TIMBER_RSC_MANIFEST__) rscManifest = window.__TIMBER_RSC_MANIFEST__;
588
- return rscManifest;
589
- }
590
- /**
591
- * Resolve a route URL to the `_rsc/*.rsc` file to fetch. The naming rule is
592
- * `shared/rsc-payload-path.ts`, shared with the build that writes the files.
593
- *
594
- * When an RSC manifest is available (hashed filenames from TIM-1254),
595
- * the manifest is consulted to resolve to the hashed path.
596
- *
597
- * / → /_rsc/index.rsc (or /_rsc/index-B7YxEKdN.rsc with manifest)
598
- * /about → /_rsc/about/index.rsc (or /_rsc/about/index-C8ZzFLfO.rsc)
599
- * /blog/hello → /_rsc/blog/hello/index.rsc
600
- *
601
- * The manifest is written and read in route space (no base prefix), so the
602
- * base is applied here — once, on the way out. See client/base-path.ts.
603
- */
604
- function toStaticRscUrl(url) {
605
- const unhashed = toUnhashedRscUrl(url);
606
- const hashedUrl = manifestLookup(unhashed);
607
- return {
608
- url: withBasePath(basePath, hashedUrl ?? unhashed),
609
- hashed: hashedUrl !== null
610
- };
611
- }
612
- /**
613
- * Compute the unhashed `_rsc/*.rsc` path for a route URL.
614
- *
615
- * Returns a **route-space** path: the deployment base is stripped from the
616
- * incoming URL and is NOT re-applied, because this value doubles as the RSC
617
- * manifest key, and the manifest is base-less. `toStaticRscUrl` applies the
618
- * base to whatever is actually fetched.
619
- *
620
- * @internal Exported for testing.
621
- */
622
- function toUnhashedRscUrl(url) {
623
- const hashIndex = url.indexOf("#");
624
- const queryIndex = url.indexOf("?");
625
- const hashEnd = hashIndex === -1 ? url.length : hashIndex;
626
- const queryEnd = queryIndex === -1 ? url.length : queryIndex;
627
- const end = Math.min(hashEnd, queryEnd);
628
- let pathname = stripBasePath(basePath, url.slice(0, end));
629
- if (pathname.length > 1 && pathname.endsWith("/")) pathname = pathname.slice(0, -1);
630
- return rscPayloadPath(pathname);
631
- }
632
- /**
633
- * Look up a key in the RSC manifest, falling back to percent-decoded
634
- * lookup for encoded browser URLs. Returns the entry or null.
635
- */
636
- function manifestEntry(key) {
637
- const manifest = getRscManifest();
638
- if (!manifest) return null;
639
- if (manifest[key]) return manifest[key];
640
- try {
641
- const decoded = decodeURIComponent(key);
642
- if (decoded !== key && manifest[decoded]) return manifest[decoded];
643
- } catch {}
644
- return null;
645
- }
646
- /**
647
- * Look up the hashed URL for an unhashed RSC path.
648
- */
649
- function manifestLookup(key) {
650
- return manifestEntry(key)?.url ?? null;
651
- }
652
- /** Header name used by the server to signal a version skew reload. */
653
- var RELOAD_HEADER = "X-Timber-Reload";
654
- /** Header name for the client's deployment ID. */
655
- var DEPLOYMENT_ID_HEADER = "X-Timber-Deployment-Id";
656
- /**
657
- * Check if a response signals a version skew reload.
658
- * Triggers a full page reload if the server indicates the client is stale.
659
- */
660
- function checkReloadSignal(response) {
661
- return response.headers.get(RELOAD_HEADER) === "1";
662
- }
663
- /**
664
- * Build the headers for an RSC payload request.
665
- *
666
- * **Every header added here is hashed into the payload URL** as the `_rsc`
667
- * cache key (see `rscCacheKey`), and URLs are logged by proxies, CDNs, and
668
- * origin access logs. So: no credentials, no session identifiers, no
669
- * user-supplied content. `fnv1aHash` is a cache-key hash, not a KDF — a
670
- * low-entropy secret would be recoverable from the URL by brute force.
671
- * Authentication already travels on cookies, which are not part of this
672
- * object and never enter the key.
673
- */
674
- function buildRscHeaders(stateTree, currentUrl) {
675
- const headers = { Accept: RSC_CONTENT_TYPE };
676
- if (stateTree) headers["X-Timber-State-Tree"] = JSON.stringify(stateTree);
677
- if (currentUrl) headers["X-Timber-URL"] = currentUrl;
678
- if (clientDeploymentId) headers[DEPLOYMENT_ID_HEADER] = clientDeploymentId;
679
- return headers;
680
- }
681
- /** Dev-only warning for malformed framework headers. Tree-shaken in production. */
682
- function warnMalformedHeader(headerName, raw) {
683
- if (process.env.NODE_ENV !== "production") {
684
- const preview = raw.length > 200 ? raw.slice(0, 200) + "…" : raw;
685
- console.warn(`[timber] Malformed ${headerName} header \u2014 JSON.parse failed. This indicates a framework bug or header corruption. Raw (first 200 chars): ${preview}`);
686
- }
687
- }
688
- /**
689
- * Extract segment metadata from the X-Timber-Segments response header.
690
- * Returns null if the header is missing or malformed.
691
- *
692
- * Format: JSON array of {path, isRequestDependent} objects describing the rendered
693
- * segment chain from root to leaf. Used to populate the client-side
694
- * segment cache for state tree diffing on subsequent navigations.
695
- */
696
- function extractSegmentInfo(response) {
697
- const header = response.headers.get("X-Timber-Segments");
698
- if (!header) return null;
699
- try {
700
- return JSON.parse(header);
701
- } catch {
702
- warnMalformedHeader("X-Timber-Segments", header);
703
- return null;
704
- }
705
- }
706
- /**
707
- * Extract skipped segment paths from the X-Timber-Skipped-Segments header.
708
- * Returns null if the header is missing or malformed.
709
- *
710
- * When the server skips sync layouts the client already has cached,
711
- * it sends this header listing the skipped segment paths (outermost first).
712
- * The client uses this to merge the partial payload with cached segments.
713
- */
714
- function extractSkippedSegments(response) {
715
- const header = response.headers.get("X-Timber-Skipped-Segments");
716
- if (!header) return null;
717
- try {
718
- const parsed = JSON.parse(header);
719
- return Array.isArray(parsed) ? parsed : null;
720
- } catch {
721
- warnMalformedHeader("X-Timber-Skipped-Segments", header);
722
- return null;
723
- }
724
- }
725
- /**
726
- * Thrown when an RSC payload response contains X-Timber-Redirect header.
727
- * Caught in navigate() to trigger a soft router navigation to the redirect target.
728
- */
729
- var RedirectError = class extends Error {
730
- redirectUrl;
731
- constructor(url) {
732
- super(`Server redirect to ${url}`);
733
- this.redirectUrl = url;
734
- }
735
- };
736
- /**
737
- * Thrown when the server signals a version skew (X-Timber-Reload header).
738
- * Caught in navigate() to trigger a full page reload.
739
- * See TIM-446.
740
- */
741
- var VersionSkewError = class extends Error {
742
- constructor() {
743
- super("Version skew detected — server has been redeployed");
744
- }
745
- };
746
- /**
747
- * Thrown when the server returns an error for an RSC payload request.
748
- * The server sends X-Timber-Error header and a JSON body instead of a
749
- * broken RSC stream for any RenderError (4xx or 5xx). Caught in
750
- * navigate() to trigger a hard navigation so the server can render
751
- * the error page as HTML.
752
- *
753
- * See design/10-error-handling.md §"Error Page Rendering for Client Navigation"
754
- */
755
- var ServerErrorResponse = class extends Error {
756
- status;
757
- url;
758
- constructor(status, url) {
759
- super(`Server error ${status} during navigation to ${url}`);
760
- this.status = status;
761
- this.url = url;
762
- }
763
- };
764
- /**
765
- * Thrown when the RSC fetch response has a Content-Type that is not
766
- * text/x-component — e.g., a static asset (image, CSS, JS) served
767
- * for a same-origin URL that isn't a route. The response body is
768
- * cancelled immediately (headers-only cost). Caught in navigate()
769
- * to trigger a hard navigation; caught in prefetch() to store a
770
- * negative cache entry so click hard-navigates without a second fetch.
771
- *
772
- * See TIM-1231.
773
- */
774
- var NonRscResponse = class extends Error {
775
- url;
776
- constructor(url) {
777
- super(`Non-RSC response for ${url}`);
778
- this.url = url;
779
- }
780
- };
781
- /**
782
- * Wrap a response body stream to track when it's fully consumed.
783
- * Returns a new body that passes all chunks through unchanged, plus
784
- * a `done` promise that resolves when the last chunk is read (or
785
- * rejects if the stream errors).
786
- *
787
- * createFromFetch's thenable resolves on shell arrival, but callers need to
788
- * know when the stream is fully decoded: that is what `navigateTransition`'s
789
- * returned promise means, and what the router's pending store, the Navigation
790
- * API deferred and `<Link>`'s `isPending` are all timed against.
791
- */
792
- function trackStreamCompletion(body) {
793
- let resolveDone;
794
- let rejectDone;
795
- const done = new Promise((res, rej) => {
796
- resolveDone = res;
797
- rejectDone = rej;
798
- });
799
- const reader = body.getReader();
800
- return {
801
- body: new ReadableStream({
802
- async pull(controller) {
803
- try {
804
- const result = await reader.read();
805
- if (result.done) {
806
- controller.close();
807
- resolveDone();
808
- } else controller.enqueue(result.value);
809
- } catch (error) {
810
- controller.error(error);
811
- rejectDone(error);
812
- }
813
- },
814
- cancel(reason) {
815
- reader.cancel(reason);
816
- resolveDone();
817
- }
818
- }),
819
- done
820
- };
821
- }
822
- /**
823
- * Fetch an RSC payload from the server. If a decodeRsc function is provided,
824
- * the response is decoded into a React element tree via createFromFetch.
825
- * Otherwise, the raw response text is returned (test mode).
826
- */
827
- async function fetchRscPayload(url, deps, stateTree, currentUrl, signal) {
828
- const staticTarget = staticMode ? toStaticRscUrl(url) : null;
829
- const fetchTarget = staticTarget ? staticTarget.url : url;
830
- const headers = staticMode ? buildRscHeaders(void 0, void 0) : buildRscHeaders(stateTree, currentUrl);
831
- const rscUrl = staticTarget?.hashed ? fetchTarget : await appendRscParam(fetchTarget, headers);
832
- signal?.throwIfAborted();
833
- if (deps.decodeRsc) {
834
- const fetchPromise = deps.fetch(rscUrl, {
835
- headers,
836
- redirect: "manual",
837
- signal
838
- });
839
- let segmentInfo = null;
840
- let skippedSegments = null;
841
- let status = 200;
842
- let streamDone = Promise.resolve();
843
- const wrappedPromise = fetchPromise.then((response) => {
844
- if (checkReloadSignal(response)) throw new VersionSkewError();
845
- const redirectLocation = response.headers.get("X-Timber-Redirect") || (response.status >= 300 && response.status < 400 ? response.headers.get("Location") : null);
846
- if (redirectLocation) throw new RedirectError(redirectLocation);
847
- if (response.headers.get("X-Timber-Error") === "1") throw new ServerErrorResponse(response.status, url);
848
- if (staticMode) {
849
- const contentType = response.headers.get("content-type");
850
- if (!response.ok || contentType && contentType.split(";")[0].trim().toLowerCase() === "text/html") {
851
- response.body?.cancel();
852
- throw new NonRscResponse(url);
853
- }
854
- } else if (!isRscContentType(response.headers.get("content-type"))) {
855
- response.body?.cancel();
856
- throw new NonRscResponse(url);
857
- }
858
- segmentInfo = extractSegmentInfo(response);
859
- skippedSegments = extractSkippedSegments(response);
860
- status = response.status;
861
- if (response.body) {
862
- const tracked = trackStreamCompletion(response.body);
863
- streamDone = tracked.done;
864
- streamDone.catch(() => {});
865
- return new Response(tracked.body, {
866
- headers: response.headers,
867
- status: response.status
868
- });
869
- }
870
- return response;
871
- });
872
- await wrappedPromise;
873
- const root = deps.decodeRsc(wrappedPromise);
874
- const { tree: payload, params } = splitPayloadRoot(root);
875
- const payloadError = new Promise((_, reject) => {
876
- Promise.resolve(root).then(() => {}, reject);
69
+ clearExcept(currentUrl) {
70
+ const currentEntry = this.entries.get(currentUrl);
71
+ this.entries.clear();
72
+ if (currentEntry) this.entries.set(currentUrl, {
73
+ payload: null,
74
+ params: currentEntry.params,
75
+ segmentInfo: currentEntry.segmentInfo
877
76
  });
878
- payloadError.catch(() => {});
879
- const decodePromise = Promise.race([streamDone, payloadError]);
880
- decodePromise.catch(() => {});
881
- return {
882
- payload,
883
- params,
884
- decodePromise,
885
- segmentInfo,
886
- skippedSegments,
887
- status
888
- };
889
77
  }
890
- const response = await deps.fetch(rscUrl, {
891
- headers,
892
- redirect: "manual",
893
- signal
894
- });
895
- if (response.status >= 300 && response.status < 400) {
896
- const location = response.headers.get("Location");
897
- if (location) throw new RedirectError(location);
898
- }
899
- if (response.headers.get("X-Timber-Error") === "1") throw new ServerErrorResponse(response.status, url);
900
- if (staticMode) {
901
- const fallbackContentType = response.headers.get("content-type");
902
- if (!response.ok || fallbackContentType && fallbackContentType.split(";")[0].trim().toLowerCase() === "text/html") {
903
- response.body?.cancel();
904
- throw new NonRscResponse(url);
905
- }
906
- } else if (!isRscContentType(response.headers.get("content-type"))) {
907
- response.body?.cancel();
908
- throw new NonRscResponse(url);
909
- }
910
- return {
911
- payload: await response.text(),
912
- params: readPublishedParams(void 0),
913
- decodePromise: null,
914
- segmentInfo: extractSegmentInfo(response),
915
- skippedSegments: extractSkippedSegments(response),
916
- status: response.status
917
- };
918
- }
919
- //#endregion
920
- //#region src/client/router-skew.ts
921
- /**
922
- * Router Skew Recording — turning a rejection into "this bundle is superseded".
923
- *
924
- * Detection lives in `stale-client.ts`; this is the one place the router (and
925
- * anything else holding an RSC rejection) turns an error into that verdict.
926
- * Recovery is not here: every path that gives up on client-side navigation —
927
- * skew or not — leaves through `createSpaExits()` in `router-effects.ts`.
928
- *
929
- * See design/33-version-skew.md §"Recovery on the next navigation"
930
- */
931
- /**
932
- * Record a version skew if `error` is one, and report whether it was.
933
- *
934
- * `VersionSkewError` is the origin saying so outright; the reactive shapes are
935
- * inferred from the error text and are a no-op in dev.
936
- *
937
- * Closes over nothing — anywhere an RSC payload can fail needs it, including
938
- * places that have no router instance to hand.
939
- */
940
- function recordSkew(error) {
941
- if (error instanceof VersionSkewError) {
942
- markClientStale();
943
- return true;
944
- }
945
- return markStaleFromError(error);
946
- }
78
+ };
947
79
  //#endregion
948
- //#region src/client/router-effects.ts
949
- /**
950
- * Router Effects — full-page navigation and post-paint scroll.
951
- *
952
- * The two things the router does *to the document* rather than to its own
953
- * state: leaving the SPA entirely, and moving the scroll position once React
954
- * has committed. Extracted from `router.ts` to keep that file focused on
955
- * navigation state (see design/18-build-system.md §"No file >500 lines").
956
- *
957
- * See design/19-client-navigation.md §"Scroll Restoration"
958
- */
959
- function createScrollEffects(deps) {
960
- /** Run a callback after the next paint (after React commit). */
961
- function afterPaint(callback) {
962
- if (deps.afterPaint) deps.afterPaint(callback);
963
- else callback();
964
- }
965
- function restoreScrollAfterPaint(scrollY) {
966
- afterPaint(() => {
967
- deps.scrollTo(0, scrollY);
968
- window.dispatchEvent(new Event("timber:scroll-restored"));
969
- });
970
- }
971
- function scrollToHashAfterPaint(hash) {
972
- afterPaint(() => {
973
- if (deps.scrollToHash?.(hash) !== true) deps.scrollTo(0, 0);
974
- window.dispatchEvent(new Event("timber:scroll-restored"));
975
- });
976
- }
977
- return {
978
- restoreScrollAfterPaint,
979
- scrollToHashAfterPaint
980
- };
981
- }
982
- /**
983
- * Leave the SPA and never come back.
984
- *
985
- * Every router path that gives up on client-side navigation does the same
986
- * three things: flag the hard navigation so the Navigation API and React stop
987
- * acting on a dying document, perform the document load, then block forever so
988
- * the caller cannot carry on rendering into it. The returned promise is
989
- * deliberately unresolvable — `location` assignment does not stop this turn of
990
- * the event loop.
991
- *
992
- * Callers: server error, non-RSC response, and every version skew path
993
- * ([33-version-skew.md](../../../../design/33-version-skew.md)).
994
- */
995
- function leaveSpa(url, fromUrl) {
996
- setHardNavigating(true);
997
- hardNavigate(url, fromUrl);
998
- return new Promise(() => {});
999
- }
1000
- /**
1001
- * The two ownership-aware ways out of the SPA.
1002
- *
1003
- * Both wrap {@link leaveSpa} with the question "is this navigation still the
1004
- * one the user is waiting for?" — asked before leaving on a failure, answered
1005
- * by force on a path that decided before any navigation began. They live
1006
- * together because every caller of `leaveSpa()` needs one or the other, and
1007
- * an unguarded call is the bug (TIM-1275, TIM-1276).
1008
- */
1009
- function createSpaExits({ currentNavAbort, supersede }) {
1010
- async function leaveSpaIfOwned(navAbort, url, fromUrl) {
1011
- if (currentNavAbort() !== navAbort) return;
1012
- await leaveSpa(url, fromUrl);
1013
- }
1014
- function leaveSpaSuperseding(url, fromUrl) {
1015
- supersede();
1016
- return leaveSpa(url, fromUrl);
1017
- }
1018
- return {
1019
- leaveSpaIfOwned,
1020
- leaveSpaSuperseding
1021
- };
1022
- }
80
+ //#region src/client/navigation-transition.ts
1023
81
  /**
1024
- * Answer the failures an RSC fetch can end on, or report that this one is not
1025
- * ours to answer.
1026
- *
1027
- * Returns `true` when the error was handled; the caller rethrows on `false`.
1028
- * Handling a failure by leaving the SPA never resolves at all — the document
1029
- * is going away and the caller must not carry on rendering into it.
1030
- *
1031
- * There is exactly one family of these and one response to it, so it lives in
1032
- * one function rather than once per fetch path. `navigate()`, `refresh()` and
1033
- * an uncached traversal all reach it: the latter two used to rethrow instead,
1034
- * and since nobody awaits a traversal that meant pressing Back on a 500 left
1035
- * the user on the old document with an unhandled rejection (TIM-1277).
1036
- *
1037
- * Order matters in exactly one way: `recordSkew()` ends in error-*text*
1038
- * matching, so it goes last and can never pre-empt a framework control-flow
1039
- * signal whose message happens to read like a stale bundle. The class checks
1040
- * above it are mutually exclusive, so their order is free.
1041
- *
1042
- * Must be called from inside the `runNavigation()` callback — see
1043
- * {@link SpaExits.leaveSpaIfOwned}.
82
+ * Create a fresh RenderOwner. Exported so tests that call
83
+ * `navigateTransition` directly can construct one without a router.
1044
84
  */
1045
- function createNavigationRecovery({ currentNavAbort, leaveSpaIfOwned, navigate }) {
1046
- return async function recoverFromNavigationError(error, navAbort, url, fromUrl) {
1047
- if (error instanceof RedirectError) {
1048
- if (currentNavAbort() !== navAbort) return true;
1049
- await navigate(error.redirectUrl);
1050
- return true;
1051
- }
1052
- if (error instanceof ServerErrorResponse || error instanceof NonRscResponse || recordSkew(error)) {
1053
- await leaveSpaIfOwned(navAbort, url, fromUrl);
1054
- return true;
85
+ function createRenderOwner(kind) {
86
+ let resolveDisplaced;
87
+ const displaced = new Promise((r) => {
88
+ resolveDisplaced = r;
89
+ });
90
+ const owner = {
91
+ kind,
92
+ fetchAbort: new AbortController(),
93
+ handedOff: false,
94
+ outcome: null,
95
+ displaced,
96
+ settle(outcome) {
97
+ if (owner.outcome === null) owner.outcome = outcome;
98
+ else if (outcome === "superseded" && owner.outcome === "committed") owner.outcome = "superseded";
99
+ if (outcome !== "committed") resolveDisplaced();
1055
100
  }
1056
- return false;
1057
101
  };
1058
- }
1059
- /**
1060
- * Perform the full document load.
1061
- *
1062
- * `fromUrl` is the URL the navigation departed from, passed explicitly
1063
- * because the address bar may have already been updated.
1064
- *
1065
- * When the target differs from the departure point only by #fragment,
1066
- * assigning `href` is a hash change, not a load — so assign first (to update
1067
- * the address bar) and then reload (TIM-1235).
1068
- */
1069
- function hardNavigate(url, fromUrl) {
1070
- const current = new URL(fromUrl, window.location.origin);
1071
- const target = new URL(url, window.location.origin);
1072
- if (target.pathname === current.pathname && target.search === current.search) {
1073
- window.location.href = url;
1074
- window.location.reload();
1075
- } else window.location.href = url;
1076
- }
1077
- //#endregion
1078
- //#region src/client/navigation-transition.ts
1079
- var NAV_TRANSITION_KEY = Symbol.for("__timber_nav_transition_counter");
1080
- function getTransitionCounter() {
1081
- const g = globalThis;
1082
- const existing = g[NAV_TRANSITION_KEY];
1083
- if (!existing) {
1084
- const created = {
1085
- id: 0,
1086
- waiters: /* @__PURE__ */ new Set()
1087
- };
1088
- g[NAV_TRANSITION_KEY] = created;
1089
- return created;
1090
- }
1091
- existing.waiters ??= /* @__PURE__ */ new Set();
1092
- return existing;
1093
- }
1094
- /** Bump the counter and wake everything waiting on an older transition. */
1095
- function bumpTransitionCounter() {
1096
- const counter = getTransitionCounter();
1097
- counter.id += 1;
1098
- for (const wake of [...counter.waiters]) wake();
1099
- return counter.id;
1100
- }
1101
- /**
1102
- * Invalidate all in-flight navigation transitions. Any navigateTransition()
1103
- * call whose perform() has not yet committed will reject with AbortError
1104
- * instead of committing its element.
1105
- *
1106
- * Called by the router when a render supersedes in-flight navigations
1107
- * WITHOUT going through navigateTransition() — the cached popstate replay
1108
- * renders directly, which doesn't bump the counter, so a stale forward
1109
- * navigation's render would otherwise pass the `counter.id !== transId`
1110
- * guard and commit the forward page over the replayed back page (TIM-1022).
1111
- */
1112
- function supersedeNavigationTransitions() {
1113
- bumpTransitionCounter();
102
+ return owner;
1114
103
  }
1115
104
  //#endregion
1116
105
  //#region src/client/router-lifecycle.ts
@@ -1118,16 +107,15 @@ function supersedeNavigationTransitions() {
1118
107
  * Navigation Lifecycle — who owns the router, and when a fetch may be cut.
1119
108
  *
1120
109
  * One navigation at a time owns the router. This module holds that ownership
1121
- * (`currentNavAbort`), the rule for taking it (`createNavAbort` supersedes),
1122
- * the wrapper every navigation runs inside (`runNavigation`), the set of
1123
- * navigations whose trees are on screen and therefore unabortable
1124
- * (`handedOffNavAborts`), and the pending store that `TopLoader` and
1125
- * `usePendingNavigation()` subscribe to.
110
+ * as a single `RenderOwner` slot, the rule for taking it (`createNavOwner`
111
+ * supersedes), the wrapper every navigation runs inside (`runNavigation`),
112
+ * and the pending store that `TopLoader` and `usePendingNavigation()`
113
+ * subscribe to.
1126
114
  *
1127
- * These belong together because every one of them is a read or a write of
1128
- * "which navigation is the user waiting for?" — the question `router.ts` used
1129
- * to answer in four places. Split out of `router.ts` for the same reason
1130
- * `router-effects.ts` was (design/18-build-system.md §"No file >500 lines").
115
+ * TIM-1481: replaced the transition counter, the `handedOffNavAborts` set,
116
+ * and the `AbortController`-as-owner pattern with `RenderOwner`. Supersession
117
+ * is "take the slot": settle the previous owner and abort its fetch unless
118
+ * handed off.
1131
119
  *
1132
120
  * See design/19-client-navigation.md §"How Pending State Works".
1133
121
  */
@@ -1141,99 +129,69 @@ function isAbortError(error) {
1141
129
  return false;
1142
130
  }
1143
131
  function createNavigationLifecycle(deps) {
1144
- let currentNavAbort = null;
132
+ let current = null;
133
+ let pendingCommit = null;
1145
134
  let routerPhase = { phase: "idle" };
1146
135
  const pendingListeners = /* @__PURE__ */ new Set();
136
+ let navigationSeq = 0;
137
+ let idleTask = null;
1147
138
  /**
1148
- * The controllers of navigations whose trees have been HANDED TO REACT.
1149
- *
1150
- * Its response may still be streaming: a destination reveals as soon as
1151
- * React can render it, so the tree on screen routinely has Suspense
1152
- * boundaries still waiting on later Flight rows. Aborting that response
1153
- * rejects those rows, and the rejection surfaces through the tree into
1154
- * whatever error boundary the app has — replacing the page the user is
1155
- * looking at with an error state, while the successor navigation is still
1156
- * in flight (codex on #1004).
1157
- *
1158
- * So such a navigation's stream is allowed to finish. It is finite and
1159
- * already in flight; the alternative is a visible error on the page being
1160
- * departed from. Cancelling is still correct for a navigation whose payload
1161
- * never reached React at all, which is the case the abort was written for.
1162
- *
1163
- * **Handed over, not committed.** This is set when the tree is given to
1164
- * React, not when React commits it. Marking on commit is a whole React
1165
- * commit phase too late: the notification would be `NavigationRoot`'s
1166
- * layout effect, and React runs *descendant* layout effects first — so a
1167
- * destination that navigates from its own mount layout effect (a redirect
1168
- * guard) runs before the mark and its just-committed stream gets torn out
1169
- * from under it. `tests/navigation-supersede.test.ts` already pins that
1170
- * ordering, and there is no earlier hook short of an extra sibling fiber,
1171
- * which would shift every `useId` in the payload (see `server/ssr-wrappers`).
1172
- *
1173
- * Handing over is the right moment on its own terms, not merely a safe
1174
- * over-approximation: from the instant React holds the tree it may commit
1175
- * it without asking, so there is no later point at which "not on screen"
1176
- * is still knowable from out here. The residue is that a navigation
1177
- * superseded in the window between handover and commit keeps streaming a
1178
- * payload nobody sees — bandwidth on a finite response, against a visible
1179
- * error page the other way (codex on #1004, second round).
1180
- *
1181
- * A SET, not a single slot. Between a successor's handover and its commit
1182
- * React is still showing the previous tree — that is what the transition
1183
- * buys — so the previous navigation's stream is still feeding the screen and
1184
- * must stay unabortable. Entries are dropped when a later tree actually
1185
- * commits (`forgetOlderHandoffs`), which is the moment the trees they fed
1186
- * are gone. A single slot made a legitimate handover steal protection from
1187
- * a stream still on screen, and let a superseded navigation overwrite the
1188
- * winner's entry outright.
139
+ * Whether a handed-off tree has not yet committed. Derived from the slot:
140
+ * true when `current` is handed off but not yet settled.
1189
141
  */
1190
- const handedOffNavAborts = /* @__PURE__ */ new Set();
142
+ function hasUncommittedNav() {
143
+ return pendingCommit !== null || current !== null && current.handedOff && current.outcome === null;
144
+ }
1191
145
  /**
1192
146
  * Cancel a navigation's RSC fetch — unless its tree is the one on screen.
1193
147
  *
1194
148
  * THE only place a navigation controller is aborted. Every path that gives
1195
149
  * up on a navigation calls this, so the "is this tree displayed?" question
1196
- * is asked once rather than at each site (the same reason `createSpaExits`
1197
- * exists for the SPA exits). A new abort path is a call to this, not a copy
1198
- * of `controller.abort()`.
150
+ * is asked once rather than at each site. A new abort path is a call to
151
+ * this, not a copy of `controller.abort()`.
1199
152
  *
1200
- * See `handedOffNavAborts` for why such a navigation keeps its stream.
153
+ * See design/19-client-navigation.md §"A navigation that reached React
154
+ * keeps its stream".
1201
155
  */
1202
- function abortUnlessHandedOff(controller) {
1203
- if (handedOffNavAborts.has(controller)) return;
1204
- controller.abort();
156
+ function abortUnlessHandedOff(owner) {
157
+ if (owner.handedOff) return;
158
+ owner.fetchAbort.abort();
1205
159
  }
1206
160
  /**
1207
- * Create a new AbortController for a navigation, superseding any
1208
- * previous in-flight navigation. Optionally links to an external
1209
- * signal (e.g., from the Navigation API's NavigateEvent.signal).
161
+ * Create a new RenderOwner for a navigation, superseding any previous
162
+ * in-flight navigation. Optionally links to an external signal (e.g.,
163
+ * from the Navigation API's NavigateEvent.signal).
1210
164
  *
1211
165
  * Superseding is one operation with three parts:
1212
166
  * 1. Abort the previous navigation's fetch — UNLESS its tree is the one on
1213
167
  * screen, in which case tearing the stream down would error the page the
1214
- * user is currently looking at. See `handedOffNavAborts`.
1215
- * 2. Invalidate its render transition so a response that already
1216
- * arrived can't commit a stale tree (NavigationRoot's transId guard).
1217
- * This happens either way, so skipping the abort cannot let a stale tree
1218
- * displace the successor.
168
+ * user is currently looking at. See `RenderOwner.handedOff`.
169
+ * 2. Settle the previous owner as 'superseded' so its transition detects
170
+ * it lost and never hands a stale tree to React. This replaces both
171
+ * `supersedeNavigationTransitions()` and the transition counter bump.
1219
172
  * 3. Resolve its Navigation API deferred — the superseded navigation's
1220
173
  * finally block is staleness-guarded (see TIM-1034) and no longer
1221
174
  * cleans up after itself, so the browser's native loading state for
1222
175
  * the dead navigation is cleared here.
1223
176
  */
1224
- function createNavAbort(externalSignal) {
1225
- if (currentNavAbort) {
1226
- abortUnlessHandedOff(currentNavAbort);
1227
- supersedeNavigationTransitions();
177
+ function createNavOwner(kind, externalSignal) {
178
+ if (current) {
179
+ abortUnlessHandedOff(current);
180
+ current.settle("superseded");
1228
181
  deps.completeRouterNavigation?.();
1229
182
  }
1230
- const controller = new AbortController();
1231
- currentNavAbort = controller;
183
+ if (pendingCommit) {
184
+ pendingCommit.settle("superseded");
185
+ pendingCommit = null;
186
+ }
187
+ navigationSeq += 1;
188
+ const owner = createRenderOwner(kind);
189
+ current = owner;
1232
190
  if (externalSignal) {
1233
- if (externalSignal.aborted) abortUnlessHandedOff(controller);
1234
- else externalSignal.addEventListener("abort", () => abortUnlessHandedOff(controller), { once: true });
191
+ if (externalSignal.aborted) abortUnlessHandedOff(owner);
192
+ else externalSignal.addEventListener("abort", () => abortUnlessHandedOff(owner), { once: true });
1235
193
  }
1236
- return controller;
194
+ return owner;
1237
195
  }
1238
196
  function setPending(value, url) {
1239
197
  const next = value && url ? {
@@ -1246,43 +204,80 @@ function createNavigationLifecycle(deps) {
1246
204
  }
1247
205
  /**
1248
206
  * Wrap a navigation in the standard abort/pending/cleanup lifecycle.
1249
- * Consolidates the createNavAbort + setPending + staleness-guarded
207
+ * Consolidates the createNavOwner + setPending + staleness-guarded
1250
208
  * finally that was duplicated across navigate, refresh, and both
1251
209
  * handlePopState paths. AbortErrors are swallowed (not application
1252
210
  * errors); all other errors propagate to the caller.
1253
211
  */
1254
212
  async function runNavigation(url, fn, externalSignal) {
1255
- const navAbort = createNavAbort(externalSignal);
213
+ const owner = createNavOwner("navigation", externalSignal);
1256
214
  setPending(true, url);
1257
215
  try {
1258
- await fn(navAbort);
216
+ await fn(owner);
1259
217
  } catch (error) {
1260
218
  if (isAbortError(error)) return;
1261
219
  throw error;
1262
220
  } finally {
1263
- if (currentNavAbort === navAbort) {
1264
- currentNavAbort = null;
221
+ if (current === owner) {
222
+ current = null;
1265
223
  setPending(false);
1266
224
  deps.completeRouterNavigation?.();
225
+ flushIdleTask();
1267
226
  }
1268
227
  }
1269
228
  }
229
+ function flushIdleTask() {
230
+ if (routerPhase.phase !== "idle" || hasUncommittedNav() || !idleTask) return;
231
+ const task = idleTask;
232
+ idleTask = null;
233
+ task();
234
+ }
1270
235
  return {
1271
- currentNavAbort: () => currentNavAbort,
1272
- createNavAbort,
236
+ currentOwner: () => current,
237
+ createNavOwner,
1273
238
  runNavigation,
1274
239
  markHandedOff(owner) {
1275
- if (currentNavAbort !== owner) return;
1276
- handedOffNavAborts.add(owner);
240
+ if (current !== owner) return;
241
+ owner.handedOff = true;
242
+ pendingCommit = owner;
1277
243
  },
1278
244
  forgetOlderHandoffs(owner) {
1279
- for (const controller of handedOffNavAborts) if (controller !== owner) handedOffNavAborts.delete(controller);
245
+ owner.settle("committed");
246
+ if (pendingCommit === owner) pendingCommit = null;
247
+ flushIdleTask();
248
+ },
249
+ placeRevalidationOwner(owner) {
250
+ current = owner;
1280
251
  },
1281
252
  isPending: () => routerPhase.phase === "navigating",
1282
253
  getPendingUrl: () => routerPhase.phase === "navigating" ? routerPhase.targetUrl : null,
1283
254
  onPendingChange(listener) {
1284
255
  pendingListeners.add(listener);
1285
256
  return () => pendingListeners.delete(listener);
257
+ },
258
+ epoch() {
259
+ return {
260
+ seq: navigationSeq,
261
+ idle: routerPhase.phase === "idle" && !hasUncommittedNav()
262
+ };
263
+ },
264
+ isEpochCurrent(e) {
265
+ return e.idle && routerPhase.phase === "idle" && !hasUncommittedNav() && navigationSeq === e.seq;
266
+ },
267
+ runWhenIdle(task) {
268
+ if (routerPhase.phase === "idle" && !hasUncommittedNav()) {
269
+ task();
270
+ return;
271
+ }
272
+ idleTask = task;
273
+ },
274
+ settleHandoffs() {
275
+ if (pendingCommit) {
276
+ pendingCommit.settle("superseded");
277
+ pendingCommit = null;
278
+ }
279
+ if (current !== null && current.handedOff && current.outcome === null) current.settle("superseded");
280
+ flushIdleTask();
1286
281
  }
1287
282
  };
1288
283
  }
@@ -1296,7 +291,7 @@ function createNavigationLifecycle(deps) {
1296
291
  * that makes the page current, and hand the result over inside a transition.
1297
292
  *
1298
293
  * `router.ts` keeps the *operations* — `navigate`, `refresh`, `handlePopState`,
1299
- * `prefetch`, `applyRevalidation` — and each of them is a call into here. The
294
+ * `prefetch`, `applyActionResult` — and each of them is a call into here. The
1300
295
  * split is the same one `router-effects.ts` and `router-lifecycle.ts` made
1301
296
  * (design/18-build-system.md §"No file >500 lines").
1302
297
  *
@@ -1413,14 +408,14 @@ function createNavigationPipeline({ deps, prefetchCache, currentStateTree, prepa
1413
408
  * make (TIM-1301). The fallback path below has no transition to be
1414
409
  * superseded by, so it commits directly.
1415
410
  */
1416
- async function renderViaTransition(url, owner, perform) {
411
+ async function renderViaTransition(url, owner, perform, onCommit) {
1417
412
  const handOff = () => markHandedOff(owner);
1418
413
  const commitAndForget = (commit) => () => {
1419
414
  forgetOlderHandoffs(owner);
1420
415
  commit();
1421
416
  };
1422
417
  if (deps.navigateTransition) {
1423
- await deps.navigateTransition(url, async (wrapPayload) => {
418
+ await deps.navigateTransition(url, owner, async (wrapPayload) => {
1424
419
  const result = await perform();
1425
420
  const params = await result.params;
1426
421
  if (isPartialNavigation(result.skippedSegments)) {
@@ -1440,14 +435,22 @@ function createNavigationPipeline({ deps, prefetchCache, currentStateTree, prepa
1440
435
  decodePromise: observeSkew(result.decodePromise),
1441
436
  commit: commitAndForget(result.commit)
1442
437
  };
1443
- });
438
+ }, onCommit);
1444
439
  return;
1445
440
  }
1446
- const result = await perform();
1447
- handOff();
1448
- const commit = commitAndForget(result.commit);
1449
- if (isPartialNavigation(result.skippedSegments)) commit();
1450
- else renderPayload(result.payload, result.navState, await result.params, commit);
441
+ let fallbackOutcome = "committed";
442
+ try {
443
+ const result = await perform();
444
+ handOff();
445
+ const commit = commitAndForget(result.commit);
446
+ if (isPartialNavigation(result.skippedSegments)) commit();
447
+ else renderPayload(result.payload, result.navState, await result.params, commit);
448
+ } catch (error) {
449
+ fallbackOutcome = "failed";
450
+ throw error;
451
+ } finally {
452
+ onCommit?.(fallbackOutcome);
453
+ }
1451
454
  }
1452
455
  /**
1453
456
  * Core navigation logic shared between the transition and fallback paths.
@@ -1467,27 +470,30 @@ function createNavigationPipeline({ deps, prefetchCache, currentStateTree, prepa
1467
470
  params: prefetched.params ?? readPublishedParams(void 0),
1468
471
  decodePromise: prefetched.decodePromise ?? null,
1469
472
  segmentInfo: prefetched.segmentInfo ?? null,
1470
- skippedSegments: prefetched.skippedSegments ?? null,
1471
- status: prefetched.status ?? 200
473
+ skippedSegments: prefetched.skippedSegments ?? null
1472
474
  } : void 0;
1473
475
  if (result === void 0) {
1474
476
  const inflight = prefetchCache.joinInflight(cacheKey);
1475
- if (inflight) try {
1476
- const outcome = await raceAbort(inflight, options.signal);
1477
- if (outcome.kind === "non-route") throw new NonRscResponse(url);
1478
- prefetchCache.consume(cacheKey);
1479
- result = {
1480
- payload: outcome.result.payload,
1481
- params: outcome.result.params ?? readPublishedParams(void 0),
1482
- decodePromise: outcome.result.decodePromise ?? null,
1483
- segmentInfo: outcome.result.segmentInfo ?? null,
1484
- skippedSegments: outcome.result.skippedSegments ?? null,
1485
- status: outcome.result.status ?? 200
1486
- };
1487
- } catch (error) {
1488
- if (error instanceof DOMException && error.name === "AbortError") throw error;
1489
- if (options.signal?.aborted) throw options.signal.reason;
1490
- if (error instanceof SingleflightTimeoutError || error instanceof TypeError) {} else throw error;
477
+ if (inflight) {
478
+ const genBefore = prefetchCache.getEvictionGen();
479
+ try {
480
+ const outcome = await raceAbort(inflight, options.signal);
481
+ if (prefetchCache.getEvictionGen() !== genBefore) {} else if (outcome.kind === "non-route") throw new NonRscResponse(url);
482
+ else {
483
+ prefetchCache.consume(cacheKey);
484
+ result = {
485
+ payload: outcome.result.payload,
486
+ params: outcome.result.params ?? readPublishedParams(void 0),
487
+ decodePromise: outcome.result.decodePromise ?? null,
488
+ segmentInfo: outcome.result.segmentInfo ?? null,
489
+ skippedSegments: outcome.result.skippedSegments ?? null
490
+ };
491
+ }
492
+ } catch (error) {
493
+ if (error instanceof DOMException && error.name === "AbortError") throw error;
494
+ if (options.signal?.aborted) throw options.signal.reason;
495
+ if (error instanceof SingleflightTimeoutError || error instanceof TypeError) {} else throw error;
496
+ }
1491
497
  }
1492
498
  }
1493
499
  if (result === void 0) result = await fetchRscPayload(url, deps, stateTree, currentUrl, options.signal);
@@ -1511,7 +517,6 @@ function createNavigationPipeline({ deps, prefetchCache, currentStateTree, prepa
1511
517
  payload,
1512
518
  params,
1513
519
  segmentInfo: result.segmentInfo,
1514
- status: result.status,
1515
520
  skippedSegments: result.skippedSegments
1516
521
  });
1517
522
  return {
@@ -1548,8 +553,9 @@ function createRouter(deps) {
1548
553
  historyStack,
1549
554
  clientSegmentCache: () => deps.clientSegmentCache
1550
555
  });
1551
- const { currentNavAbort, createNavAbort, runNavigation, markHandedOff, forgetOlderHandoffs, isPending, getPendingUrl, onPendingChange } = createNavigationLifecycle(deps);
1552
- const { performNavigationFetch, renderViaTransition, renderPayload, resolveForFallback } = createNavigationPipeline({
556
+ const lifecycle = createNavigationLifecycle(deps);
557
+ const { currentOwner, createNavOwner, runNavigation, markHandedOff, forgetOlderHandoffs, isPending, getPendingUrl, onPendingChange } = lifecycle;
558
+ const { performNavigationFetch, renderViaTransition, resolveForFallback } = createNavigationPipeline({
1553
559
  deps,
1554
560
  prefetchCache,
1555
561
  currentStateTree,
@@ -1559,11 +565,11 @@ function createRouter(deps) {
1559
565
  });
1560
566
  const { restoreScrollAfterPaint, scrollToHashAfterPaint } = createScrollEffects(deps);
1561
567
  const { leaveSpaIfOwned, leaveSpaSuperseding } = createSpaExits({
1562
- currentNavAbort,
1563
- supersede: () => void createNavAbort()
568
+ currentOwner,
569
+ supersede: () => void createNavOwner("navigation")
1564
570
  });
1565
571
  const recoverFromNavigationError = createNavigationRecovery({
1566
- currentNavAbort,
572
+ currentOwner,
1567
573
  leaveSpaIfOwned,
1568
574
  navigate: (url) => navigate(url, { replace: true })
1569
575
  });
@@ -1584,7 +590,7 @@ function createRouter(deps) {
1584
590
  }, "", deps.getCurrentUrl());
1585
591
  if (isClientStale()) await leaveSpaSuperseding(url, departingUrl);
1586
592
  let effectiveSkipHistory = skipHistory;
1587
- await runNavigation(url, async (navAbort) => {
593
+ await runNavigation(url, async (owner) => {
1588
594
  if (!effectiveSkipHistory && deps.navigationNavigate) {
1589
595
  deps.setRouterNavigating?.(true);
1590
596
  deps.navigationNavigate(url, replace);
@@ -1592,17 +598,17 @@ function createRouter(deps) {
1592
598
  effectiveSkipHistory = true;
1593
599
  }
1594
600
  try {
1595
- await renderViaTransition(fetchUrl, navAbort, () => performNavigationFetch(fetchUrl, {
601
+ await renderViaTransition(fetchUrl, owner, () => performNavigationFetch(fetchUrl, {
1596
602
  replace,
1597
603
  commitUrl: url,
1598
- signal: navAbort.signal,
604
+ signal: owner.fetchAbort.signal,
1599
605
  skipHistory: effectiveSkipHistory,
1600
606
  departingUrl
1601
- }));
607
+ }), options.onCommit);
1602
608
  if (scroll && hash) scrollToHashAfterPaint(hash);
1603
609
  else restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
1604
610
  } catch (error) {
1605
- if (await recoverFromNavigationError(error, navAbort, url, departingUrl)) return;
611
+ if (await recoverFromNavigationError(error, owner, url, departingUrl)) return;
1606
612
  throw error;
1607
613
  }
1608
614
  }, externalSignal);
@@ -1617,17 +623,16 @@ function createRouter(deps) {
1617
623
  * browser is already where it is going), so the fetch is a plain one.
1618
624
  */
1619
625
  async function fetchCommitAndRender(url, opts = {}) {
1620
- await runNavigation(url, async (navAbort) => {
626
+ await runNavigation(url, async (owner) => {
1621
627
  try {
1622
- await renderViaTransition(url, navAbort, async () => {
1623
- const result = await fetchRscPayload(url, deps, opts.stateTree, void 0, navAbort.signal);
628
+ await renderViaTransition(url, owner, async () => {
629
+ const result = await fetchRscPayload(url, deps, opts.stateTree, void 0, owner.fetchAbort.signal);
1624
630
  const payload = await resolveForFallback(result.payload);
1625
631
  const params = await result.params;
1626
632
  const { navState, commit } = prepareNavigation(url, {
1627
633
  payload,
1628
634
  params,
1629
635
  segmentInfo: result.segmentInfo,
1630
- status: result.status,
1631
636
  skippedSegments: result.skippedSegments
1632
637
  });
1633
638
  return {
@@ -1637,30 +642,40 @@ function createRouter(deps) {
1637
642
  navState,
1638
643
  commit
1639
644
  };
1640
- });
645
+ }, opts.onCommit);
1641
646
  } catch (error) {
1642
- if (await recoverFromNavigationError(error, navAbort, url, url)) return;
647
+ if (await recoverFromNavigationError(error, owner, url, url)) return;
1643
648
  throw error;
1644
649
  }
1645
650
  if (opts.scrollY !== void 0) restoreScrollAfterPaint(opts.scrollY);
1646
651
  }, opts.externalSignal);
1647
652
  }
1648
- async function refresh() {
653
+ async function refresh(options) {
1649
654
  const currentUrl = deps.getCurrentUrl();
1650
655
  if (isClientStale()) await leaveSpaSuperseding(currentUrl, currentUrl);
1651
- await fetchCommitAndRender(currentUrl);
656
+ await fetchCommitAndRender(currentUrl, { onCommit: options?.onCommit });
1652
657
  }
1653
658
  async function handlePopState(url, scrollY = 0, externalSignal) {
1654
659
  if (isClientStale()) await leaveSpaSuperseding(url, url);
1655
660
  const entry = historyStack.get(url);
1656
- if (entry && entry.payload !== null) await runNavigation(url, async () => {
1657
- const { navState, commit } = prepareNavigation(url, {
1658
- payload: entry.payload,
1659
- params: entry.params,
1660
- segmentInfo: entry.segmentInfo,
1661
- clearSegmentCacheOnEmpty: true
661
+ if (entry && entry.payload !== null) await runNavigation(url, async (owner) => {
662
+ await renderViaTransition(url, owner, async () => {
663
+ const { navState, commit } = prepareNavigation(url, {
664
+ payload: entry.payload,
665
+ params: entry.params,
666
+ segmentInfo: entry.segmentInfo,
667
+ clearSegmentCacheOnEmpty: true
668
+ });
669
+ return {
670
+ payload: entry.payload,
671
+ params: entry.params,
672
+ navState,
673
+ commit,
674
+ decodePromise: null,
675
+ segmentInfo: entry.segmentInfo ?? null,
676
+ skippedSegments: null
677
+ };
1662
678
  });
1663
- renderPayload(entry.payload, navState, entry.params, commit);
1664
679
  restoreScrollAfterPaint(scrollY);
1665
680
  }, externalSignal);
1666
681
  else await fetchCommitAndRender(url, {
@@ -1684,7 +699,6 @@ function createRouter(deps) {
1684
699
  const cacheKey = prefetchKeyFor(fetchUrl, deps.getCurrentUrl(), stateTree);
1685
700
  const from = cacheKey.from;
1686
701
  if (prefetchCache.has(cacheKey)) return;
1687
- if (historyStack.has(fetchUrl)) return;
1688
702
  prefetchCache.fetchOrCoalesce(cacheKey, (signal) => fetchRscPayload(fetchUrl, deps, stateTree, from, signal), (err) => err instanceof NonRscResponse).then((outcome) => {
1689
703
  if (outcome.kind === "ready") outcome.result.decodePromise?.catch((error) => void recordSkew(error));
1690
704
  }, (error) => {
@@ -1699,17 +713,50 @@ function createRouter(deps) {
1699
713
  getPendingUrl,
1700
714
  onPendingChange,
1701
715
  prefetch,
1702
- applyRevalidation(payloadRoot) {
716
+ epoch: () => lifecycle.epoch(),
717
+ async applyActionResult(epoch, tree) {
718
+ if (!lifecycle.isEpochCurrent(epoch)) return false;
719
+ if (tree === void 0) {
720
+ let outcomeResolve;
721
+ const outcomePromise = new Promise((r) => outcomeResolve = r);
722
+ const [, outcome] = await Promise.all([refresh({ onCommit: outcomeResolve }).catch(() => {}), outcomePromise]);
723
+ return outcome === "committed";
724
+ }
1703
725
  const currentUrl = deps.getCurrentUrl();
1704
- const tree = readPayloadTree(payloadRoot);
1705
- const params = readPublishedParams(payloadRoot);
726
+ const payloadTree = readPayloadTree(tree);
727
+ const params = readPublishedParams(tree);
1706
728
  const existingEntry = historyStack.get(currentUrl);
1707
- const { navState, commit } = prepareNavigation(currentUrl, {
1708
- payload: tree,
1709
- params,
1710
- segmentInfo: existingEntry?.segmentInfo
1711
- });
1712
- renderPayload(tree, navState, params, commit);
729
+ let outcomeResolve;
730
+ const outcomePromise = new Promise((r) => outcomeResolve = r);
731
+ const owner = createRenderOwner("revalidation");
732
+ lifecycle.placeRevalidationOwner(owner);
733
+ const [, outcome] = await Promise.all([renderViaTransition(currentUrl, owner, async () => {
734
+ const { navState, commit } = prepareNavigation(currentUrl, {
735
+ payload: payloadTree,
736
+ params,
737
+ segmentInfo: existingEntry?.segmentInfo
738
+ });
739
+ return {
740
+ payload: payloadTree,
741
+ params,
742
+ navState,
743
+ commit,
744
+ decodePromise: null,
745
+ segmentInfo: existingEntry?.segmentInfo ?? null,
746
+ skippedSegments: null
747
+ };
748
+ }, outcomeResolve).catch(() => {}), outcomePromise]);
749
+ return outcome === "committed";
750
+ },
751
+ runWhenIdle: (task) => lifecycle.runWhenIdle(task),
752
+ settleHandoffs: () => lifecycle.settleHandoffs(),
753
+ invalidatePath(path) {
754
+ historyStack.delete(path);
755
+ prefetchCache.invalidateUrl(path);
756
+ },
757
+ evictStaleCaches() {
758
+ prefetchCache.clearReady();
759
+ historyStack.clearExcept(deps.getCurrentUrl());
1713
760
  },
1714
761
  initSegmentCache: (segments) => updateSegmentCache(segments),
1715
762
  segmentCache,