@timber-js/app 0.2.0-alpha.198 → 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 (246) 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-dVDi1GQz.js → cli-check-D6VolrDV.js} +3 -3
  11. package/dist/_chunks/{cli-check-dVDi1GQz.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-Ph6luW4c.js → convention-lint-fRkwVwEH.js} +25 -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/index.js +2 -2
  92. package/dist/routing/interception.d.ts +2 -2
  93. package/dist/routing/slot-placement.d.ts +2 -2
  94. package/dist/server/access-gate.d.ts +73 -1
  95. package/dist/server/access-gate.d.ts.map +1 -1
  96. package/dist/server/action-handler.d.ts.map +1 -1
  97. package/dist/server/actions.d.ts +16 -1
  98. package/dist/server/actions.d.ts.map +1 -1
  99. package/dist/server/als-registry.d.ts +3 -9
  100. package/dist/server/als-registry.d.ts.map +1 -1
  101. package/dist/server/children-interception.d.ts +1 -1
  102. package/dist/server/default-status-page.d.ts +2 -2
  103. package/dist/server/default-status-page.d.ts.map +1 -1
  104. package/dist/server/deny-boundary.d.ts +15 -9
  105. package/dist/server/deny-boundary.d.ts.map +1 -1
  106. package/dist/server/deny-renderer.d.ts.map +1 -1
  107. package/dist/server/error-boundary-wrapper.d.ts +21 -4
  108. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  109. package/dist/server/error-response-headers.d.ts +3 -0
  110. package/dist/server/error-response-headers.d.ts.map +1 -0
  111. package/dist/server/index.js +3 -3
  112. package/dist/server/index.js.map +1 -1
  113. package/dist/server/internal.d.ts +1 -2
  114. package/dist/server/internal.d.ts.map +1 -1
  115. package/dist/server/internal.js +2339 -2506
  116. package/dist/server/internal.js.map +1 -1
  117. package/dist/server/metadata-collector.d.ts +2 -5
  118. package/dist/server/metadata-collector.d.ts.map +1 -1
  119. package/dist/server/param-coercion.d.ts +10 -3
  120. package/dist/server/param-coercion.d.ts.map +1 -1
  121. package/dist/server/pipeline-outcome.d.ts.map +1 -1
  122. package/dist/server/pipeline-phases.d.ts +11 -0
  123. package/dist/server/pipeline-phases.d.ts.map +1 -1
  124. package/dist/server/port-resolution.d.ts +3 -89
  125. package/dist/server/port-resolution.d.ts.map +1 -1
  126. package/dist/server/primitives.d.ts +38 -10
  127. package/dist/server/primitives.d.ts.map +1 -1
  128. package/dist/server/response-cache-policy.d.ts +3 -0
  129. package/dist/server/response-cache-policy.d.ts.map +1 -0
  130. package/dist/server/route-element-builder.d.ts +11 -41
  131. package/dist/server/route-element-builder.d.ts.map +1 -1
  132. package/dist/server/route-element-helpers.d.ts +12 -0
  133. package/dist/server/route-element-helpers.d.ts.map +1 -0
  134. package/dist/server/route-module-loader.d.ts +37 -0
  135. package/dist/server/route-module-loader.d.ts.map +1 -0
  136. package/dist/server/rsc-cache-key-guard.d.ts.map +1 -1
  137. package/dist/server/rsc-entry/action-middleware-runner.d.ts.map +1 -1
  138. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  139. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  140. package/dist/server/rsc-entry/rsc-payload.d.ts +22 -1
  141. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  142. package/dist/server/rsc-entry/rsc-stream.d.ts +4 -11
  143. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  144. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  145. package/dist/server/rsc-error-envelope.d.ts +11 -0
  146. package/dist/server/rsc-error-envelope.d.ts.map +1 -0
  147. package/dist/server/skippable-prefix.d.ts +18 -15
  148. package/dist/server/skippable-prefix.d.ts.map +1 -1
  149. package/dist/server/slot-resolver.d.ts.map +1 -1
  150. package/dist/server/slot-subtree-contain.d.ts +54 -0
  151. package/dist/server/slot-subtree-contain.d.ts.map +1 -0
  152. package/dist/server/stream-utils.d.ts.map +1 -1
  153. package/dist/server/utils/element-type.d.ts +10 -0
  154. package/dist/server/utils/element-type.d.ts.map +1 -1
  155. package/dist/shared/rsc-error-envelope.d.ts +0 -9
  156. package/dist/shared/rsc-error-envelope.d.ts.map +1 -1
  157. package/dist/shared/status-reason-phrase.d.ts +26 -0
  158. package/dist/shared/status-reason-phrase.d.ts.map +1 -0
  159. package/docs/api/30-api-server.mdx +4 -2
  160. package/docs/api/31-api-client.mdx +5 -1
  161. package/docs/api/36-cli.mdx +5 -3
  162. package/docs/learn/12-error-handling.mdx +5 -1
  163. package/package.json +10 -10
  164. package/src/client/browser-entry/action-dispatch.ts +166 -99
  165. package/src/client/browser-entry/action-queue.ts +90 -0
  166. package/src/client/browser-entry/router-init.ts +60 -35
  167. package/src/client/deny-last-resort.tsx +54 -0
  168. package/src/client/error-boundary.tsx +144 -42
  169. package/src/client/history.ts +52 -3
  170. package/src/client/internal.ts +1 -0
  171. package/src/client/link.tsx +70 -35
  172. package/src/client/navigation-commit.ts +79 -27
  173. package/src/client/navigation-transition.ts +176 -127
  174. package/src/client/router-effects.ts +14 -17
  175. package/src/client/router-lifecycle.ts +181 -115
  176. package/src/client/router-pipeline.ts +94 -71
  177. package/src/client/router-types.ts +61 -7
  178. package/src/client/router.ts +147 -74
  179. package/src/client/rsc-fetch.ts +0 -13
  180. package/src/client/segment-cache.ts +43 -10
  181. package/src/client/state.ts +26 -0
  182. package/src/client/status-page-marker.tsx +32 -0
  183. package/src/dev-tools/holding-server.ts +4 -17
  184. package/src/index.ts +18 -34
  185. package/src/plugins/dev-server.ts +2 -1
  186. package/src/routing/interception.ts +2 -2
  187. package/src/routing/slot-placement.ts +2 -2
  188. package/src/server/access-gate.tsx +89 -21
  189. package/src/server/action-client.ts +2 -2
  190. package/src/server/action-handler.ts +23 -10
  191. package/src/server/actions.ts +81 -34
  192. package/src/server/als-registry.ts +3 -9
  193. package/src/server/children-interception.ts +1 -1
  194. package/src/server/default-status-page.ts +7 -47
  195. package/src/server/deny-boundary.ts +45 -28
  196. package/src/server/deny-renderer.ts +6 -2
  197. package/src/server/error-boundary-wrapper.ts +23 -4
  198. package/src/server/error-response-headers.ts +18 -0
  199. package/src/server/internal.ts +2 -10
  200. package/src/server/metadata-collector.ts +3 -18
  201. package/src/server/param-coercion.ts +13 -4
  202. package/src/server/pipeline-outcome.ts +35 -13
  203. package/src/server/pipeline-phases.ts +22 -16
  204. package/src/server/port-resolution.ts +3 -165
  205. package/src/server/prebuilt-builder.ts +4 -4
  206. package/src/server/primitives.ts +75 -11
  207. package/src/server/response-cache-policy.ts +45 -0
  208. package/src/server/route-element-builder.ts +149 -412
  209. package/src/server/route-element-helpers.ts +37 -0
  210. package/src/server/route-handler.ts +2 -2
  211. package/src/server/route-module-loader.ts +161 -0
  212. package/src/server/rsc-cache-key-guard.ts +2 -42
  213. package/src/server/rsc-entry/action-middleware-runner.ts +4 -4
  214. package/src/server/rsc-entry/api-handler.ts +5 -5
  215. package/src/server/rsc-entry/error-renderer.ts +3 -4
  216. package/src/server/rsc-entry/helpers.ts +1 -1
  217. package/src/server/rsc-entry/index.ts +3 -3
  218. package/src/server/rsc-entry/render-route.ts +4 -8
  219. package/src/server/rsc-entry/rsc-payload.ts +59 -42
  220. package/src/server/rsc-entry/rsc-stream.ts +48 -27
  221. package/src/server/rsc-entry/ssr-renderer.ts +6 -10
  222. package/src/server/rsc-error-envelope.ts +18 -0
  223. package/src/server/skippable-prefix.ts +105 -7
  224. package/src/server/slot-resolver.ts +43 -12
  225. package/src/server/slot-subtree-contain.ts +255 -0
  226. package/src/server/stream-utils.ts +12 -8
  227. package/src/server/utils/element-type.ts +18 -2
  228. package/src/shared/rsc-error-envelope.ts +0 -15
  229. package/src/shared/status-reason-phrase.ts +61 -0
  230. package/dist/_chunks/actions-BS-m5SLv.js.map +0 -1
  231. package/dist/_chunks/convention-lint-Ph6luW4c.js.map +0 -1
  232. package/dist/_chunks/error-boundary-BvRCCmbN.js +0 -353
  233. package/dist/_chunks/error-boundary-BvRCCmbN.js.map +0 -1
  234. package/dist/_chunks/logger-DDirEsn7.js.map +0 -1
  235. package/dist/_chunks/mdx-file-CXyHGUpS.js +0 -25
  236. package/dist/_chunks/mdx-file-CXyHGUpS.js.map +0 -1
  237. package/dist/_chunks/router-ref-8gr8qsxN.js +0 -28
  238. package/dist/_chunks/router-ref-8gr8qsxN.js.map +0 -1
  239. package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js +0 -40
  240. package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js.map +0 -1
  241. package/dist/_chunks/scanner-tdFPvDYi.js.map +0 -1
  242. package/dist/_chunks/segment-keys-BhqoHiLc.js.map +0 -1
  243. package/dist/_chunks/ssr-data-BQGhTPAK.js.map +0 -1
  244. package/dist/server/tree-builder.d.ts +0 -150
  245. package/dist/server/tree-builder.d.ts.map +0 -1
  246. package/src/server/tree-builder.ts +0 -313
