@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
@@ -6,7 +6,7 @@
6
6
  * that makes the page current, and hand the result over inside a transition.
7
7
  *
8
8
  * `router.ts` keeps the *operations* — `navigate`, `refresh`, `handlePopState`,
9
- * `prefetch`, `applyRevalidation` — and each of them is a call into here. The
9
+ * `prefetch`, `applyActionResult` — and each of them is a call into here. The
10
10
  * split is the same one `router-effects.ts` and `router-lifecycle.ts` made
11
11
  * (design/18-build-system.md §"No file >500 lines").
12
12
  *
@@ -22,6 +22,7 @@ import type { StateTree } from '../shared/segment-info.ts';
22
22
  import type { NavigationState } from './navigation-context.ts';
23
23
  import { isPartialNavigation, type NavigationCommitter } from './navigation-commit.ts';
24
24
  import type { RouterDeps } from './router-types.ts';
25
+ import type { CommitOutcome, RenderOwner } from './navigation-transition.ts';
25
26
  import { recordSkew } from './router-skew.ts';
26
27
  import { SingleflightTimeoutError } from '../cache/singleflight.ts';
27
28
 
@@ -64,9 +65,9 @@ export interface NavigationPipelineDeps extends Pick<
64
65
  deps: RouterDeps;
65
66
  prefetchCache: PrefetchCache;
66
67
  /** Record that this navigation's tree reached React (router-lifecycle.ts). */
67
- markHandedOff: (owner: AbortController) => void;
68
+ markHandedOff: (owner: RenderOwner) => void;
68
69
  /** React committed this navigation's tree (router-lifecycle.ts). */
69
- forgetOlderHandoffs: (owner: AbortController) => void;
70
+ forgetOlderHandoffs: (owner: RenderOwner) => void;
70
71
  }
71
72
 
