@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
@@ -15,6 +15,58 @@ import type { HistoryStack } from './history.ts';
15
15
  import type { SegmentInfo, StateTree } from '../shared/segment-info.ts';
16
16
  import type { ParamsSource } from '../shared/payload-root.ts';
17
17
  import { setNavigationState, type NavigationState } from './navigation-context.ts';
18
+ import {
19
+ statusPageRendered,
20
+ _setStatusPageRendered,
21
+ onStatusPageRendered,
22
+ _setOnStatusPageRendered,
23
+ } from './state.ts';
24
+
25
+ // ─── What a status page teaches the cache ─────────────────────────────────
26
+ //
27
+ // A status page replaces a subtree: a deny page lands at the segment owning
28
+ // the matched file and nothing below it mounts; an `error.tsx` fallback
29
+ // discards everything under the boundary that rendered it. `X-Timber-Segments`
30
+ // nonetheless describes the whole chain the payload was built against — it
31
+ // must, because a partial navigation picks its merge point out of it — so the
32
+ // cache has to be told not to learn from such a tree.
33
+ //
34
+ // The HTTP status used to say so, and could not be trusted to: an RSC payload
35
+ // response commits one macrotask after its first chunk, and a nested
36
+ // component that denies after I/O puts its row in the stream after the
37
+ // status line has gone out (TIM-1489). The only thing that reliably knows a
38
+ // status page rendered is the status page itself, and the only moment that
39
+ // matters is the commit that puts it on screen — so every status page the
40
+ // framework produces is wrapped in `StatusPageMarker`, whose layout effect
41
+ // reports on each commit that renders it. Layout effects run child-before-
42
+ // parent, so the report lands before `NavigationRoot`'s publish of that
43
+ // same commit, and never for a render React discards. Without Suspense
44
+ // around the row, the transition cannot commit until the row has arrived, so
45
+ // this covers the delayed nested case too, which no status ever could.
46
+ //
47
+ // The report also clears the cache on the spot, because not every commit
48
+ // has a navigation to consume it: a deny row arriving inside a Suspense
49
+ // boundary after its navigation published, or a status document being
50
+ // hydrated, would otherwise leave a tree in the cache that is no longer on
51
+ // screen. Consuming the flag at commit is what keeps the same navigation from
52
+ // re-seeding it a moment later. Both are over-approximations — a page-level
53
+ // deny leaves every layout standing — at the cost of one full render on the
54
+ // next navigation.
55
+ //
56
+ // Slot boundaries do not report: a slot degrading is the page staying, and
57
+ // the chain it mounted is the chain the header describes. See TIM-1356,
58
+ // TIM-1495, design/04-authorization.md §"Where a Deny Page Renders".
59
+
60
+ /**
61
+ * Record that a status page for the main route is on screen. Called from
62
+ * `StatusPageMarker`'s layout effect; consumed by the commit of the
63
+ * navigation whose tree it belongs to, and acted on immediately for the
64
+ * commits that have none.
65
+ */
66
+ export function reportStatusPageRendered(): void {
67
+ _setStatusPageRendered(true);
68
+ onStatusPageRendered?.();
69
+ }
18
70
 
19
71
  /** Everything a commit needs to describe the page it makes current. */