@@ -2,23 +2,39 @@
2
2
  * Navigation Lifecycle — who owns the router, and when a fetch may be cut.
3
3
  *
4
4
  * One navigation at a time owns the router. This module holds that ownership
5
- * (`currentNavAbort`), the rule for taking it (`createNavAbort` supersedes),
6
- * the wrapper every navigation runs inside (`runNavigation`), the set of
7
- * navigations whose trees are on screen and therefore unabortable
8
- * (`handedOffNavAborts`), and the pending store that `TopLoader` and
9
- * `usePendingNavigation()` subscribe to.
5
+ * as a single `RenderOwner` slot, the rule for taking it (`createNavOwner`
6
+ * supersedes), the wrapper every navigation runs inside (`runNavigation`),
7
+ * and the pending store that `TopLoader` and `usePendingNavigation()`
8
+ * subscribe to.
10
9
  *
11
- * These belong together because every one of them is a read or a write of
12
- * "which navigation is the user waiting for?" — the question `router.ts` used
13
- * to answer in four places. Split out of `router.ts` for the same reason
14
- * `router-effects.ts` was (design/18-build-system.md §"No file >500 lines").
10
+ * TIM-1481: replaced the transition counter, the `handedOffNavAborts` set,
11
+ * and the `AbortController`-as-owner pattern with `RenderOwner`. Supersession
12
+ * is "take the slot": settle the previous owner and abort its fetch unless
13
+ * handed off.
15
14
  *