72
73
  export interface NavigationPipeline {
@@ -78,8 +79,10 @@ export interface NavigationPipeline {
78
79
  /** Hand a payload to React inside a transition, or render it directly in tests. */
79
80
  renderViaTransition: (
80
81
  url: string,
81
- owner: AbortController,
82
- perform: () => Promise<NavigationPayload>
82
+ owner: RenderOwner,
83
+ perform: () => Promise<NavigationPayload>,
84
+ /** See `NavigationOptions.onCommit`. */
85
+ onCommit?: (outcome: CommitOutcome) => void
83
86
  ) => Promise<void>;
84
87
  /** Render a decoded payload into the DOM if a renderer is available. */
85
88
  renderPayload: (
@@ -222,79 +225,96 @@ export function createNavigationPipeline({
222
225
  */
223
226
  async function renderViaTransition(
224
227
  url: string,
225
- owner: AbortController,
226
- perform: () => Promise<NavigationPayload>
228
+ owner: RenderOwner,
229
+ perform: () => Promise<NavigationPayload>,
230
+ onCommit?: (outcome: CommitOutcome) => void
227
231
  ): Promise<void> {
228
232
  // Record that THIS navigation's payload has reached React, at the moment
229
233
  // the tree is built and handed back for `navigateTransition` to give to
230
234
  // React — NOT on commit, which is a whole commit phase too late. See
231
- // `handedOffNavAborts` in router-lifecycle.ts.
235
+ // `RenderOwner.handedOff` in router-lifecycle.ts.
232
236
  const handOff = (): void => markHandedOff(owner);
233
237
 
234
- // Drop the earlier handoffs once React has committed this tree — the
235
- // trees they were feeding are gone. Wrapped around the commit thunk so it
236
- // runs on React's commit and not at handover; see `forgetOlderHandoffs`.
238
+ // Settle the owner as committed once React has committed this tree.
239
+ // Wrapped around the commit thunk so it runs on React's commit and not
240
+ // at handover; see `forgetOlderHandoffs`.
237
241
  const commitAndForget = (commit: () => void) => (): void => {
238
242
  forgetOlderHandoffs(owner);
239
243
  commit();
240
244
  };
241
245
 
242
246
  if (deps.navigateTransition) {
243
- await deps.navigateTransition(url, async (wrapPayload) => {
244
- const result = await perform();
245
- // Await the payload's *root row* — the same thing React would suspend
246
- // on to render the tree, and the first thing Flight emits. The
247
- // previous UI stays on screen while this resolves: nothing has been
248
- // handed to React yet, and when it is, NavigationRoot hands it over
249
- // inside a transition so a tree that is still streaming does not
250
- // displace the departing page (TIM-1306).
251
- //
252
- // Reading them here rather than with `use()` above the tree is what
253
- // keeps a decode failure contained: `result.params` never rejects, so
254
- // the rejection reaches React only through the tree, inside whatever
255
- // error boundary the app put around it (TIM-1297).
256
- const params = await result.params;
247
+ await deps.navigateTransition(
248
+ url,
249
+ owner,
250
+ async (wrapPayload) => {
251
+ const result = await perform();
252
+ // Await the payload's *root row* — the same thing React would suspend
253
+ // on to render the tree, and the first thing Flight emits. The
254
+ // previous UI stays on screen while this resolves: nothing has been
255
+ // handed to React yet, and when it is, NavigationRoot hands it over
256
+ // inside a transition so a tree that is still streaming does not
257
+ // displace the departing page (TIM-1306).
258
+ //
259
+ // Reading them here rather than with `use()` above the tree is what
260
+ // keeps a decode failure contained: `result.params` never rejects, so
261
+ // the rejection reaches React only through the tree, inside whatever
262
+ // error boundary the app put around it (TIM-1297).
263
+ const params = await result.params;
257
264
 
258
- if (isPartialNavigation(result.skippedSegments)) {
259
- const segmentUpdates = buildSegmentUpdates(result);
265
+ if (isPartialNavigation(result.skippedSegments)) {
266
+ const segmentUpdates = buildSegmentUpdates(result);
260
267
 
261
- // Re-wrap the CURRENT element with new context values.
262
- // SegmentOutlets read updates from SegmentUpdateContext;
263
- // NavigationProvider gets the new pathname; the params published
264
- // above the retained tree are the *incoming* route's.
265
- const element = wrapPayload(
266
- deps._getCurrentPayload?.() ?? result.payload,
267
- result.navState,
268
- params,
269
- segmentUpdates
270
- );
268
+ // Re-wrap the CURRENT element with new context values.
269
+ // SegmentOutlets read updates from SegmentUpdateContext;
270
+ // NavigationProvider gets the new pathname; the params published
271
+ // above the retained tree are the *incoming* route's.
272
+ const element = wrapPayload(
273
+ deps._getCurrentPayload?.() ?? result.payload,
274
+ result.navState,
275
+ params,
276
+ segmentUpdates
277
+ );
278
+ handOff();
279
+ return {
280
+ element,
281
+ decodePromise: observeSkew(result.decodePromise),
282
+ commit: commitAndForget(result.commit),
283
+ };
284
+ }
285
+
286
+ // Full navigation — empty updates, render the new tree.
287
+ const element = wrapPayload(result.payload, result.navState, params);
271
288
  handOff();
272
289
  return {
273
290
  element,
274
291
  decodePromise: observeSkew(result.decodePromise),
275
292
  commit: commitAndForget(result.commit),
276
293
  };
277
- }
278
-
279
- // Full navigation — empty updates, render the new tree.
280
- const element = wrapPayload(result.payload, result.navState, params);
281
- handOff();
282
- return {
283
- element,
284
- decodePromise: observeSkew(result.decodePromise),
285
- commit: commitAndForget(result.commit),
286
- };
287
- });
294
+ },
295
+ onCommit
296
+ );
288
297
  return;
289
298
  }
290
- // Fallback: no transition (tests, no React tree)
291
- const result = await perform();
292
- handOff();
293
- const commit = commitAndForget(result.commit);
294
- if (isPartialNavigation(result.skippedSegments)) {
295
- commit();
296
- } else {
297
- renderPayload(result.payload, result.navState, await result.params, commit);
299
+ // Fallback: no transition (tests, no React tree). The commit is
300
+ // synchronous here, so the outcome is always 'committed' or 'failed' —
301
+ // no transition counter to supersede. The hook fires on the way out,
302
+ // on failure too, so it still fires exactly once.
303
+ let fallbackOutcome: CommitOutcome = 'committed';
304
+ try {
305
+ const result = await perform();
306
+ handOff();
307
+ const commit = commitAndForget(result.commit);
308
+ if (isPartialNavigation(result.skippedSegments)) {
309
+ commit();
310
+ } else {
311
+ renderPayload(result.payload, result.navState, await result.params, commit);
312
+ }
313
+ } catch (error) {
314
+ fallbackOutcome = 'failed';
315
+ throw error;
316
+ } finally {
317
+ onCommit?.(fallbackOutcome);
298
318
  }
299
319
  }
300
320
 
@@ -346,9 +366,6 @@ export function createNavigationPipeline({
346
366
  decodePromise: prefetched.decodePromise ?? null,
347
367
  segmentInfo: prefetched.segmentInfo ?? null,
348
368
  skippedSegments: prefetched.skippedSegments ?? null,
349
- // Entries written before this field carried it are treated as
350
- // cacheable, which is what they were before a deny could be one.
351
- status: prefetched.status ?? 200,
352
369
  }
353
370
  : undefined;
354
371
 
@@ -361,21 +378,28 @@ export function createNavigationPipeline({
361
378
  if (result === undefined) {
362
379
  const inflight = prefetchCache.joinInflight(cacheKey);
363
380
  if (inflight) {
381
+ // Capture the eviction generation before awaiting the flight.
382
+ // If an action evicts caches while we wait, the result predates
383
+ // the mutation and must be discarded (TIM-1476, codex on #1131).
384
+ const genBefore = prefetchCache.getEvictionGen();
364
385
  try {
365
386
  const outcome = await raceAbort(inflight, options.signal);
366
- if (outcome.kind === 'non-route') {
387
+ if (prefetchCache.getEvictionGen() !== genBefore) {
388
+ // Eviction happened while we waited — discard stale result,
389
+ // fall through to a fresh fetch below.
390
+ } else if (outcome.kind === 'non-route') {
367
391
  throw new NonRscResponse(url);
392
+ } else {
393
+ // Consume the ready entry so a second click re-fetches
394
+ prefetchCache.consume(cacheKey);
395
+ result = {
396
+ payload: outcome.result.payload,
397
+ params: outcome.result.params ?? readPublishedParams(undefined),
398
+ decodePromise: outcome.result.decodePromise ?? null,
399
+ segmentInfo: outcome.result.segmentInfo ?? null,
400
+ skippedSegments: outcome.result.skippedSegments ?? null,
401
+ };
368
402
  }
369
- // Consume the ready entry so a second click re-fetches
370
- prefetchCache.consume(cacheKey);
371
- result = {
372
- payload: outcome.result.payload,
373
- params: outcome.result.params ?? readPublishedParams(undefined),
374
- decodePromise: outcome.result.decodePromise ?? null,
375
- segmentInfo: outcome.result.segmentInfo ?? null,
376
- skippedSegments: outcome.result.skippedSegments ?? null,
377
- status: outcome.result.status ?? 200,
378
- };
379
403
  } catch (error) {
380
404
  // Supersession aborts propagate — don't retry a cancelled navigation
381
405
  if (error instanceof DOMException && error.name === 'AbortError') throw error;
@@ -440,7 +464,6 @@ export function createNavigationPipeline({
440
464
  payload,
441
465
  params,
442
466
  segmentInfo: result.segmentInfo,
443
- status: result.status,
444
467
  skippedSegments: result.skippedSegments,
445
468
  });
446
469
 
@@ -14,13 +14,23 @@ import type { ParamsSource } from '../shared/payload-root.ts';
14
14
  import type { SegmentInfo } from '../shared/segment-info.ts';
15
15
  import type { SegmentCache, PrefetchCache } from './segment-cache.ts';
16
16
  import type { HistoryStack } from './history.ts';
17
- import type { TransitionResult } from './navigation-transition.ts';
17
+ import type { TransitionResult, CommitOutcome, RenderOwner } from './navigation-transition.ts';
18
+ import type { NavigationEpoch } from './router-lifecycle.ts';
18
19
 
19
20
  export interface NavigationOptions {
20
21
  /** Set to false to prevent scroll-to-top on forward navigation */
21
22
  scroll?: boolean;
22
23
  /** Use replaceState instead of pushState (replaces current history entry) */
23
24
  replace?: boolean;
25
+ /**
26
+ * Runs exactly once, when React commits this navigation's tree — or when
27
+ * the navigation is abandoned (superseded or failed) and no such commit
28
+ * will come. The returned promise resolves on decode, which can be before
29
+ * OR after the commit; this is the hook for the other event. `<Link>`
30
+ * holds `isPending` until both have happened (TIM-1418). The outcome says
31
+ * which of the three it was.
32
+ */
33
+ onCommit?: (outcome: CommitOutcome) => void;
24
34
  /**
25
35
  * @internal AbortSignal from the Navigation API's NavigateEvent.
26
36
  * When provided, the signal is linked to the router's per-navigation
@@ -111,6 +121,7 @@ export interface RouterDeps {
111
121
  */
112
122
  navigateTransition?: (
113
123
  pendingUrl: string,
124
+ owner: RenderOwner,
114
125
  perform: (
115
126
  wrapPayload: (
116
127
  payload: unknown,
@@ -125,7 +136,9 @@ export interface RouterDeps {
125
136
  params: ParamsSource,
126
137
  segmentUpdates?: Map<string, unknown>
127
138
  ) => unknown
128
- ) => Promise<TransitionResult<unknown>>
139
+ ) => Promise<TransitionResult<unknown>>,
140
+ /** See `NavigationOptions.onCommit`; forwarded to `navigateTransition`. */
141
+ onCommit?: (outcome: CommitOutcome) => void
129
142
  ) => Promise<void>;
130
143
 
131
144
  /**
@@ -196,7 +209,7 @@ export interface RouterInstance {
196
209
  /** Navigate to a new URL (forward navigation) */
197
210
  navigate(url: string, options?: NavigationOptions): Promise<void>;
198
211
  /** Full re-render of the current URL — no state tree sent */
199
- refresh(): Promise<void>;
212
+ refresh(options?: { onCommit?: (outcome: CommitOutcome) => void }): Promise<void>;
200
213
  /** Handle a popstate event (back/forward button). scrollY is read from history.state. */
201
214
  handlePopState(url: string, scrollY?: number, externalSignal?: AbortSignal): Promise<void>;
202
215
  /** Whether a navigation is currently in flight */
@@ -208,12 +221,53 @@ export interface RouterInstance {
208
221
  /** Prefetch an RSC payload for a URL (used by Link hover) */
209
222
  prefetch(url: string): void;
210
223
  /**
211
- * Apply a piggybacked revalidation payload from a server action response.
212
- * Takes the decoded payload *root* — tree plus the params it published —
213
- * and renders it without a server fetch.
224
+ * Snapshot the router's navigation state before dispatching a server action.
225
+ * Hand the epoch back to `applyActionResult` with the response (TIM-1474).
226
+ */
227
+ epoch(): NavigationEpoch;
228
+ /**
229
+ * Reconcile the page after a server action. `tree` is the piggybacked
230
+ * revalidation payload root, or `undefined` when the server sent none.
231
+ *
232
+ * Returns `true` when the result was applied, `false` when stale (a
233
+ * navigation happened since `epoch` was taken or the router was busy at
234
+ * dispatch). Async because the no-tree path awaits `refresh()` — the next
235
+ * queued action must not capture its epoch while the refresh is in-flight.
236
+ *
237
+ * Does NOT call `runWhenIdle` — that is the action queue's responsibility
238
+ * (gated on "queue empty", not just "router idle").
239
+ *
214
240
  * See design/08-forms-and-actions.md §"Single-Roundtrip Revalidation".
215
241
  */
216
- applyRevalidation(payloadRoot: unknown): void;
242
+ applyActionResult(epoch: NavigationEpoch, tree?: unknown): Promise<boolean>;
243
+ /**
244
+ * Run `task` now if the router is idle (not pending, no uncommitted tree),
245
+ * otherwise once when it next becomes idle. Single slot — the action queue
246
+ * uses this to flush one deferred refresh when both queue-empty and
247
+ * router-idle are true (TIM-1474).
248
+ */
249
+ runWhenIdle(task: () => void): void;
250
+ /**
251
+ * Settle any handed-off-but-uncommitted owners as superseded, then flush
252
+ * idle tasks. Called by `syncShallowSearch` when a shallow URL update
253
+ * displaces a suspended transition (TIM-1480).
254
+ */
255
+ settleHandoffs(): void;
256
+ /**
257
+ * Invalidate client caches for a path so the next navigation fetches
258
+ * fresh data. Called when `revalidatePath` targeted a non-current path.
259
+ */
260
+ invalidatePath(path: string): void;
261
+ /**
262
+ * Evict all prefetch cache entries and history stack payloads (TIM-1476).
263
+ * Called after a server action that revalidated data — the mutation may
264
+ * affect any route's data, not just the paths the server named, so every
265
+ * cached payload is stale. In-flight prefetch singleflights are fenced
266
+ * by the eviction generation — their results are discarded if they
267
+ * complete after eviction. The current URL's history entry is preserved
268
+ * so `applyActionResult` can read its segment metadata.
269
+ */
270
+ evictStaleCaches(): void;
217
271
  /**
218
272
  * Populate the segment cache from server-provided segment metadata.
219
273
  * Called on initial hydration with segment info embedded in the HTML.