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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/dist/_chunks/{actions-BS-m5SLv.js → actions-d1hCqnU3.js} +35 -8
  2. package/dist/_chunks/actions-d1hCqnU3.js.map +1 -0
  3. package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -1
  4. package/dist/_chunks/{cache-api-DqzgTEqk.js → cache-api-ByagcC-J.js} +2 -2
  5. package/dist/_chunks/{cache-api-DqzgTEqk.js.map → cache-api-ByagcC-J.js.map} +1 -1
  6. package/dist/_chunks/canonicalize-CgHoscYO.js +66 -0
  7. package/dist/_chunks/canonicalize-CgHoscYO.js.map +1 -0
  8. package/dist/_chunks/{chains-CZG7E5zg.js → chains-Bpb0W4ax.js} +3 -3
  9. package/dist/_chunks/{chains-CZG7E5zg.js.map → chains-Bpb0W4ax.js.map} +1 -1
  10. package/dist/_chunks/{cli-check-bE3H5Bjr.js → cli-check-D6VolrDV.js} +3 -3
  11. package/dist/_chunks/{cli-check-bE3H5Bjr.js.map → cli-check-D6VolrDV.js.map} +1 -1
  12. package/dist/_chunks/{cli-schema-sync-DTy_-Msq.js → cli-schema-sync-D6rO-VcS.js} +2 -2
  13. package/dist/_chunks/{cli-schema-sync-DTy_-Msq.js.map → cli-schema-sync-D6rO-VcS.js.map} +1 -1
  14. package/dist/_chunks/{convention-lint-n3RJLgww.js → convention-lint-fRkwVwEH.js} +27 -4
  15. package/dist/_chunks/convention-lint-fRkwVwEH.js.map +1 -0
  16. package/dist/_chunks/error-boundary-BfPHZjm0.js +1050 -0
  17. package/dist/_chunks/error-boundary-BfPHZjm0.js.map +1 -0
  18. package/dist/_chunks/{live-graph-BXDsdzBv.js → live-graph-D_2D32Ad.js} +3 -3
  19. package/dist/_chunks/{live-graph-BXDsdzBv.js.map → live-graph-D_2D32Ad.js.map} +1 -1
  20. package/dist/_chunks/{logger-DDirEsn7.js → logger-uLBuGKDI.js} +471 -440
  21. package/dist/_chunks/logger-uLBuGKDI.js.map +1 -0
  22. package/dist/_chunks/{navigation-root-B00jjGd5.js → navigation-context-D0TU0Jog.js} +3 -101
  23. package/dist/_chunks/navigation-context-D0TU0Jog.js.map +1 -0
  24. package/dist/_chunks/navigation-root-mHSK9psY.js +126 -0
  25. package/dist/_chunks/{navigation-root-B00jjGd5.js.map → navigation-root-mHSK9psY.js.map} +1 -1
  26. package/dist/_chunks/{poison-scan-BoDLgbix.js → poison-scan-Bm9Yyqk9.js} +2 -2
  27. package/dist/_chunks/{poison-scan-BoDLgbix.js.map → poison-scan-Bm9Yyqk9.js.map} +1 -1
  28. package/dist/_chunks/{scanner-tdFPvDYi.js → scanner-AiazgH_f.js} +6 -5
  29. package/dist/_chunks/scanner-AiazgH_f.js.map +1 -0
  30. package/dist/_chunks/{segment-keys-BhqoHiLc.js → segment-keys-lqtdookO.js} +2 -65
  31. package/dist/_chunks/segment-keys-lqtdookO.js.map +1 -0
  32. package/dist/_chunks/{ssr-data-BQGhTPAK.js → ssr-data-D6T6Y3ef.js} +4 -26
  33. package/dist/_chunks/ssr-data-D6T6Y3ef.js.map +1 -0
  34. package/dist/_chunks/state-FippDgxN.js +52 -0
  35. package/dist/_chunks/state-FippDgxN.js.map +1 -0
  36. package/dist/_chunks/status-page-marker-DwQBrLBz.js +496 -0
  37. package/dist/_chunks/status-page-marker-DwQBrLBz.js.map +1 -0
  38. package/dist/_chunks/{walkers-DNX05dC0.js → walkers-B6XUtmqK.js} +2 -2
  39. package/dist/_chunks/{walkers-DNX05dC0.js.map → walkers-B6XUtmqK.js.map} +1 -1
  40. package/dist/analyze/crawl-entry.js +2 -2
  41. package/dist/analyze/graph-command.js +2 -2
  42. package/dist/cache/index.js +1 -1
  43. package/dist/cli.js +2 -2
  44. package/dist/client/browser-entry/action-dispatch.d.ts +6 -4
  45. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  46. package/dist/client/browser-entry/action-queue.d.ts +44 -0
  47. package/dist/client/browser-entry/action-queue.d.ts.map +1 -0
  48. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  49. package/dist/client/deny-last-resort.d.ts +29 -0
  50. package/dist/client/deny-last-resort.d.ts.map +1 -0
  51. package/dist/client/error-boundary.d.ts +48 -2
  52. package/dist/client/error-boundary.d.ts.map +1 -1
  53. package/dist/client/error-boundary.js +2 -2
  54. package/dist/client/history.d.ts +21 -2
  55. package/dist/client/history.d.ts.map +1 -1
  56. package/dist/client/index.js +34 -14
  57. package/dist/client/index.js.map +1 -1
  58. package/dist/client/internal.d.ts +1 -0
  59. package/dist/client/internal.d.ts.map +1 -1
  60. package/dist/client/internal.js +272 -1225
  61. package/dist/client/internal.js.map +1 -1
  62. package/dist/client/link.d.ts.map +1 -1
  63. package/dist/client/navigation-commit.d.ts +12 -19
  64. package/dist/client/navigation-commit.d.ts.map +1 -1
  65. package/dist/client/navigation-transition.d.ts +62 -11
  66. package/dist/client/navigation-transition.d.ts.map +1 -1
  67. package/dist/client/router-effects.d.ts +9 -8
  68. package/dist/client/router-effects.d.ts.map +1 -1
  69. package/dist/client/router-lifecycle.d.ts +60 -17
  70. package/dist/client/router-lifecycle.d.ts.map +1 -1
  71. package/dist/client/router-pipeline.d.ts +7 -4
  72. package/dist/client/router-pipeline.d.ts.map +1 -1
  73. package/dist/client/router-types.d.ts +63 -8
  74. package/dist/client/router-types.d.ts.map +1 -1
  75. package/dist/client/router.d.ts.map +1 -1
  76. package/dist/client/rsc-fetch.d.ts +0 -9
  77. package/dist/client/rsc-fetch.d.ts.map +1 -1
  78. package/dist/client/segment-cache.d.ts +23 -8
  79. package/dist/client/segment-cache.d.ts.map +1 -1
  80. package/dist/client/state.d.ts +16 -0
  81. package/dist/client/state.d.ts.map +1 -1
  82. package/dist/client/status-page-marker.d.ts +25 -0
  83. package/dist/client/status-page-marker.d.ts.map +1 -0
  84. package/dist/cookies/index.js +1 -1
  85. package/dist/dev-tools/holding-server.d.ts +4 -17
  86. package/dist/dev-tools/holding-server.d.ts.map +1 -1
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +74 -175
  89. package/dist/index.js.map +1 -1
  90. package/dist/plugins/dev-server.d.ts.map +1 -1
  91. package/dist/routing/codegen-write.d.ts.map +1 -1
  92. package/dist/routing/index.js +2 -2
  93. package/dist/routing/interception.d.ts +2 -2
  94. package/dist/routing/slot-placement.d.ts +2 -2
  95. package/dist/server/access-gate.d.ts +73 -1
  96. package/dist/server/access-gate.d.ts.map +1 -1
  97. package/dist/server/action-handler.d.ts.map +1 -1
  98. package/dist/server/actions.d.ts +16 -1
  99. package/dist/server/actions.d.ts.map +1 -1
  100. package/dist/server/als-registry.d.ts +3 -9
  101. package/dist/server/als-registry.d.ts.map +1 -1
  102. package/dist/server/children-interception.d.ts +1 -1
  103. package/dist/server/default-status-page.d.ts +2 -2
  104. package/dist/server/default-status-page.d.ts.map +1 -1
  105. package/dist/server/deny-boundary.d.ts +15 -9
  106. package/dist/server/deny-boundary.d.ts.map +1 -1
  107. package/dist/server/deny-renderer.d.ts.map +1 -1
  108. package/dist/server/error-boundary-wrapper.d.ts +21 -4
  109. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  110. package/dist/server/error-response-headers.d.ts +3 -0
  111. package/dist/server/error-response-headers.d.ts.map +1 -0
  112. package/dist/server/index.js +3 -3
  113. package/dist/server/index.js.map +1 -1
  114. package/dist/server/internal.d.ts +1 -2
  115. package/dist/server/internal.d.ts.map +1 -1
  116. package/dist/server/internal.js +2339 -2506
  117. package/dist/server/internal.js.map +1 -1
  118. package/dist/server/metadata-collector.d.ts +2 -5
  119. package/dist/server/metadata-collector.d.ts.map +1 -1
  120. package/dist/server/param-coercion.d.ts +10 -3
  121. package/dist/server/param-coercion.d.ts.map +1 -1
  122. package/dist/server/pipeline-outcome.d.ts.map +1 -1
  123. package/dist/server/pipeline-phases.d.ts +11 -0
  124. package/dist/server/pipeline-phases.d.ts.map +1 -1
  125. package/dist/server/port-resolution.d.ts +3 -89
  126. package/dist/server/port-resolution.d.ts.map +1 -1
  127. package/dist/server/primitives.d.ts +38 -10
  128. package/dist/server/primitives.d.ts.map +1 -1
  129. package/dist/server/response-cache-policy.d.ts +3 -0
  130. package/dist/server/response-cache-policy.d.ts.map +1 -0
  131. package/dist/server/route-element-builder.d.ts +11 -41
  132. package/dist/server/route-element-builder.d.ts.map +1 -1
  133. package/dist/server/route-element-helpers.d.ts +12 -0
  134. package/dist/server/route-element-helpers.d.ts.map +1 -0
  135. package/dist/server/route-module-loader.d.ts +37 -0
  136. package/dist/server/route-module-loader.d.ts.map +1 -0
  137. package/dist/server/rsc-cache-key-guard.d.ts.map +1 -1
  138. package/dist/server/rsc-entry/action-middleware-runner.d.ts.map +1 -1
  139. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  140. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  141. package/dist/server/rsc-entry/rsc-payload.d.ts +22 -1
  142. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  143. package/dist/server/rsc-entry/rsc-stream.d.ts +4 -11
  144. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  145. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  146. package/dist/server/rsc-error-envelope.d.ts +11 -0
  147. package/dist/server/rsc-error-envelope.d.ts.map +1 -0
  148. package/dist/server/skippable-prefix.d.ts +18 -15
  149. package/dist/server/skippable-prefix.d.ts.map +1 -1
  150. package/dist/server/slot-resolver.d.ts.map +1 -1
  151. package/dist/server/slot-subtree-contain.d.ts +54 -0
  152. package/dist/server/slot-subtree-contain.d.ts.map +1 -0
  153. package/dist/server/stream-utils.d.ts.map +1 -1
  154. package/dist/server/utils/element-type.d.ts +10 -0
  155. package/dist/server/utils/element-type.d.ts.map +1 -1
  156. package/dist/shared/rsc-error-envelope.d.ts +0 -9
  157. package/dist/shared/rsc-error-envelope.d.ts.map +1 -1
  158. package/dist/shared/status-reason-phrase.d.ts +26 -0
  159. package/dist/shared/status-reason-phrase.d.ts.map +1 -0
  160. package/docs/api/30-api-server.mdx +4 -2
  161. package/docs/api/31-api-client.mdx +5 -1
  162. package/docs/api/36-cli.mdx +5 -3
  163. package/docs/learn/12-error-handling.mdx +5 -1
  164. package/package.json +10 -10
  165. package/src/client/browser-entry/action-dispatch.ts +166 -99
  166. package/src/client/browser-entry/action-queue.ts +90 -0
  167. package/src/client/browser-entry/router-init.ts +60 -35
  168. package/src/client/deny-last-resort.tsx +54 -0
  169. package/src/client/error-boundary.tsx +144 -42
  170. package/src/client/history.ts +52 -3
  171. package/src/client/internal.ts +1 -0
  172. package/src/client/link.tsx +70 -35
  173. package/src/client/navigation-commit.ts +79 -27
  174. package/src/client/navigation-transition.ts +176 -127
  175. package/src/client/router-effects.ts +14 -17
  176. package/src/client/router-lifecycle.ts +181 -115
  177. package/src/client/router-pipeline.ts +94 -71
  178. package/src/client/router-types.ts +61 -7
  179. package/src/client/router.ts +147 -74
  180. package/src/client/rsc-fetch.ts +0 -13
  181. package/src/client/segment-cache.ts +43 -10
  182. package/src/client/state.ts +26 -0
  183. package/src/client/status-page-marker.tsx +32 -0
  184. package/src/dev-tools/holding-server.ts +4 -17
  185. package/src/index.ts +18 -34
  186. package/src/plugins/dev-server.ts +2 -1
  187. package/src/react-canary.d.ts +2 -0
  188. package/src/routing/codegen-write.ts +2 -0
  189. package/src/routing/interception.ts +2 -2
  190. package/src/routing/slot-placement.ts +2 -2
  191. package/src/server/access-gate.tsx +89 -21
  192. package/src/server/action-client.ts +2 -2
  193. package/src/server/action-handler.ts +23 -10
  194. package/src/server/actions.ts +81 -34
  195. package/src/server/als-registry.ts +3 -9
  196. package/src/server/children-interception.ts +1 -1
  197. package/src/server/default-status-page.ts +7 -47
  198. package/src/server/deny-boundary.ts +45 -28
  199. package/src/server/deny-renderer.ts +6 -2
  200. package/src/server/error-boundary-wrapper.ts +23 -4
  201. package/src/server/error-response-headers.ts +18 -0
  202. package/src/server/internal.ts +2 -10
  203. package/src/server/metadata-collector.ts +3 -18
  204. package/src/server/param-coercion.ts +13 -4
  205. package/src/server/pipeline-outcome.ts +35 -13
  206. package/src/server/pipeline-phases.ts +22 -16
  207. package/src/server/port-resolution.ts +3 -165
  208. package/src/server/prebuilt-builder.ts +4 -4
  209. package/src/server/primitives.ts +75 -11
  210. package/src/server/response-cache-policy.ts +45 -0
  211. package/src/server/route-element-builder.ts +149 -412
  212. package/src/server/route-element-helpers.ts +37 -0
  213. package/src/server/route-handler.ts +2 -2
  214. package/src/server/route-module-loader.ts +161 -0
  215. package/src/server/rsc-cache-key-guard.ts +2 -42
  216. package/src/server/rsc-entry/action-middleware-runner.ts +4 -4
  217. package/src/server/rsc-entry/api-handler.ts +5 -5
  218. package/src/server/rsc-entry/error-renderer.ts +3 -4
  219. package/src/server/rsc-entry/helpers.ts +1 -1
  220. package/src/server/rsc-entry/index.ts +3 -3
  221. package/src/server/rsc-entry/render-route.ts +4 -8
  222. package/src/server/rsc-entry/rsc-payload.ts +59 -42
  223. package/src/server/rsc-entry/rsc-stream.ts +48 -27
  224. package/src/server/rsc-entry/ssr-renderer.ts +6 -10
  225. package/src/server/rsc-error-envelope.ts +18 -0
  226. package/src/server/skippable-prefix.ts +105 -7
  227. package/src/server/slot-resolver.ts +43 -12
  228. package/src/server/slot-subtree-contain.ts +255 -0
  229. package/src/server/stream-utils.ts +12 -8
  230. package/src/server/utils/element-type.ts +18 -2
  231. package/src/shared/rsc-error-envelope.ts +0 -15
  232. package/src/shared/status-reason-phrase.ts +61 -0
  233. package/dist/_chunks/actions-BS-m5SLv.js.map +0 -1
  234. package/dist/_chunks/convention-lint-n3RJLgww.js.map +0 -1
  235. package/dist/_chunks/error-boundary-BvRCCmbN.js +0 -353
  236. package/dist/_chunks/error-boundary-BvRCCmbN.js.map +0 -1
  237. package/dist/_chunks/logger-DDirEsn7.js.map +0 -1
  238. package/dist/_chunks/mdx-file-CXyHGUpS.js +0 -25
  239. package/dist/_chunks/mdx-file-CXyHGUpS.js.map +0 -1
  240. package/dist/_chunks/router-ref-8gr8qsxN.js +0 -28
  241. package/dist/_chunks/router-ref-8gr8qsxN.js.map +0 -1
  242. package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js +0 -40
  243. package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js.map +0 -1
  244. package/dist/_chunks/scanner-tdFPvDYi.js.map +0 -1
  245. package/dist/_chunks/segment-keys-BhqoHiLc.js.map +0 -1
  246. package/dist/_chunks/ssr-data-BQGhTPAK.js.map +0 -1
  247. package/dist/server/tree-builder.d.ts +0 -150
  248. package/dist/server/tree-builder.d.ts.map +0 -1
  249. package/src/server/tree-builder.ts +0 -313