20
72
  export interface NavigationCommitInput {
@@ -38,22 +90,6 @@ export interface NavigationCommitInput {
38
90
  /** When true, clear the segment cache if segmentInfo is absent
39
91
  * (popstate replay for entries without layout metadata). */
40
92
  clearSegmentCacheOnEmpty?: boolean;
41
- /**
42
- * HTTP status of the response this navigation rendered, when it came from a
43
- * fetch. Omitted by replays, which re-publish a tree already judged.
44
- *
45
- * An error response rendered a status page, and a status page replaces a
46
- * subtree: the deny page lands at the segment owning the matched file and
47
- * nothing below it mounts. `segmentInfo` nonetheless describes the whole
48
- * chain the payload was built against — it must, because the merge point is
49
- * chosen out of it — so the cache has to be told not to learn from this one.
50
- *
51
- * Enforced here rather than at the call sites because there are three of
52
- * them (`performNavigationFetch`, `refresh()`, and uncached traversal, the
53
- * last two through `fetchCommitAndRender`) and only one had the check.
54
- * See TIM-1356.
55
- */
56
- status?: number;
57
93
  /**
58
94
  * Layouts the server skipped on this response (`X-Timber-Skipped-Segments`),
59
95
  * when it came from a fetch. Omitted by replays and revalidation.
@@ -65,9 +101,11 @@ export interface NavigationCommitInput {
65
101
  * replayed later, shows whatever the client holds *then*, not what was on
66
102
  * screen when the entry committed. Such entries store a null payload and
67
103
  * traversals to them fetch instead. Decided here, not at the fetch call
68
- * sites, for the same reason as `status`: `performNavigationFetch` had the
69
- * check and `fetchCommitAndRender` did not, so a back/forward fetch that
70
- * was answered with skips stored the holes (TIM-1432).
104
+ * sites, because there are three of those (`performNavigationFetch`,
105
+ * `refresh()`, and uncached traversal) and a rule applied at one of them
106
+ * holds by accident: `performNavigationFetch` had the check and
107
+ * `fetchCommitAndRender` did not, so a back/forward fetch that was
108
+ * answered with skips stored the holes (TIM-1432).
71
109
  */
72
110
  skippedSegments?: string[] | null;
73
111
  }
@@ -93,7 +131,7 @@ function hasSkippedSlot(segmentInfo: SegmentInfo[] | null | undefined): boolean
93
131
  * The metadata a history entry keeps. `skipped` describes the *response*
94
132
  * — "this slot's content was omitted" — and the entry stores no such
95
133
  * payload (it stores null). Left on the entry, the flag outlives the
96
- * response: `applyRevalidation()` reuses the entry's metadata beside a full
134
+ * response: `applyActionResult()` reuses the entry's metadata beside a full
97
135
  * re-render and would have that judged non-replayable too.
98
136
  */
99
137
  function storedSegmentInfo(segmentInfo: SegmentInfo[] | null | undefined) {
@@ -145,6 +183,10 @@ export function createNavigationCommitter(deps: {
145
183
  }): NavigationCommitter {
146
184
  const { segmentCache, historyStack } = deps;
147
185
 
186
+ // See `reportStatusPageRendered`: a status page that commits with no
187
+ // navigation to consume the report still empties the cache.
188
+ _setOnStatusPageRendered(() => segmentCache.clear());
189
+
148
190
  /**
149
191
  * Update the segment cache from server-provided segment metadata.
150
192
  *
@@ -211,18 +253,28 @@ export function createNavigationCommitter(deps: {
211
253
  */
212
254
  prepareNavigation(url: string, opts: NavigationCommitInput): PreparedNavigation {
213
255
  const navState = deriveNavigationState(url);
214
-
215
- // A tree rendered by an error response is not one to cache — see
216
- // `status` on NavigationCommitInput. Normalized here, once, so no fetch
217
- // caller can forget it; the history entry records the same thing, or a
218
- // traversal back to this URL would replay the metadata this drops.
219
- const cacheable = opts.status === undefined || opts.status < 400;
220
- const segmentInfo = cacheable ? opts.segmentInfo : [];
221
256
  const payload = isReplayable(opts) ? opts.payload : null;
222
257
 
258
+ // The tree this navigation renders has not rendered yet. Whatever a
259
+ // boundary reported before now belongs to a commit that had no
260
+ // navigation to consume it — a hydration, a row that arrived inside a
261
+ // Suspense boundary after its navigation had published — and must not
262
+ // be charged to this one.
263
+ _setStatusPageRendered(false);
264
+
223
265
  return {
224
266
  navState,
225
267
  commit() {
268
+ // A tree in which a boundary rendered a status page is not one to
269
+ // cache — see the module note. Read at commit rather than at
270
+ // prepare because that is when the boundary has spoken: the row it
271
+ // renders may not have existed when the response headers were read.
272
+ // The history entry records the same thing, or a traversal back to
273
+ // this URL would replay the metadata this drops.
274
+ const cacheable = !statusPageRendered;
275
+ _setStatusPageRendered(false);
276
+ const segmentInfo = cacheable ? opts.segmentInfo : [];
277
+
226
278
  if (segmentInfo && segmentInfo.length > 0) {
227
279
  updateSegmentCache(segmentInfo);
228
280
  } else if (segmentInfo?.length === 0 || opts.clearSegmentCacheOnEmpty) {
@@ -94,114 +94,95 @@ export interface TransitionResult<E = ReactNode> {
94
94
  commit: () => void;
95
95
  }
96
96
 
97
- // ─── Navigation Transition Counter ──────────────────────────────
98
- // Monotonically increasing counter that increments each time
99
- // navigateTransition() is called. Used to detect stale transitions:
100
- // if a newer transition started while the current one's perform()
101
- // was in flight, the current transition is stale and should reject.
102
- //
103
- // Separate from the link-pending navId (which only increments on
104
- // link clicks). This counter covers all navigation types: link clicks,
105
- // programmatic navigate(), refresh(), and handlePopState().
106
- //
107
- // Uses globalThis for singleton guarantee across chunks — same pattern
108
- // as NavigationContext and the link pending store.
97
+ /** What happened to this navigation's tree. */
98
+ export type CommitOutcome = 'committed' | 'superseded' | 'failed';
109
99
 
110
- const NAV_TRANSITION_KEY = Symbol.for('__timber_nav_transition_counter');
100
+ // ─── RenderOwner ────────────────────────────────────────────────
111
101
 
112
102
  /**
113
- * `waiters` are woken on every bump so an in-flight navigation can stop
114
- * waiting on its own payload the moment it is superseded — see
115
- * `settleOnDecodeOrSupersession`.
116
- */
117
- interface TransitionCounter {
118
- id: number;
119
- waiters: Set<() => void>;
120
- }
121
-
122
- function getTransitionCounter(): TransitionCounter {
123
- const g = globalThis as Record<symbol, unknown>;
124
- const existing = g[NAV_TRANSITION_KEY] as Partial<TransitionCounter> | undefined;
125
- if (!existing) {
126
- const created: TransitionCounter = { id: 0, waiters: new Set() };
127
- g[NAV_TRANSITION_KEY] = created;
128
- return created;
129
- }
130
- // A duplicated copy of this module may have created the singleton before
131
- // `waiters` existed. The object is shared across chunks, so fill it in
132
- // rather than replacing it — replacing would strand the other copy's id.
133
- existing.waiters ??= new Set();
134
- return existing as TransitionCounter;
135
- }
136
-
137
- /** Bump the counter and wake everything waiting on an older transition. */
138
- function bumpTransitionCounter(): number {
139
- const counter = getTransitionCounter();
140
- counter.id += 1;
141
- for (const wake of [...counter.waiters]) wake();
142
- return counter.id;
143
- }
144
-
145
- /**
146
- * Invalidate all in-flight navigation transitions. Any navigateTransition()
147
- * call whose perform() has not yet committed will reject with AbortError
148
- * instead of committing its element.
103
+ * A per-render identity that replaces the transition counter, the
104
+ * `handedOffNavAborts` set, and the `AbortController`-as-owner pattern.
105
+ *
106
+ * The navigation lifecycle holds one slot (`current: RenderOwner | null`).
107
+ * All supersession is "take the slot":
108
+ *
109
+ * - Navigation takes unconditionally (previous owner gets settled + aborted
110
+ * if !handedOff).
111
+ * - Revalidation takes only if empty (epoch check).
112
+ * - Handover gate before root.render checks `current === owner`.
113
+ * - `settle()` is called from NavigationRoot publish (committed), slot
114
+ * takeover (superseded), or rejection (failed).
149
115
  *
150
- * Called by the router when a render supersedes in-flight navigations
151
- * WITHOUT going through navigateTransition() — the cached popstate replay
152
- * renders directly, which doesn't bump the counter, so a stale forward
153
- * navigation's render would otherwise pass the `counter.id !== transId`
154
- * guard and commit the forward page over the replayed back page (TIM-1022).
116
+ * `settle()` records the outcome and resolves `displaced` for non-committed
117
+ * outcomes. The `outcome` field is first-wins with one exception:
118
+ * `settle('superseded')` overrides a prior `'committed'`, because a
119
+ * committed-but-still-decoding navigation can be displaced by a successor
120
+ * and the post-decode check must throw rather than letting the old
121
+ * navigation continue.
122
+ *
123
+ * TIM-1481: replaces counter + waiters + bumpTransitionCounter +
124
+ * supersedeNavigationTransitions + handedOffNavAborts + globalThis singleton.
155
125
  */
156
- export function supersedeNavigationTransitions(): void {
157
- bumpTransitionCounter();
126
+ export interface RenderOwner {
127
+ readonly kind: 'navigation' | 'revalidation';
128
+ readonly fetchAbort: AbortController;
129
+ handedOff: boolean;
130
+ /** Sync read for the post-perform() check. null until settled. */
131
+ outcome: CommitOutcome | null;
132
+ /**
133
+ * Resolves when this owner is displaced — a successor supersedes it or it
134
+ * fails. Does NOT resolve on commit: a committed navigation still waits
135
+ * for its decode to finish. Used to stop waiting on a decode that is no
136
+ * longer this navigation's business.
137
+ */
138
+ readonly displaced: Promise<void>;
139
+ /**
140
+ * Record this owner's outcome and, for non-committed outcomes, resolve
141
+ * `displaced`. First-wins with one exception: `settle('superseded')`
142
+ * overrides a prior `'committed'`.
143
+ */
144
+ settle: (outcome: CommitOutcome) => void;
158
145
  }
159
146
 
160
147
  /**
161
- * Wait for the payload to finish decoding, OR for this transition to be
162
- * superseded — whichever happens first.
163
- *
164
- * A superseded navigation must stop waiting on its own stream. The stream is
165
- * deliberately NOT aborted once its tree has been handed to React (the tree
166
- * may be on screen with boundaries still feeding from it — see
167
- * `handedOffNavAbort` in `client/router-lifecycle.ts`), so there is nothing
168
- * left to make `decodePromise` settle promptly. Awaiting it bare would keep
169
- * the loser's `router.navigate()` promise pending for the rest of the stream
170
- * — and forever if it stalls — which is what `<Link>`'s `isPending` is timed
171
- * against, so the losing link would sit spinning while the winner loaded
172
- * (codex on #1004).
173
- *
174
- * A decode *failure* still propagates: it is a real error for this
175
- * navigation, and the caller's recovery is timed against it.
148
+ * Create a fresh RenderOwner. Exported so tests that call
149
+ * `navigateTransition` directly can construct one without a router.
176
150
  */
177
- function settleOnDecodeOrSupersession(
178
- decodePromise: Promise<void>,
179
- counter: TransitionCounter,
180
- transId: number
181
- ): Promise<void> {
182
- if (counter.id !== transId) return Promise.resolve();
183
- return new Promise<void>((resolve, reject) => {
184
- const stopWaiting = (): void => {
185
- counter.waiters.delete(wake);
186
- };
187
- const wake = (): void => {
188
- if (counter.id !== transId) {
189
- stopWaiting();
190
- resolve();
151
+ export function createRenderOwner(kind: 'navigation' | 'revalidation'): RenderOwner {
152
+ let resolveDisplaced!: () => void;
153
+ const displaced = new Promise<void>((r) => {
154
+ resolveDisplaced = r;
155
+ });
156
+
157
+ const owner: RenderOwner = {
158
+ kind,
159
+ fetchAbort: new AbortController(),
160
+ handedOff: false,
161
+ outcome: null,
162
+ displaced,
163
+ settle(outcome: CommitOutcome): void {
164
+ if (owner.outcome === null) {
165
+ owner.outcome = outcome;
166
+ } else if (outcome === 'superseded' && owner.outcome === 'committed') {
167
+ // A committed-but-still-decoding navigation can be displaced by a
168
+ // successor. Override so the post-decode check throws AbortError
169
+ // rather than letting the old navigation continue with scroll
170
+ // restoration while a newer navigation is active.
171
+ owner.outcome = 'superseded';
191
172
  }
192
- };
193
- counter.waiters.add(wake);
194
- decodePromise.then(
195
- () => {
196
- stopWaiting();
197
- resolve();
198
- },
199
- (error: unknown) => {
200
- stopWaiting();
201
- reject(error instanceof Error ? error : new Error(String(error)));
173
+ // Resolve `displaced` for any non-committed outcome, even if the
174
+ // outcome field was already set. A committed-but-still-decoding
175
+ // navigation must be woken when a successor supersedes it — the old
176
+ // counter-based waiter did this because the counter bumped regardless
177
+ // of commit state. A commit does NOT resolve displaced: that would
178
+ // end the decode wait early, clearing pending state before the Flight
179
+ // stream finishes.
180
+ if (outcome !== 'committed') {
181
+ resolveDisplaced();
202
182
  }
203
- );
204
- });
183
+ },
184
+ };
185
+ return owner;
205
186
  }
206
187
 
207
188
  // ─── navigateTransition ─────────────────────────────────────────
@@ -235,44 +216,112 @@ function settleOnDecodeOrSupersession(
235
216
  * one's `perform()` was in flight — the stale tree is never handed to React
236
217
  * and its `commit` never runs (TIM-629, TIM-1301).
237
218
  *
219
+ * `onCommit` is the caller's hook on the *commit*, which the returned promise
220
+ * deliberately does not wait for. It runs exactly once: when React commits
221
+ * this navigation's tree, or — so nothing waits on a commit that will never
222
+ * come — when the navigation is abandoned instead: superseded by a newer
223
+ * transition (before or after its tree was handed to React) or failed. It is
224
+ * how `<Link>` keeps `isPending` up until the destination is on screen
225
+ * without scheduling anything against React's lanes (TIM-1418).
226
+ *
227
+ * `owner` is the `RenderOwner` for this render (TIM-1481). Supersession is
228
+ * detected via `owner.outcome` (sync) and `owner.displaced` (async).
229
+ * No module-level state; no globalThis singleton.
230
+ *
238
231
  * Used for: navigate(), refresh(), popstate with fetch.
239
232
  */
240
233
  export function navigateTransition(
234
+ owner: RenderOwner,
241
235
  perform: () => Promise<TransitionResult>,
242
- render: NavigationRender
236
+ render: NavigationRender,
237
+ onCommit?: (outcome: CommitOutcome) => void
243
238
  ): Promise<void> {
244
- // Increment the transition counter SYNCHRONOUSLY (before any await). Each
245
- // call gets a unique transId; the counter is the same globalThis
246
- // singleton, so a newer call always has a higher id.
247
- const counter = getTransitionCounter();
248
- const transId = bumpTransitionCounter();
249
-
250
239
  const superseded = () => new DOMException('Navigation superseded', 'AbortError');
251
240
 
241
+ // `onCommit`, exactly once, on whichever comes first: the commit, a
242
+ // supersession, or a failure.
243
+ let commitSettled = onCommit === undefined;
244
+ const settleCommit = (outcome: CommitOutcome): void => {
245
+ if (commitSettled) return;
246
+ commitSettled = true;
247
+ onCommit?.(outcome);
248
+ };
249
+
250
+ // Watch for displacement from outside (a successor taking the slot).
251
+ if (!commitSettled) {
252
+ void owner.displaced.then(() => {
253
+ if (owner.outcome === 'superseded') settleCommit('superseded');
254
+ });
255
+ }
256
+
252
257
  return (async () => {
253
- const { element, decodePromise, commit } = await perform();
254
- if (counter.id !== transId) {
255
- decodePromise?.catch(() => {});
256
- throw superseded();
258
+ try {
259
+ const { element, decodePromise, commit } = await perform();
260
+ if (owner.outcome !== null) {
261
+ decodePromise?.catch(() => {});
262
+ throw superseded();
263
+ }
264
+ // Hand the tree over with its commit attached. NavigationRoot runs it if
265
+ // and when React commits this tree, so a navigation that is superseded
266
+ // while its payload streams publishes nothing (TIM-1301).
267
+ //
268
+ // This is THE update that must not hide the departing page (TIM-1306);
269
+ // `render` is the synchronous transition around `root.render`.
270
+ render(element, () => {
271
+ try {
272
+ commit();
273
+ } finally {
274
+ settleCommit('committed');
275
+ }
276
+ });
277
+ // React may commit the tree before this settles — that is the point:
278
+ // the destination reveals as React is able to render it
279
+ // rather than waiting for the whole Flight stream. The await is here so
280
+ // the promise this function hands back still means "the payload is
281
+ // decoded", which is what the router's scroll restoration, the
282
+ // Navigation API deferred and `<Link>`'s `isPending` are timed against.
283
+ //
284
+ // ...unless this navigation loses first, in which case it stops waiting
285
+ // on a stream that is no longer its business.
286
+ if (decodePromise) {
287
+ await settleOnDecodeOrDisplacement(decodePromise, owner);
288
+ }
289
+ if (owner.outcome !== null && owner.outcome !== 'committed') {
290
+ throw superseded();
291
+ }
292
+ } catch (error) {
293
+ settleCommit('failed');
294
+ throw error;
257
295
  }
258
- // Hand the tree over with its commit attached. NavigationRoot runs it if
259
- // and when React commits this tree, so a navigation that is superseded
260
- // while its payload streams publishes nothing (TIM-1301).
261
- //
262
- // This is THE update that must not hide the departing page (TIM-1306);
263
- // `render` is the synchronous transition around `root.render`.
264
- render(element, commit);
265
- // React may commit the tree before this settles — that is the point:
266
- // the destination reveals as React is able to render it
267
- // rather than waiting for the whole Flight stream. The await is here so
268
- // the promise this function hands back still means "the payload is
269
- // decoded", which is what the router's scroll restoration, the
270
- // Navigation API deferred and `<Link>`'s `isPending` are timed against.
271
- //
272
- // ...unless this navigation loses first, in which case it stops waiting
273
- // on a stream that is no longer its business. See
274
- // `settleOnDecodeOrSupersession`.
275
- if (decodePromise) await settleOnDecodeOrSupersession(decodePromise, counter, transId);
276
- if (counter.id !== transId) throw superseded();
277
296
  })();
278
297
  }
298
+
299
+ /**
300
+ * Wait for the payload to finish decoding, OR for this owner to be
301
+ * displaced — whichever happens first.
302
+ *
303
+ * A superseded navigation must stop waiting on its own stream. The stream is
304
+ * deliberately NOT aborted once its tree has been handed to React (the tree
305
+ * may be on screen with boundaries still feeding from it — see
306
+ * `RenderOwner.handedOff`), so there is nothing left to make `decodePromise`
307
+ * settle promptly. Awaiting it bare would keep the loser's
308
+ * `router.navigate()` promise pending for the rest of the stream — and
309
+ * forever if it stalls — which is what `<Link>`'s `isPending` is timed
310
+ * against, so the losing link would sit spinning while the winner loaded
311
+ * (codex on #1004).
312
+ *
313
+ * A decode *failure* still propagates: it is a real error for this
314
+ * navigation, and the caller's recovery is timed against it.
315
+ */
316
+ function settleOnDecodeOrDisplacement(
317
+ decodePromise: Promise<void>,
318
+ owner: RenderOwner
319
+ ): Promise<void> {
320
+ // Only bail early for displacement (superseded/failed), not for commit.
321
+ // A committed tree still needs to wait for its decode to finish — pending
322
+ // state and the Link indicator are timed against it.
323
+ if (owner.outcome !== null && owner.outcome !== 'committed') {
324
+ return Promise.resolve();
325
+ }
326
+ return Promise.race([decodePromise, owner.displaced]);
327
+ }
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { setHardNavigating } from './navigation-root.tsx';
13
+ import type { RenderOwner } from './navigation-transition.ts';
13
14
  import { RedirectError, ServerErrorResponse, NonRscResponse } from './rsc-fetch.ts';
14
15
  import { recordSkew } from './router-skew.ts';
15
16
 
@@ -84,15 +85,15 @@ export function leaveSpa(url: string, fromUrl: string): Promise<never> {
84
85
  }
85
86
 
86
87
  export interface SpaExitDeps {
87
- /** The controller that owns the router right now, or null when idle. */
88
- currentNavAbort: () => AbortController | null;
88
+ /** The owner that holds the router right now, or null when idle. */
89
+ currentOwner: () => RenderOwner | null;
89
90
  /** Supersede whatever is in flight and take ownership. */
90
91
  supersede: () => void;
91
92
  }
92
93
 
93
94
  export interface SpaExits {
94
95
  /**
95
- * Leave the SPA — but only if `navAbort` still owns the router. Every
96
+ * Leave the SPA — but only if `owner` still owns the router. Every
96
97
  * failure a navigation can end on (a server error, a non-RSC response, a
97
98
  * version skew) can surface long after the user has clicked something else:
98
99
  * a fetch that was never cancellable, a Flight thenable that rejects late.
@@ -104,7 +105,7 @@ export interface SpaExits {
104
105
  * `finally` clears the current controller, so an ownership check downstream
105
106
  * of it compares against `null` and never leaves at all.
106
107
  */
107
- leaveSpaIfOwned: (navAbort: AbortController, url: string, fromUrl: string) => Promise<void>;
108
+ leaveSpaIfOwned: (owner: RenderOwner, url: string, fromUrl: string) => Promise<void>;
108
109
 
109
110
  /**
110
111
  * Leave the SPA from a path that never entered `runNavigation()` — the
@@ -127,13 +128,9 @@ export interface SpaExits {
127
128
  * together because every caller of `leaveSpa()` needs one or the other, and
128
129
  * an unguarded call is the bug (TIM-1275, TIM-1276).
129
130
  */
130
- export function createSpaExits({ currentNavAbort, supersede }: SpaExitDeps): SpaExits {
131
- async function leaveSpaIfOwned(
132
- navAbort: AbortController,
133
- url: string,
134
- fromUrl: string
135
- ): Promise<void> {
136
- if (currentNavAbort() !== navAbort) return;
131
+ export function createSpaExits({ currentOwner, supersede }: SpaExitDeps): SpaExits {
132
+ async function leaveSpaIfOwned(owner: RenderOwner, url: string, fromUrl: string): Promise<void> {
133
+ if (currentOwner() !== owner) return;
137
134
  await leaveSpa(url, fromUrl);
138
135
  }
139
136
 
@@ -146,8 +143,8 @@ export function createSpaExits({ currentNavAbort, supersede }: SpaExitDeps): Spa
146
143
  }
147
144
 
148
145
  export interface NavigationRecoveryDeps {
149
- /** The controller that owns the router right now, or null when idle. */
150
- currentNavAbort: () => AbortController | null;
146
+ /** The owner that holds the router right now, or null when idle. */
147
+ currentOwner: () => RenderOwner | null;
151
148
  leaveSpaIfOwned: SpaExits['leaveSpaIfOwned'];
152
149
  /** Replace the current entry with `url` — the router's own `navigate()`. */
153
150
  navigate: (url: string) => Promise<void>;
@@ -176,20 +173,20 @@ export interface NavigationRecoveryDeps {
176
173
  * {@link SpaExits.leaveSpaIfOwned}.
177
174
  */
178
175
  export function createNavigationRecovery({
179
- currentNavAbort,
176
+ currentOwner,
180
177
  leaveSpaIfOwned,
181
178
  navigate,
182
179
  }: NavigationRecoveryDeps) {
183
180
  return async function recoverFromNavigationError(
184
181
  error: unknown,
185
- navAbort: AbortController,
182
+ owner: RenderOwner,
186
183
  url: string,
187
184
  fromUrl: string
188
185
  ): Promise<boolean> {
189
186
  if (error instanceof RedirectError) {
190
187
  // Same ownership rule as leaving the SPA: a superseded navigation must
191
188
  // not steer the document to the destination it was abandoned for.
192
- if (currentNavAbort() !== navAbort) return true;
189
+ if (currentOwner() !== owner) return true;
193
190
  await navigate(error.redirectUrl);
194
191
  return true;
195
192
  }
@@ -202,7 +199,7 @@ export function createNavigationRecovery({
202
199
  error instanceof NonRscResponse ||
203
200
  recordSkew(error)
204
201
  ) {
205
- await leaveSpaIfOwned(navAbort, url, fromUrl);
202
+ await leaveSpaIfOwned(owner, url, fromUrl);
206
203
  return true;
207
204
  }
208
205
  return false;