16
15
  * See design/19-client-navigation.md §"How Pending State Works".
17
16
  */
18
17
 
19
- import { supersedeNavigationTransitions } from './navigation-transition.ts';
18
+ import { createRenderOwner, type RenderOwner } from './navigation-transition.ts';
20
19
  import type { RouterPhase } from './router-types.ts';
21
20
 
21
+ /**
22
+ * A snapshot of "which page, and was a navigation in flight" at one instant.
23
+ * Captured before an action's POST and compared at response time so the
24
+ * router can tell whether the response still describes the page on screen.
25
+ *
26
+ * `seq` is a monotonic counter bumped on every `createNavOwner` — covering
27
+ * every path through `runNavigation`. `idle` is false when the router is
28
+ * mid-navigation OR a handed-off tree has not yet committed (the
29
+ * handover-to-commit window on the History API fallback).
30
+ *
31
+ * See design/19-client-navigation.md §State Update Invariants (TIM-1474).
32
+ */
33
+ export interface NavigationEpoch {
34
+ readonly seq: number;
35
+ readonly idle: boolean;
36
+ }
37
+
22
38
  /** The subset of `RouterDeps` the navigation lifecycle needs. */
23
39
  export interface NavigationLifecycleDeps {
24
40
  /**
@@ -30,29 +46,33 @@ export interface NavigationLifecycleDeps {
30
46
  }
31
47
 
32
48
  export interface NavigationLifecycle {
33
- /** The controller that owns the router right now, or null when idle. */
34
- currentNavAbort: () => AbortController | null;
49
+ /** The owner that holds the router right now, or null when idle. */
50
+ currentOwner: () => RenderOwner | null;
35
51
  /**
36
- * Take ownership of the router, superseding whatever held it. See the
37
- * implementation for the three parts of superseding.
52
+ * Take ownership of the router, superseding whatever held it. Returns
53
+ * a fresh `RenderOwner` for the new navigation. See the implementation
54
+ * for the three parts of superseding.
38
55
  */
39
- createNavAbort: (externalSignal?: AbortSignal) => AbortController;
56
+ createNavOwner: (
57
+ kind: 'navigation' | 'revalidation',
58
+ externalSignal?: AbortSignal
59
+ ) => RenderOwner;
40
60
  /**
41
61
  * Run `fn` inside the abort/pending/cleanup lifecycle. AbortErrors are
42
62
  * swallowed (not application errors); all other errors propagate.
43
63
  */
44
64
  runNavigation: (
45
65
  url: string,
46
- fn: (navAbort: AbortController) => Promise<void>,
66
+ fn: (owner: RenderOwner) => Promise<void>,
47
67
  externalSignal?: AbortSignal
48
68
  ) => Promise<void>;
49
69
  /**
50
70
  * Record that `owner`'s payload has reached React, so its stream is no
51
71
  * longer cuttable. No-op when `owner` no longer owns the router.
52
72
  */
53
- markHandedOff: (owner: AbortController) => void;
73
+ markHandedOff: (owner: RenderOwner) => void;
54
74
  /** React has committed `owner`'s tree — every earlier handoff is off screen. */
55
- forgetOlderHandoffs: (owner: AbortController) => void;
75
+ forgetOlderHandoffs: (owner: RenderOwner) => void;
56
76
 
57
77
  /** Whether a navigation is currently in flight. */
58
78
  isPending: () => boolean;
@@ -60,6 +80,33 @@ export interface NavigationLifecycle {
60
80
  getPendingUrl: () => string | null;
61
81
  /** Subscribe to pending state changes. */
62
82
  onPendingChange: (listener: (pending: boolean) => void) => () => void;
83
+
84
+ /**
85
+ * Place a revalidation owner in the slot without bumping the navigation
86
+ * sequence. Unlike `createNavOwner`, this does not supersede what's in the
87
+ * slot — the caller (applyActionResult) has already verified the slot is
88
+ * idle via the epoch check. A concurrent navigation will supersede this
89
+ * owner through the normal `createNavOwner` path.
90
+ */
91
+ placeRevalidationOwner: (owner: RenderOwner) => void;
92
+ /** Snapshot the router's navigation state (TIM-1474). */
93
+ epoch: () => NavigationEpoch;
94
+ /** True iff no navigation has started or is in flight since `e` was taken. */
95
+ isEpochCurrent: (e: NavigationEpoch) => boolean;
96
+ /**
97
+ * Run `task` now if the router is idle, otherwise once when it next becomes
98
+ * idle (pending false AND commit done). Single slot — a later request
99
+ * replaces an earlier one (TIM-1474).
100
+ */
101
+ runWhenIdle: (task: () => void) => void;
102
+ /**
103
+ * Settle any handed-off-but-uncommitted owners as superseded, then flush
104
+ * the idle task if the router is now idle. Called by `syncShallowSearch`
105
+ * when a shallow URL update displaces a suspended transition — without
106
+ * it, `hasUncommittedNav` stays true indefinitely and `runWhenIdle`
107
+ * tasks never fire (TIM-1480).
108
+ */
109
+ settleHandoffs: () => void;
63
110
  }
64
111
 
65
112
  /**
@@ -73,102 +120,83 @@ function isAbortError(error: unknown): boolean {
73
120
  }
74
121
 
75
122
  export function createNavigationLifecycle(deps: NavigationLifecycleDeps): NavigationLifecycle {
76
- // AbortController for the current in-flight navigation.
77
- // When a new navigation starts, the previous controller is aborted,
78
- // cancelling any in-progress RSC fetch. This provides automatic
79
- // cancellation of stale fetches regardless of Navigation API support.
80
- let currentNavAbort: AbortController | null = null;
123
+ // The single ownership slot. One navigation at a time.
124
+ let current: RenderOwner | null = null;
125
+
126
+ // A handed-off-but-uncommitted owner that `runNavigation`'s finally has
127
+ // already cleared from `current`. Tracked separately so `createNavOwner`
128
+ // can settle it as superseded if a new navigation starts before the
129
+ // commit (P1 from codex on #1127). Cleared by `forgetOlderHandoffs`
130
+ // (commit) or by `createNavOwner` (supersession).
131
+ let pendingCommit: RenderOwner | null = null;
81
132
 
82
133
  let routerPhase: RouterPhase = { phase: 'idle' };
83
134
  const pendingListeners = new Set<(pending: boolean) => void>();
84
135
 
136
+ let navigationSeq = 0;
137
+ let idleTask: (() => void) | null = null;
138
+
85
139
  /**
86
- * The controllers of navigations whose trees have been HANDED TO REACT.
87
- *
88
- * Its response may still be streaming: a destination reveals as soon as
89
- * React can render it, so the tree on screen routinely has Suspense
90
- * boundaries still waiting on later Flight rows. Aborting that response
91
- * rejects those rows, and the rejection surfaces through the tree into
92
- * whatever error boundary the app has — replacing the page the user is
93
- * looking at with an error state, while the successor navigation is still
94
- * in flight (codex on #1004).
95
- *
96
- * So such a navigation's stream is allowed to finish. It is finite and
97
- * already in flight; the alternative is a visible error on the page being
98
- * departed from. Cancelling is still correct for a navigation whose payload
99
- * never reached React at all, which is the case the abort was written for.
100
- *
101
- * **Handed over, not committed.** This is set when the tree is given to
102
- * React, not when React commits it. Marking on commit is a whole React
103
- * commit phase too late: the notification would be `NavigationRoot`'s
104
- * layout effect, and React runs *descendant* layout effects first — so a
105
- * destination that navigates from its own mount layout effect (a redirect
106
- * guard) runs before the mark and its just-committed stream gets torn out
107
- * from under it. `tests/navigation-supersede.test.ts` already pins that
108
- * ordering, and there is no earlier hook short of an extra sibling fiber,
109
- * which would shift every `useId` in the payload (see `server/ssr-wrappers`).
110
- *
111
- * Handing over is the right moment on its own terms, not merely a safe
112
- * over-approximation: from the instant React holds the tree it may commit
113
- * it without asking, so there is no later point at which "not on screen"
114
- * is still knowable from out here. The residue is that a navigation
115
- * superseded in the window between handover and commit keeps streaming a
116
- * payload nobody sees — bandwidth on a finite response, against a visible
117
- * error page the other way (codex on #1004, second round).
118
- *
119
- * A SET, not a single slot. Between a successor's handover and its commit
120
- * React is still showing the previous tree — that is what the transition
121
- * buys — so the previous navigation's stream is still feeding the screen and
122
- * must stay unabortable. Entries are dropped when a later tree actually
123
- * commits (`forgetOlderHandoffs`), which is the moment the trees they fed
124
- * are gone. A single slot made a legitimate handover steal protection from
125
- * a stream still on screen, and let a superseded navigation overwrite the
126
- * winner's entry outright.
140
+ * Whether a handed-off tree has not yet committed. Derived from the slot:
141
+ * true when `current` is handed off but not yet settled.
127
142
  */
128
- const handedOffNavAborts = new Set<AbortController>();
143
+ function hasUncommittedNav(): boolean {
144
+ return (
145
+ pendingCommit !== null || (current !== null && current.handedOff && current.outcome === null)
146
+ );
147
+ }
129
148
 
130
149
  /**
131
150
  * Cancel a navigation's RSC fetch — unless its tree is the one on screen.
132
151
  *
133
152
  * THE only place a navigation controller is aborted. Every path that gives
134
153
  * up on a navigation calls this, so the "is this tree displayed?" question
135
- * is asked once rather than at each site (the same reason `createSpaExits`
136
- * exists for the SPA exits). A new abort path is a call to this, not a copy
137
- * of `controller.abort()`.
154
+ * is asked once rather than at each site. A new abort path is a call to
155
+ * this, not a copy of `controller.abort()`.
138
156
  *
139
- * See `handedOffNavAborts` for why such a navigation keeps its stream.
157
+ * See design/19-client-navigation.md §"A navigation that reached React
158
+ * keeps its stream".
140
159
  */
141
- function abortUnlessHandedOff(controller: AbortController): void {
142
- if (handedOffNavAborts.has(controller)) return;
143
- controller.abort();
160
+ function abortUnlessHandedOff(owner: RenderOwner): void {
161
+ if (owner.handedOff) return;
162
+ owner.fetchAbort.abort();
144
163
  }
145
164
 
146
165
  /**
147
- * Create a new AbortController for a navigation, superseding any
148
- * previous in-flight navigation. Optionally links to an external
149
- * signal (e.g., from the Navigation API's NavigateEvent.signal).
166
+ * Create a new RenderOwner for a navigation, superseding any previous
167
+ * in-flight navigation. Optionally links to an external signal (e.g.,
168
+ * from the Navigation API's NavigateEvent.signal).
150
169
  *
151
170
  * Superseding is one operation with three parts:
152
171
  * 1. Abort the previous navigation's fetch — UNLESS its tree is the one on
153
172
  * screen, in which case tearing the stream down would error the page the
154
- * user is currently looking at. See `handedOffNavAborts`.
155
- * 2. Invalidate its render transition so a response that already
156
- * arrived can't commit a stale tree (NavigationRoot's transId guard).
157
- * This happens either way, so skipping the abort cannot let a stale tree
158
- * displace the successor.
173
+ * user is currently looking at. See `RenderOwner.handedOff`.
174
+ * 2. Settle the previous owner as 'superseded' so its transition detects
175
+ * it lost and never hands a stale tree to React. This replaces both
176
+ * `supersedeNavigationTransitions()` and the transition counter bump.
159
177
  * 3. Resolve its Navigation API deferred — the superseded navigation's
160
178
  * finally block is staleness-guarded (see TIM-1034) and no longer
161
179
  * cleans up after itself, so the browser's native loading state for
162
180
  * the dead navigation is cleared here.
163
181
  */
164
- function createNavAbort(externalSignal?: AbortSignal): AbortController {
165
- if (currentNavAbort) {
166
- abortUnlessHandedOff(currentNavAbort);
167
- supersedeNavigationTransitions();
182
+ function createNavOwner(
183
+ kind: 'navigation' | 'revalidation',
184
+ externalSignal?: AbortSignal
185
+ ): RenderOwner {
186
+ if (current) {
187
+ abortUnlessHandedOff(current);
188
+ current.settle('superseded');
168
189
  deps.completeRouterNavigation?.();
169
190
  }
170
- const controller = new AbortController();
171
- currentNavAbort = controller;
191
+ // A handed-off owner whose runNavigation has already finished but whose
192
+ // tree hasn't committed yet. Settle it so its onCommit fires.
193
+ if (pendingCommit) {
194
+ pendingCommit.settle('superseded');
195
+ pendingCommit = null;
196
+ }
197
+ navigationSeq += 1;
198
+ const owner = createRenderOwner(kind);
199
+ current = owner;
172
200
 
173
201
  // If an external signal is provided (e.g., Navigation API),
174
202
  // forward its abort to our controller.
@@ -182,15 +210,13 @@ export function createNavigationLifecycle(deps: NavigationLifecycleDeps): Naviga
182
210
  // external signal (codex on #1004).
183
211
  if (externalSignal) {
184
212
  if (externalSignal.aborted) {
185
- abortUnlessHandedOff(controller);
213
+ abortUnlessHandedOff(owner);
186
214
  } else {
187
- externalSignal.addEventListener('abort', () => abortUnlessHandedOff(controller), {
188
- once: true,
189
- });
215
+ externalSignal.addEventListener('abort', () => abortUnlessHandedOff(owner), { once: true });
190
216
  }
191
217
  }
192
218
 
193
- return controller;
219
+ return owner;
194
220
  }
195
221
 
196
222
  function setPending(value: boolean, url?: string): void {
@@ -217,65 +243,75 @@ export function createNavigationLifecycle(deps: NavigationLifecycleDeps): Naviga
217
243
 
218
244
  /**
219
245
  * Wrap a navigation in the standard abort/pending/cleanup lifecycle.
220
- * Consolidates the createNavAbort + setPending + staleness-guarded
246
+ * Consolidates the createNavOwner + setPending + staleness-guarded
221
247
  * finally that was duplicated across navigate, refresh, and both
222
248
  * handlePopState paths. AbortErrors are swallowed (not application
223
249
  * errors); all other errors propagate to the caller.
224
250
  */
225
251
  async function runNavigation(
226
252
  url: string,
227
- fn: (navAbort: AbortController) => Promise<void>,
253
+ fn: (owner: RenderOwner) => Promise<void>,
228
254
  externalSignal?: AbortSignal
229
255
  ): Promise<void> {
230
- const navAbort = createNavAbort(externalSignal);
256
+ const owner = createNavOwner('navigation', externalSignal);
231
257
  setPending(true, url);
232
258
  try {
233
- await fn(navAbort);
259
+ await fn(owner);
234
260
  } catch (error) {
235
261
  if (isAbortError(error)) return;
236
262
  throw error;
237
263
  } finally {
238
- if (currentNavAbort === navAbort) {
239
- currentNavAbort = null;
264
+ if (current === owner) {
265
+ current = null;
240
266
  setPending(false);
241
267
  deps.completeRouterNavigation?.();
268
+ flushIdleTask();
242
269
  }
243
270
  }
244
271
  }
245
272
 
273
+ function flushIdleTask(): void {
274
+ if (routerPhase.phase !== 'idle' || hasUncommittedNav() || !idleTask) return;
275
+ const task = idleTask;
276
+ idleTask = null;
277
+ task();
278
+ }
279
+
246
280
  return {
247
- currentNavAbort: () => currentNavAbort,
248
- createNavAbort,
281
+ currentOwner: () => current,
282
+ createNavOwner,
249
283
  runNavigation,
250
284
 
251
285
  // Guarded on still owning the router. A superseded navigation can still
252
286
  // reach here: a prefetch hit answers from `prefetchCache`, whose payload
253
287
  // was fetched under the PREFETCH's controller, so aborting this
254
288
  // navigation's controller does not stop it and `perform()` runs to
255
- // completion. `navigateTransition`'s stale-transition check happens after
289
+ // completion. `navigateTransition`'s stale check happens after
256
290
  // `perform()` returns, so without this the loser would claim ownership
257
291
  // moments before being discarded — and the next navigation would then
258
292
  // abort the winner's still-streaming response, erroring the visible tree
259
293
  // (codex on #1004).
260
- markHandedOff(owner: AbortController): void {
261
- if (currentNavAbort !== owner) return;
262
- handedOffNavAborts.add(owner);
294
+ markHandedOff(owner: RenderOwner): void {
295
+ if (current !== owner) return;
296
+ owner.handedOff = true;
297
+ pendingCommit = owner;
263
298
  },
264
299
 
265
300
  // React has committed THIS tree, so every earlier one is unmounted and
266
- // their streams no longer feed anything on screen. Forget them — they are
267
- // left to finish on their own rather than aborted, since by here they are
268
- // nearly done anyway and the point of the set is never to cut a stream
269
- // that might still be feeding the screen.
270
- //
271
- // Pruning on commit rather than at handover is deliberate: between a
272
- // successor's handover and its commit, React is still showing the
273
- // PREVIOUS tree (that is what the transition buys), so the previous
274
- // navigation's stream is still live on screen and must stay unabortable.
275
- forgetOlderHandoffs(owner: AbortController): void {
276
- for (const controller of handedOffNavAborts) {
277
- if (controller !== owner) handedOffNavAborts.delete(controller);
301
+ // their streams no longer feed anything on screen. The owner's `handedOff`
302
+ // boolean already captured the individual state; settling clears the
303
+ // "uncommitted" window. Unlike the old `handedOffNavAborts` set, there is
304
+ // no set to prune — the single slot means at most one owner is current.
305
+ forgetOlderHandoffs(owner: RenderOwner): void {
306
+ owner.settle('committed');
307
+ if (pendingCommit === owner) {
308
+ pendingCommit = null;
278
309
  }
310
+ flushIdleTask();
311
+ },
312
+
313
+ placeRevalidationOwner(owner: RenderOwner): void {
314
+ current = owner;
279
315
  },
280
316
 
281
317
  isPending: () => routerPhase.phase === 'navigating',
@@ -284,5 +320,35 @@ export function createNavigationLifecycle(deps: NavigationLifecycleDeps): Naviga
284
320
  pendingListeners.add(listener);
285
321
  return () => pendingListeners.delete(listener);
286
322
  },
323
+
324
+ epoch(): NavigationEpoch {
325
+ return {
326
+ seq: navigationSeq,
327
+ idle: routerPhase.phase === 'idle' && !hasUncommittedNav(),
328
+ };
329
+ },
330
+ isEpochCurrent(e: NavigationEpoch): boolean {
331
+ return (
332
+ e.idle && routerPhase.phase === 'idle' && !hasUncommittedNav() && navigationSeq === e.seq
333
+ );
334
+ },
335
+ runWhenIdle(task: () => void): void {
336
+ if (routerPhase.phase === 'idle' && !hasUncommittedNav()) {
337
+ task();
338
+ return;
339
+ }
340
+ idleTask = task;
341
+ },
342
+
343
+ settleHandoffs(): void {
344
+ if (pendingCommit) {
345
+ pendingCommit.settle('superseded');
346
+ pendingCommit = null;
347
+ }
348
+ if (current !== null && current.handedOff && current.outcome === null) {
349
+ current.settle('superseded');
350
+ }
351
+ flushIdleTask();
352
+ },
287
353
  };
288
354
  }