@@ -1,6 +1,6 @@
1
1
  // Segment Router — the operations a client-side navigation can be.
2
2
  //
3
- // `navigate`, `refresh`, `handlePopState`, `prefetch` and `applyRevalidation`
3
+ // `navigate`, `refresh`, `handlePopState`, `prefetch` and `applyActionResult`
4
4
  // live here; each is wiring over modules that own one concern apiece:
5
5
  //
6
6
  // router-types.ts — RouterDeps / RouterInstance / the option shapes
@@ -24,6 +24,7 @@ import { createNavigationLifecycle } from './router-lifecycle.ts';
24
24
  import { createNavigationPipeline, prefetchKeyFor } from './router-pipeline.ts';
25
25
  import { recordSkew } from './router-skew.ts';
26
26
  import type { NavigationOptions, RouterDeps, RouterInstance } from './router-types.ts';
27
+ import { createRenderOwner, type CommitOutcome } from './navigation-transition.ts';
27
28
 
28
29
  // ─── Router Factory ──────────────────────────────────────────────
29
30
 
@@ -46,19 +47,20 @@ export function createRouter(deps: RouterDeps): RouterInstance {
46
47
 
47
48
  // Ownership of the router: who is navigating, whose fetch may be cut, and
48
49
  // the pending store TopLoader subscribes to. See router-lifecycle.ts.
50
+ const lifecycle = createNavigationLifecycle(deps);
49
51
  const {
50
- currentNavAbort,
51
- createNavAbort,
52
+ currentOwner,
53
+ createNavOwner,
52
54
  runNavigation,
53
55
  markHandedOff,
54
56
  forgetOlderHandoffs,
55
57
  isPending,
56
58
  getPendingUrl,
57
59
  onPendingChange,
58
- } = createNavigationLifecycle(deps);
60
+ } = lifecycle;
59
61
 
60
62
  // Fetch → commit → hand to React. See router-pipeline.ts.
61
- const { performNavigationFetch, renderViaTransition, renderPayload, resolveForFallback } =
63
+ const { performNavigationFetch, renderViaTransition, resolveForFallback } =
62
64
  createNavigationPipeline({
63
65
  deps,
64
66
  prefetchCache,
@@ -76,15 +78,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
76
78
  // navigation still own the router?" question is asked once rather than
77
79
  // per branch (TIM-1275, TIM-1276).
78
80
  const { leaveSpaIfOwned, leaveSpaSuperseding } = createSpaExits({
79
- currentNavAbort,
80
- supersede: () => void createNavAbort(),
81
+ currentOwner,
82
+ supersede: () => void createNavOwner('navigation'),
81
83
  });
82
84
 
83
85
  // Every way an RSC fetch can fail that the router answers rather than
84
86
  // rethrows. Shared by navigate(), refresh(), and traversals so a path that
85
87
  // grows a new branch grows it for all of them (TIM-1277).
86
88
  const recoverFromNavigationError = createNavigationRecovery({
87
- currentNavAbort,
89
+ currentOwner,
88
90
  leaveSpaIfOwned,
89
91
  // Hoisted — `navigate` is a function declaration below.
90
92
  navigate: (url) => navigate(url, { replace: true }),
@@ -137,10 +139,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
137
139
 
138
140
  await runNavigation(
139
141
  url,
140
- async (navAbort) => {
142
+ async (owner) => {
141
143
  // When Navigation API is active, initiate the navigation via
142
144
  // navigation.navigate() BEFORE the fetch. Must happen after
143
- // createNavAbort supersedes the previous navigation (done by
145
+ // createNavOwner supersedes the previous navigation (done by
144
146
  // runNavigation) so the old deferred is resolved first.
145
147
  if (!effectiveSkipHistory && deps.navigationNavigate) {
146
148
  deps.setRouterNavigating?.(true);
@@ -150,14 +152,18 @@ export function createRouter(deps: RouterDeps): RouterInstance {
150
152
  }
151
153
 
152
154
  try {
153
- await renderViaTransition(fetchUrl, navAbort, () =>
154
- performNavigationFetch(fetchUrl, {
155
- replace,
156
- commitUrl: url,
157
- signal: navAbort.signal,
158
- skipHistory: effectiveSkipHistory,
159
- departingUrl,
160
- })
155
+ await renderViaTransition(
156
+ fetchUrl,
157
+ owner,
158
+ () =>
159
+ performNavigationFetch(fetchUrl, {
160
+ replace,
161
+ commitUrl: url,
162
+ signal: owner.fetchAbort.signal,
163
+ skipHistory: effectiveSkipHistory,
164
+ departingUrl,
165
+ }),
166
+ options.onCommit
161
167
  );
162
168
 
163
169
  // Scroll-to-top on forward navigation, scroll to the #fragment target
@@ -173,7 +179,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
173
179
  // load of the destination, fragment included (TIM-1234). Reloading
174
180
  // instead would rebuild the page they were *leaving* and discard
175
181
  // the click (TIM-1275).
176
- if (await recoverFromNavigationError(error, navAbort, url, departingUrl)) return;
182
+ if (await recoverFromNavigationError(error, owner, url, departingUrl)) return;
177
183
  throw error;
178
184
  }
179
185
  },
@@ -198,31 +204,37 @@ export function createRouter(deps: RouterDeps): RouterInstance {
198
204
  /** Restored after paint when present — traversals only. */
199
205
  scrollY?: number;
200
206
  externalSignal?: AbortSignal;
207
+ /** Fires once when React commits, or when the navigation is abandoned. */
208
+ onCommit?: (outcome: CommitOutcome) => void;
201
209
  } = {}
202
210
  ): Promise<void> {
203
211
  await runNavigation(
204
212
  url,
205
- async (navAbort) => {
213
+ async (owner) => {
206
214
  try {
207
- await renderViaTransition(url, navAbort, async () => {
208
- const result = await fetchRscPayload(
209
- url,
210
- deps,
211
- opts.stateTree,
212
- undefined,
213
- navAbort.signal
214
- );
215
- const payload = await resolveForFallback(result.payload);
216
- const params = await result.params;
217
- const { navState, commit } = prepareNavigation(url, {
218
- payload,
219
- params,
220
- segmentInfo: result.segmentInfo,
221
- status: result.status,
222
- skippedSegments: result.skippedSegments,
223
- });
224
- return { ...result, payload, params, navState, commit };
225
- });
215
+ await renderViaTransition(
216
+ url,
217
+ owner,
218
+ async () => {
219
+ const result = await fetchRscPayload(
220
+ url,
221
+ deps,
222
+ opts.stateTree,
223
+ undefined,
224
+ owner.fetchAbort.signal
225
+ );
226
+ const payload = await resolveForFallback(result.payload);
227
+ const params = await result.params;
228
+ const { navState, commit } = prepareNavigation(url, {
229
+ payload,
230
+ params,
231
+ segmentInfo: result.segmentInfo,
232
+ skippedSegments: result.skippedSegments,
233
+ });
234
+ return { ...result, payload, params, navState, commit };
235
+ },
236
+ opts.onCommit
237
+ );
226
238
  } catch (error) {
227
239
  // Neither path is a navigate(), and neither has a caller that
228
240
  // handles a rejection — `refresh()` is `void`-called everywhere it
@@ -235,7 +247,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
235
247
  // browser has already traversed, and `refresh()` is by definition
236
248
  // where it already is. So `hardNavigate()` takes its same-document
237
249
  // branch and reloads rather than pushing an entry.
238
- if (await recoverFromNavigationError(error, navAbort, url, url)) return;
250
+ if (await recoverFromNavigationError(error, owner, url, url)) return;
239
251
  throw error;
240
252
  }
241
253
 
@@ -245,7 +257,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
245
257
  );
246
258
  }
247
259
 
248
- async function refresh(): Promise<void> {
260
+ async function refresh(options?: { onCommit?: (outcome: CommitOutcome) => void }): Promise<void> {
249
261
  const currentUrl = deps.getCurrentUrl();
250
262
 
251
263
  // A refresh on a stale client is a full document load of the current URL.
@@ -256,7 +268,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
256
268
  await leaveSpaSuperseding(currentUrl, currentUrl);
257
269
  }
258
270
 
259
- await fetchCommitAndRender(currentUrl);
271
+ await fetchCommitAndRender(currentUrl, { onCommit: options?.onCommit });
260
272
  }
261
273
 
262
274
  async function handlePopState(
@@ -290,7 +302,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
290
302
  // synchronous — the fn resolves immediately.
291
303
  await runNavigation(
292
304
  url,
293
- async () => {
305
+ async (owner) => {
294
306
  // clearSegmentCacheOnEmpty: popstate to an entry without layout
295
307
  // metadata (e.g., initial SSR page) clears the cache so the next
296
308
  // forward navigation gets a full render.
@@ -302,13 +314,30 @@ export function createRouter(deps: RouterDeps): RouterInstance {
302
314
  // here. Publishing first would advertise the replayed route's slot
303
315
  // keys while the slot content cache still describes the departing
304
316
  // one (TIM-1423, codex on #1108).
305
- const { navState, commit } = prepareNavigation(url, {
306
- payload: entry.payload,
307
- params: entry.params,
308
- segmentInfo: entry.segmentInfo,
309
- clearSegmentCacheOnEmpty: true,
317
+ //
318
+ // Routed through renderViaTransition so the replay gets the same
319
+ // lifecycle every other render has — handoff tracking, commit-on-
320
+ // commit, forgetOlderHandoffs — rather than a hand-rolled publish
321
+ // that misses one of them (TIM-1478). The perform resolves
322
+ // immediately: there is no fetch, and the cached payload is
323
+ // already decoded.
324
+ await renderViaTransition(url, owner, async () => {
325
+ const { navState, commit } = prepareNavigation(url, {
326
+ payload: entry.payload,
327
+ params: entry.params,
328
+ segmentInfo: entry.segmentInfo,
329
+ clearSegmentCacheOnEmpty: true,
330
+ });
331
+ return {
332
+ payload: entry.payload,
333
+ params: entry.params,
334
+ navState,
335
+ commit,
336
+ decodePromise: null,
337
+ segmentInfo: entry.segmentInfo ?? null,
338
+ skippedSegments: null,
339
+ };
310
340
  });
311
- renderPayload(entry.payload, navState, entry.params, commit);
312
341
  restoreScrollAfterPaint(scrollY);
313
342
  },
314
343
  externalSignal
@@ -356,7 +385,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
356
385
 
357
386
  // Don't prefetch if already cached (ready or negative)
358
387
  if (prefetchCache.has(cacheKey)) return;
359
- if (historyStack.has(fetchUrl)) return;
360
388
 
361
389
  // Fire-and-forget. Concurrent hovers coalesce in the singleflight;
362
390
  // a click during the round-trip joins the same flight via
@@ -399,32 +427,77 @@ export function createRouter(deps: RouterDeps): RouterInstance {
399
427
  getPendingUrl,
400
428
  onPendingChange,
401
429
  prefetch,
402
- applyRevalidation(payloadRoot: unknown): void {
403
- // Render the piggybacked payload from a server action response. Updates
404
- // the current history entry with the fresh payload — same as refresh()
405
- // but without a server fetch.
406
- //
407
- // The revalidation renderer builds its tree through the same
408
- // `withPublishedParams` every route payload goes through, so it is a
409
- // payload root and carries its own params. It is already decoded by the
410
- // time it reaches here (action-dispatch awaits the Flight response), so
411
- // the split is synchronous.
430
+ epoch: () => lifecycle.epoch(),
431
+
432
+ async applyActionResult(epoch, tree) {
433
+ if (!lifecycle.isEpochCurrent(epoch)) return false;
434
+ if (tree === undefined) {
435
+ // Await through the commit so the next queued action captures a
436
+ // current epoch — refresh() alone resolves before React commits
437
+ // on the History API fallback (codex on #1125). The outcome
438
+ // distinguishes a committed refresh from one superseded by a
439
+ // navigation the user started while the refresh was in flight
440
+ // (TIM-1477).
441
+ let outcomeResolve!: (o: CommitOutcome) => void;
442
+ const outcomePromise = new Promise<CommitOutcome>((r) => (outcomeResolve = r));
443
+ const [, outcome] = await Promise.all([
444
+ refresh({ onCommit: outcomeResolve }).catch(() => {}),
445
+ outcomePromise,
446
+ ]);
447
+ return outcome === 'committed';
448
+ }
449
+ // Render the piggybacked payload from a server action response.
450
+ // Routed through renderViaTransition so the piggybacked tree gets
451
+ // the same commit lifecycle every other render has (TIM-1478).
452
+ // The perform resolves immediately — the payload is already in hand.
412
453
  const currentUrl = deps.getCurrentUrl();
413
- const tree = readPayloadTree(payloadRoot);
414
- const params = readPublishedParams(payloadRoot);
415
-
416
- // Preserve existing segmentInfo so away-and-back navigation replays
417
- // with a correct segment cache (TIM-1037).
454
+ const payloadTree = readPayloadTree(tree);
455
+ const params = readPublishedParams(tree);
418
456
  const existingEntry = historyStack.get(currentUrl);
419
- // Like the popstate replay: there is no fetch to be superseded and the
420
- // payload is already decoded, but the publish still rides React's
421
- // commit of the tree, not this call (TIM-1423).
422
- const { navState, commit } = prepareNavigation(currentUrl, {
423
- payload: tree,
424
- params,
425
- segmentInfo: existingEntry?.segmentInfo,
426
- });
427
- renderPayload(tree, navState, params, commit);
457
+
458
+ let outcomeResolve!: (o: CommitOutcome) => void;
459
+ const outcomePromise = new Promise<CommitOutcome>((r) => (outcomeResolve = r));
460
+ // Place in the lifecycle's slot so a concurrent navigation supersedes
461
+ // this owner. The epoch check above ensures the slot is idle. Don't use
462
+ // createNavOwner — that bumps navigationSeq, which is reserved for
463
+ // navigations (the epoch test pins this: TIM-1474).
464
+ const owner = createRenderOwner('revalidation');
465
+ lifecycle.placeRevalidationOwner(owner);
466
+ const [, outcome] = await Promise.all([
467
+ renderViaTransition(
468
+ currentUrl,
469
+ owner,
470
+ async () => {
471
+ const { navState, commit } = prepareNavigation(currentUrl, {
472
+ payload: payloadTree,
473
+ params,
474
+ segmentInfo: existingEntry?.segmentInfo,
475
+ });
476
+ return {
477
+ payload: payloadTree,
478
+ params,
479
+ navState,
480
+ commit,
481
+ decodePromise: null,
482
+ segmentInfo: existingEntry?.segmentInfo ?? null,
483
+ skippedSegments: null,
484
+ };
485
+ },
486
+ outcomeResolve
487
+ ).catch(() => {}),
488
+ outcomePromise,
489
+ ]);
490
+ return outcome === 'committed';
491
+ },
492
+ runWhenIdle: (task: () => void) => lifecycle.runWhenIdle(task),
493
+ settleHandoffs: () => lifecycle.settleHandoffs(),
494
+ invalidatePath(path: string): void {
495
+ historyStack.delete(path);
496
+ prefetchCache.invalidateUrl(path);
497
+ },
498
+ evictStaleCaches(): void {
499
+ prefetchCache.clearReady();
500
+ historyStack.clearExcept(deps.getCurrentUrl());
428
501
  },
429
502
  initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
430
503
  segmentCache,
@@ -58,15 +58,6 @@ export interface FetchResult {
58
58
  segmentInfo: SegmentInfo[] | null;
59
59
  /** Segment paths that were skipped by the server (for client-side merging). */
60
60
  skippedSegments: string[] | null;
61
- /**
62
- * HTTP status of the navigation response.
63
- *
64
- * Read at the commit site to decide whether the tree this payload produced
65
- * is one worth caching. A deny page replaced a subtree, so `segmentInfo`
66
- * describes more than actually mounted — but it cannot simply be emptied,
67
- * because the merge point is chosen out of it. See TIM-1356.
68
- */
69
- status: number;
70
61
  }
71
62
 
72
63
  // ─── URL Helpers ─────────────────────────────────────────────────
@@ -537,7 +528,6 @@ export async function fetchRscPayload(
537
528
  const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual', signal });
538
529
  let segmentInfo: SegmentInfo[] | null = null;
539
530
  let skippedSegments: string[] | null = null;
540
- let status = 200;
541
531
  // Track when the full RSC body stream is consumed (not just shell).
542
532
  // Initialized to resolved for bodyless responses; overwritten when
543
533
  // the response has a body.
@@ -592,7 +582,6 @@ export async function fetchRscPayload(
592
582
  // as React elements — React 19 Float handles them. See TIM-1151.
593
583
  segmentInfo = extractSegmentInfo(response);
594
584
  skippedSegments = extractSkippedSegments(response);
595
- status = response.status;
596
585
 
597
586
  // Wrap the body to track full stream consumption. createFromFetch's
598
587
  // thenable resolves when the root model (shell) arrives, but we need
@@ -641,7 +630,6 @@ export async function fetchRscPayload(
641
630
  decodePromise,
642
631
  segmentInfo,
643
632
  skippedSegments,
644
- status,
645
633
  };
646
634
  }
647
635
  // Test/fallback path: return raw text
@@ -681,6 +669,5 @@ export async function fetchRscPayload(
681
669
  decodePromise: null,
682
670
  segmentInfo: extractSegmentInfo(response),
683
671
  skippedSegments: extractSkippedSegments(response),
684
- status: response.status,
685
672
  };
686
673
  }
@@ -27,14 +27,6 @@ export interface PrefetchResult {
27
27
  params?: PublishedParams | Promise<PublishedParams>;
28
28
  /** Segment paths skipped by the server (for client-side merging). */
29
29
  skippedSegments?: string[] | null;
30
- /**
31
- * HTTP status of the prefetched response. A deny payload is an ordinary
32
- * Flight response carrying a 4xx, so it prefetches and caches like any
33
- * other — and the tree it renders is one the segment cache must not learn
34
- * from. Absent on entries that predate the field, and on the negative
35
- * sentinel. See TIM-1356.
36
- */
37
- status?: number;
38
30
  }
39
31
 
40
32
  /**
@@ -363,6 +355,7 @@ export class PrefetchCache {
363
355
  private static readonly TTL_MS = 30_000;
364
356
  private entries = new Map<string, ReadyEntry>();
365
357
  private flights = createSingleflight({ timeoutMs: PREFETCH_SINGLEFLIGHT_TIMEOUT_MS });
358
+ private evictionGen = 0;
366
359
 
367
360
  set(key: PrefetchKey, result: PrefetchResult): void {
368
361
  this.entries.set(prefetchMapKey(key), {
@@ -414,14 +407,18 @@ export class PrefetchCache {
414
407
  doFetch: (signal: AbortSignal) => Promise<PrefetchResult>,
415
408
  isNonRoute: (error: unknown) => boolean
416
409
  ): Promise<FlightOutcome> {
410
+ // Capture the eviction generation before the flight starts. If an
411
+ // action evicts caches while this flight is in progress, the gen
412
+ // will have bumped and the stale result is discarded (TIM-1476).
413
+ const genAtStart = this.evictionGen;
417
414
  return this.flights.do(prefetchMapKey(key), async (signal) => {
418
415
  try {
419
416
  const result = await doFetch(signal);
420
- if (!signal.aborted) this.set(key, result);
417
+ if (!signal.aborted && this.evictionGen === genAtStart) this.set(key, result);
421
418
  return { kind: 'ready' as const, result };
422
419
  } catch (err) {
423
420
  if (isNonRoute(err)) {
424
- if (!signal.aborted) this.setNegative(key);
421
+ if (!signal.aborted && this.evictionGen === genAtStart) this.setNegative(key);
425
422
  return { kind: 'non-route' as const };
426
423
  }
427
424
  throw err;
@@ -456,4 +453,40 @@ export class PrefetchCache {
456
453
  isNegative(key: PrefetchKey): boolean {
457
454
  return this.get(key) === NEGATIVE_ENTRY;
458
455
  }
456
+
457
+ /**
458
+ * Invalidate all entries whose destination URL matches (TIM-1454).
459
+ * Used by `revalidatePath` on a non-current route so the next navigation
460
+ * fetches fresh data instead of replaying a stale prefetch.
461
+ */
462
+ invalidateUrl(url: string): void {
463
+ for (const [mapKey] of this.entries) {
464
+ if (mapKey.endsWith(':' + url)) {
465
+ this.entries.delete(mapKey);
466
+ }
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Evict all ready entries and fence in-flight singleflights (TIM-1476).
472
+ * Called after a server action that revalidated data — the mutation may
473
+ * affect any route, not just the paths the server named. In-flight
474
+ * singleflights are not aborted (they may carry post-mutation data),
475
+ * but their results are discarded if they complete after eviction:
476
+ * `fetchOrCoalesce` captures the generation before the flight and
477
+ * skips the store when it has been bumped.
478
+ *
479
+ * Consumers that join an in-flight flight via `joinInflight` must
480
+ * check `getEvictionGen()` before and after the await: if the gen
481
+ * changed, the result predates the eviction and must be discarded
482
+ * (codex on #1131 round 2).
483
+ */
484
+ clearReady(): void {
485
+ this.entries.clear();
486
+ this.evictionGen++;
487
+ }
488
+
489
+ getEvictionGen(): number {
490
+ return this.evictionGen;
491
+ }
459
492
  }
@@ -69,3 +69,29 @@ export let unloading = false;
69
69
  export function _setUnloading(value: boolean): void {
70
70
  unloading = value;
71
71
  }
72
+
73
+ // ─── Status Page Commit Flag (from navigation-commit.ts) ────────────────────
74
+
75
+ /**
76
+ * Whether a status page for the main route has rendered since the last
77
+ * navigation was prepared. Raised by `StatusPageMarker`, consumed by the
78
+ * navigation's commit. See navigation-commit.ts §"What a status page
79
+ * teaches the cache".
80
+ */
81
+ export let statusPageRendered = false;
82
+
83
+ export function _setStatusPageRendered(value: boolean): void {
84
+ statusPageRendered = value;
85
+ }
86
+
87
+ /**
88
+ * What a status page report does beyond raising the flag: registered by the
89
+ * router's committer so the report can clear the segment cache at once, for
90
+ * the commits no navigation consumes (a row arriving inside Suspense after
91
+ * its navigation published, a status document being hydrated).
92
+ */
93
+ export let onStatusPageRendered: (() => void) | undefined;
94
+
95
+ export function _setOnStatusPageRendered(handler: (() => void) | undefined): void {
96
+ onStatusPageRendered = handler;
97
+ }
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * The one way a status page tells the router it is on screen.
5
+ *
6
+ * A status page replaces a subtree, and the segment cache must not learn a
7
+ * chain that never mounted (see `navigation-commit.ts` §"What a status page
8
+ * teaches the cache"). Status pages are produced in three places — an
9
+ * in-tree catch site rendering the page it owns, the server's fallback
10
+ * renderers, and a client boundary rendering its fallback for a Flight row —
11
+ * and every one of them wraps the page in this marker, so the report does
12
+ * not depend on which producer it was. In particular the in-tree case, the
13
+ * common `access.ts` deny, never involves a client boundary at all.
14
+ *
15
+ * A layout effect, on every commit that renders the marker rather than on
16
+ * mount only: React reuses the fiber when one status page is replaced by
17
+ * another at the same position, and the second one must report too. Layout
18
+ * effects run child-before-parent, so the report lands before the ancestor
19
+ * `NavigationRoot` publishes the same commit, and never for a render React
20
+ * discards. Idempotent, so StrictMode's double invocation and a status page
21
+ * re-rendering on its own state are harmless. See TIM-1495.
22
+ */
23
+
24
+ import { useLayoutEffect, type ReactNode } from 'react';
25
+ import { reportStatusPageRendered } from './navigation-commit.ts';
26
+
27
+ export function StatusPageMarker({ children }: { children: ReactNode }): ReactNode {
28
+ useLayoutEffect(() => {
29
+ reportStatusPageRendered();
30
+ });
31
+ return children;
32
+ }
@@ -14,7 +14,7 @@
14
14
  * The holding server is started in rootSync's config() hook (the
15
15
  * earliest Vite plugin lifecycle point where we know the port) and
16
16
  * closed directly in timber-dev-server's configureServer() hook
17
- * (the last plugin, fire-and-forget). configureServer runs during
17
+ * (the last plugin, awaited). configureServer runs during
18
18
  * createServer(), well before Vite's port probe and bind, so the
19
19
  * port is free by the time httpServerStart() runs. (TIM-1419)
20
20
  *
@@ -121,22 +121,9 @@ const HOLDING_PAGE_HTML = [
121
121
  /**
122
122
  * Create a holding HTTP server that serves a loading page during startup.
123
123
  *
124
- * Usage (inside Vite plugin):
125
- * ```ts
126
- * // In config() hook — earliest point where port is known.
127
- * // Use bindWithBump() to honor the default-3000 + auto-bump policy
128
- * // (see ../server/port-resolution.ts and TIM-842).
129
- * const holding = createHoldingServer();
130
- * await bindWithBump((p) => holding.listen(p), { startPort: 3000, autoBump: true });
131
- *
132
- * // In last plugin's configureServer() — close directly so Vite 8's
133
- * // isPortAvailable() probe and httpServer.listen() both see a free
134
- * // port. SO_REUSEADDR allows immediate rebind. (TIM-1419)
135
- * if (ctx.holdingServer) {
136
- * ctx.holdingServer.close().catch(() => {});
137
- * ctx.holdingServer = null;
138
- * }
139
- * ```
124
+ * The config hook attempts one bind on the requested port. If it fails,
125
+ * startup continues without the holding page; Vite owns port selection.
126
+ * The last configureServer hook awaits close() before Vite probes/binds.
140
127
  */
141
128
  export function createHoldingServer(): HoldingServer {
142
129
  const server = http.createServer((req, res) => {
package/src/index.ts CHANGED
@@ -50,7 +50,7 @@ import {
50
50
  shouldEnableEncryption,
51
51
  } from './server/action-encryption.ts';
52
52
  import { createHoldingServer } from './dev-tools/holding-server.ts';
53
- import { resolveStartPort, startDevServerPort } from './server/port-resolution.ts';
53
+ import { resolveStartPort } from './server/port-resolution.ts';
54
54
  import type { TimberUserConfig } from './config-types.ts';
55
55
  import type { PluginContext, TimberPluginApi } from './plugin-context.ts';
56
56
  import {
@@ -319,13 +319,10 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
319
319
  // override below fixes Vite's native path; plugin-react's compiler
320
320
  // plugin is handled by patchReactCompilerForProdJsx above.
321
321
  // ── Resolve dev/preview port (TIM-842) ───────────────────────
322
- // Default port is 3000 with auto-bump on conflict. Explicit user
323
- // overrides (`--port`, `PORT` env, `vite.config.ts` server.port)
324
- // are honored as-is and fail loudly via `strictPort: true`.
322
+ // Default port is 3000. Vite handles conflicts and honors server.strictPort.
325
323
  //
326
324
  // For dev (`command === 'serve' && !isPreview`) we start the
327
- // holding server and use its `listen()` as the probe, pairing
328
- // the probe with the real bind to eliminate TOCTOU races.
325
+ // holding server on the requested port when available.
329
326
  //
330
327
  // For `vite preview` (`command === 'serve' && isPreview`) we
331
328
  // resolve the config/env port but delegate the actual bind +
@@ -354,31 +351,20 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
354
351
  ? undefined
355
352
  : 'localhost';
356
353
  if (command === 'serve' && !isPreview && !isMiddlewareMode) {
357
- ctx.holdingServer = createHoldingServer();
358
- const holdingRef = ctx.holdingServer;
359
- const result = await startDevServerPort({
360
- configPort:
361
- typeof userConfig.server?.port === 'number' ? userConfig.server.port : undefined,
354
+ const start = resolveStartPort({
355
+ configPort: userConfig.server?.port,
362
356
  envPort: process.env.PORT,
363
- listen: (p) => holdingRef.listen(p, resolvedHost),
364
357
  });
365
- if (!result.bound) {
366
- // Holding server failed to bind. Drop the reference so the
367
- // dev-server plugin doesn't try to close a server that was
368
- // never listening.
369
- ctx.holdingServer = null;
370
- }
371
- if (result.bound) {
372
- // Forward the probed port so Vite starts there. strictPort
373
- // matches explicit — explicit ports fail loudly on a race
374
- // between close and bind; implicit ones let Vite re-bump.
375
- resolvedDevPort = result.port;
376
- resolvedDevPortExplicit = result.explicit;
377
- } else if (result.explicit) {
378
- // Explicit port failed to bind (another process holds it).
379
- // Forward it as the starting point but without strictPort
380
- // so Vite auto-bumps to the next free port. TIM-1419.
381
- resolvedDevPort = result.port;
358
+ // Vite owns port selection and remembers its bound port on restart.
359
+ // Never forward a probed/bumped port: replacement config runs while
360
+ // the old listener still owns the port. The holding page is optional.
361
+ resolvedDevPort = start.port;
362
+ resolvedDevPortExplicit = userConfig.server?.strictPort ?? false;
363
+ if (start.port !== 0) {
364
+ ctx.holdingServer = createHoldingServer();
365
+ await ctx.holdingServer.listen(start.port, resolvedHost).catch(() => {
366
+ ctx.holdingServer = null;
367
+ });
382
368
  }
383
369
  } else if (command === 'serve' && isPreview) {
384
370
  // Preview mode: resolve the starting port from config/env/
@@ -445,11 +431,9 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
445
431
  // Dev/preview mode: set outDir so dev-time references are
446
432
  // consistent, and surface the resolved port to Vite.
447
433
  //
448
- // For dev with an explicit port, we set strictPort: true so Vite
449
- // fails loudly on conflict. For auto-bumped (implicit) ports, we
450
- // leave strictPort false so Vite can re-bump if the holding server's
451
- // port becomes unavailable between close and bind (e.g., a second
452
- // app instance starting on the same default port). TIM-1419.
434
+ // Dev auto-bumps unless the user sets server.strictPort.
435
+ // Always forward the requested port so Vite can recognize unchanged
436
+ // config and reuse its actual bound port across restarts (TIM-1456).
453
437
  //
454
438
  // For `vite preview`, we set `preview.strictPort` based on
455
439
  // whether the user explicitly set the port. Implicit (default)