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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/dist/_chunks/{actions-BS-m5SLv.js → actions-d1hCqnU3.js} +35 -8
  2. package/dist/_chunks/actions-d1hCqnU3.js.map +1 -0
  3. package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -1
  4. package/dist/_chunks/{cache-api-DqzgTEqk.js → cache-api-ByagcC-J.js} +2 -2
  5. package/dist/_chunks/{cache-api-DqzgTEqk.js.map → cache-api-ByagcC-J.js.map} +1 -1
  6. package/dist/_chunks/canonicalize-CgHoscYO.js +66 -0
  7. package/dist/_chunks/canonicalize-CgHoscYO.js.map +1 -0
  8. package/dist/_chunks/{chains-CZG7E5zg.js → chains-Bpb0W4ax.js} +3 -3
  9. package/dist/_chunks/{chains-CZG7E5zg.js.map → chains-Bpb0W4ax.js.map} +1 -1
  10. package/dist/_chunks/{cli-check-dVDi1GQz.js → cli-check-D6VolrDV.js} +3 -3
  11. package/dist/_chunks/{cli-check-dVDi1GQz.js.map → cli-check-D6VolrDV.js.map} +1 -1
  12. package/dist/_chunks/{cli-schema-sync-DTy_-Msq.js → cli-schema-sync-D6rO-VcS.js} +2 -2
  13. package/dist/_chunks/{cli-schema-sync-DTy_-Msq.js.map → cli-schema-sync-D6rO-VcS.js.map} +1 -1
  14. package/dist/_chunks/{convention-lint-Ph6luW4c.js → convention-lint-fRkwVwEH.js} +25 -4
  15. package/dist/_chunks/convention-lint-fRkwVwEH.js.map +1 -0
  16. package/dist/_chunks/error-boundary-BfPHZjm0.js +1050 -0
  17. package/dist/_chunks/error-boundary-BfPHZjm0.js.map +1 -0
  18. package/dist/_chunks/{live-graph-BXDsdzBv.js → live-graph-D_2D32Ad.js} +3 -3
  19. package/dist/_chunks/{live-graph-BXDsdzBv.js.map → live-graph-D_2D32Ad.js.map} +1 -1
  20. package/dist/_chunks/{logger-DDirEsn7.js → logger-uLBuGKDI.js} +471 -440
  21. package/dist/_chunks/logger-uLBuGKDI.js.map +1 -0
  22. package/dist/_chunks/{navigation-root-B00jjGd5.js → navigation-context-D0TU0Jog.js} +3 -101
  23. package/dist/_chunks/navigation-context-D0TU0Jog.js.map +1 -0
  24. package/dist/_chunks/navigation-root-mHSK9psY.js +126 -0
  25. package/dist/_chunks/{navigation-root-B00jjGd5.js.map → navigation-root-mHSK9psY.js.map} +1 -1
  26. package/dist/_chunks/{poison-scan-BoDLgbix.js → poison-scan-Bm9Yyqk9.js} +2 -2
  27. package/dist/_chunks/{poison-scan-BoDLgbix.js.map → poison-scan-Bm9Yyqk9.js.map} +1 -1
  28. package/dist/_chunks/{scanner-tdFPvDYi.js → scanner-AiazgH_f.js} +6 -5
  29. package/dist/_chunks/scanner-AiazgH_f.js.map +1 -0
  30. package/dist/_chunks/{segment-keys-BhqoHiLc.js → segment-keys-lqtdookO.js} +2 -65
  31. package/dist/_chunks/segment-keys-lqtdookO.js.map +1 -0
  32. package/dist/_chunks/{ssr-data-BQGhTPAK.js → ssr-data-D6T6Y3ef.js} +4 -26
  33. package/dist/_chunks/ssr-data-D6T6Y3ef.js.map +1 -0
  34. package/dist/_chunks/state-FippDgxN.js +52 -0
  35. package/dist/_chunks/state-FippDgxN.js.map +1 -0
  36. package/dist/_chunks/status-page-marker-DwQBrLBz.js +496 -0
  37. package/dist/_chunks/status-page-marker-DwQBrLBz.js.map +1 -0
  38. package/dist/_chunks/{walkers-DNX05dC0.js → walkers-B6XUtmqK.js} +2 -2
  39. package/dist/_chunks/{walkers-DNX05dC0.js.map → walkers-B6XUtmqK.js.map} +1 -1
  40. package/dist/analyze/crawl-entry.js +2 -2
  41. package/dist/analyze/graph-command.js +2 -2
  42. package/dist/cache/index.js +1 -1
  43. package/dist/cli.js +2 -2
  44. package/dist/client/browser-entry/action-dispatch.d.ts +6 -4
  45. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  46. package/dist/client/browser-entry/action-queue.d.ts +44 -0
  47. package/dist/client/browser-entry/action-queue.d.ts.map +1 -0
  48. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  49. package/dist/client/deny-last-resort.d.ts +29 -0
  50. package/dist/client/deny-last-resort.d.ts.map +1 -0
  51. package/dist/client/error-boundary.d.ts +48 -2
  52. package/dist/client/error-boundary.d.ts.map +1 -1
  53. package/dist/client/error-boundary.js +2 -2
  54. package/dist/client/history.d.ts +21 -2
  55. package/dist/client/history.d.ts.map +1 -1
  56. package/dist/client/index.js +34 -14
  57. package/dist/client/index.js.map +1 -1
  58. package/dist/client/internal.d.ts +1 -0
  59. package/dist/client/internal.d.ts.map +1 -1
  60. package/dist/client/internal.js +272 -1225
  61. package/dist/client/internal.js.map +1 -1
  62. package/dist/client/link.d.ts.map +1 -1
  63. package/dist/client/navigation-commit.d.ts +12 -19
  64. package/dist/client/navigation-commit.d.ts.map +1 -1
  65. package/dist/client/navigation-transition.d.ts +62 -11
  66. package/dist/client/navigation-transition.d.ts.map +1 -1
  67. package/dist/client/router-effects.d.ts +9 -8
  68. package/dist/client/router-effects.d.ts.map +1 -1
  69. package/dist/client/router-lifecycle.d.ts +60 -17
  70. package/dist/client/router-lifecycle.d.ts.map +1 -1
  71. package/dist/client/router-pipeline.d.ts +7 -4
  72. package/dist/client/router-pipeline.d.ts.map +1 -1
  73. package/dist/client/router-types.d.ts +63 -8
  74. package/dist/client/router-types.d.ts.map +1 -1
  75. package/dist/client/router.d.ts.map +1 -1
  76. package/dist/client/rsc-fetch.d.ts +0 -9
  77. package/dist/client/rsc-fetch.d.ts.map +1 -1
  78. package/dist/client/segment-cache.d.ts +23 -8
  79. package/dist/client/segment-cache.d.ts.map +1 -1
  80. package/dist/client/state.d.ts +16 -0
  81. package/dist/client/state.d.ts.map +1 -1
  82. package/dist/client/status-page-marker.d.ts +25 -0
  83. package/dist/client/status-page-marker.d.ts.map +1 -0
  84. package/dist/cookies/index.js +1 -1
  85. package/dist/dev-tools/holding-server.d.ts +4 -17
  86. package/dist/dev-tools/holding-server.d.ts.map +1 -1
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +74 -175
  89. package/dist/index.js.map +1 -1
  90. package/dist/plugins/dev-server.d.ts.map +1 -1
  91. package/dist/routing/index.js +2 -2
  92. package/dist/routing/interception.d.ts +2 -2
  93. package/dist/routing/slot-placement.d.ts +2 -2
  94. package/dist/server/access-gate.d.ts +73 -1
  95. package/dist/server/access-gate.d.ts.map +1 -1
  96. package/dist/server/action-handler.d.ts.map +1 -1
  97. package/dist/server/actions.d.ts +16 -1
  98. package/dist/server/actions.d.ts.map +1 -1
  99. package/dist/server/als-registry.d.ts +3 -9
  100. package/dist/server/als-registry.d.ts.map +1 -1
  101. package/dist/server/children-interception.d.ts +1 -1
  102. package/dist/server/default-status-page.d.ts +2 -2
  103. package/dist/server/default-status-page.d.ts.map +1 -1
  104. package/dist/server/deny-boundary.d.ts +15 -9
  105. package/dist/server/deny-boundary.d.ts.map +1 -1
  106. package/dist/server/deny-renderer.d.ts.map +1 -1
  107. package/dist/server/error-boundary-wrapper.d.ts +21 -4
  108. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  109. package/dist/server/error-response-headers.d.ts +3 -0
  110. package/dist/server/error-response-headers.d.ts.map +1 -0
  111. package/dist/server/index.js +3 -3
  112. package/dist/server/index.js.map +1 -1
  113. package/dist/server/internal.d.ts +1 -2
  114. package/dist/server/internal.d.ts.map +1 -1
  115. package/dist/server/internal.js +2339 -2506
  116. package/dist/server/internal.js.map +1 -1
  117. package/dist/server/metadata-collector.d.ts +2 -5
  118. package/dist/server/metadata-collector.d.ts.map +1 -1
  119. package/dist/server/param-coercion.d.ts +10 -3
  120. package/dist/server/param-coercion.d.ts.map +1 -1
  121. package/dist/server/pipeline-outcome.d.ts.map +1 -1
  122. package/dist/server/pipeline-phases.d.ts +11 -0
  123. package/dist/server/pipeline-phases.d.ts.map +1 -1
  124. package/dist/server/port-resolution.d.ts +3 -89
  125. package/dist/server/port-resolution.d.ts.map +1 -1
  126. package/dist/server/primitives.d.ts +38 -10
  127. package/dist/server/primitives.d.ts.map +1 -1
  128. package/dist/server/response-cache-policy.d.ts +3 -0
  129. package/dist/server/response-cache-policy.d.ts.map +1 -0
  130. package/dist/server/route-element-builder.d.ts +11 -41
  131. package/dist/server/route-element-builder.d.ts.map +1 -1
  132. package/dist/server/route-element-helpers.d.ts +12 -0
  133. package/dist/server/route-element-helpers.d.ts.map +1 -0
  134. package/dist/server/route-module-loader.d.ts +37 -0
  135. package/dist/server/route-module-loader.d.ts.map +1 -0
  136. package/dist/server/rsc-cache-key-guard.d.ts.map +1 -1
  137. package/dist/server/rsc-entry/action-middleware-runner.d.ts.map +1 -1
  138. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  139. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  140. package/dist/server/rsc-entry/rsc-payload.d.ts +22 -1
  141. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  142. package/dist/server/rsc-entry/rsc-stream.d.ts +4 -11
  143. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  144. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  145. package/dist/server/rsc-error-envelope.d.ts +11 -0
  146. package/dist/server/rsc-error-envelope.d.ts.map +1 -0
  147. package/dist/server/skippable-prefix.d.ts +18 -15
  148. package/dist/server/skippable-prefix.d.ts.map +1 -1
  149. package/dist/server/slot-resolver.d.ts.map +1 -1
  150. package/dist/server/slot-subtree-contain.d.ts +54 -0
  151. package/dist/server/slot-subtree-contain.d.ts.map +1 -0
  152. package/dist/server/stream-utils.d.ts.map +1 -1
  153. package/dist/server/utils/element-type.d.ts +10 -0
  154. package/dist/server/utils/element-type.d.ts.map +1 -1
  155. package/dist/shared/rsc-error-envelope.d.ts +0 -9
  156. package/dist/shared/rsc-error-envelope.d.ts.map +1 -1
  157. package/dist/shared/status-reason-phrase.d.ts +26 -0
  158. package/dist/shared/status-reason-phrase.d.ts.map +1 -0
  159. package/docs/api/30-api-server.mdx +4 -2
  160. package/docs/api/31-api-client.mdx +5 -1
  161. package/docs/api/36-cli.mdx +5 -3
  162. package/docs/learn/12-error-handling.mdx +5 -1
  163. package/package.json +10 -10
  164. package/src/client/browser-entry/action-dispatch.ts +166 -99
  165. package/src/client/browser-entry/action-queue.ts +90 -0
  166. package/src/client/browser-entry/router-init.ts +60 -35
  167. package/src/client/deny-last-resort.tsx +54 -0
  168. package/src/client/error-boundary.tsx +144 -42
  169. package/src/client/history.ts +52 -3
  170. package/src/client/internal.ts +1 -0
  171. package/src/client/link.tsx +70 -35
  172. package/src/client/navigation-commit.ts +79 -27
  173. package/src/client/navigation-transition.ts +176 -127
  174. package/src/client/router-effects.ts +14 -17
  175. package/src/client/router-lifecycle.ts +181 -115
  176. package/src/client/router-pipeline.ts +94 -71
  177. package/src/client/router-types.ts +61 -7
  178. package/src/client/router.ts +147 -74
  179. package/src/client/rsc-fetch.ts +0 -13
  180. package/src/client/segment-cache.ts +43 -10
  181. package/src/client/state.ts +26 -0
  182. package/src/client/status-page-marker.tsx +32 -0
  183. package/src/dev-tools/holding-server.ts +4 -17
  184. package/src/index.ts +18 -34
  185. package/src/plugins/dev-server.ts +2 -1
  186. package/src/routing/interception.ts +2 -2
  187. package/src/routing/slot-placement.ts +2 -2
  188. package/src/server/access-gate.tsx +89 -21
  189. package/src/server/action-client.ts +2 -2
  190. package/src/server/action-handler.ts +23 -10
  191. package/src/server/actions.ts +81 -34
  192. package/src/server/als-registry.ts +3 -9
  193. package/src/server/children-interception.ts +1 -1
  194. package/src/server/default-status-page.ts +7 -47
  195. package/src/server/deny-boundary.ts +45 -28
  196. package/src/server/deny-renderer.ts +6 -2
  197. package/src/server/error-boundary-wrapper.ts +23 -4
  198. package/src/server/error-response-headers.ts +18 -0
  199. package/src/server/internal.ts +2 -10
  200. package/src/server/metadata-collector.ts +3 -18
  201. package/src/server/param-coercion.ts +13 -4
  202. package/src/server/pipeline-outcome.ts +35 -13
  203. package/src/server/pipeline-phases.ts +22 -16
  204. package/src/server/port-resolution.ts +3 -165
  205. package/src/server/prebuilt-builder.ts +4 -4
  206. package/src/server/primitives.ts +75 -11
  207. package/src/server/response-cache-policy.ts +45 -0
  208. package/src/server/route-element-builder.ts +149 -412
  209. package/src/server/route-element-helpers.ts +37 -0
  210. package/src/server/route-handler.ts +2 -2
  211. package/src/server/route-module-loader.ts +161 -0
  212. package/src/server/rsc-cache-key-guard.ts +2 -42
  213. package/src/server/rsc-entry/action-middleware-runner.ts +4 -4
  214. package/src/server/rsc-entry/api-handler.ts +5 -5
  215. package/src/server/rsc-entry/error-renderer.ts +3 -4
  216. package/src/server/rsc-entry/helpers.ts +1 -1
  217. package/src/server/rsc-entry/index.ts +3 -3
  218. package/src/server/rsc-entry/render-route.ts +4 -8
  219. package/src/server/rsc-entry/rsc-payload.ts +59 -42
  220. package/src/server/rsc-entry/rsc-stream.ts +48 -27
  221. package/src/server/rsc-entry/ssr-renderer.ts +6 -10
  222. package/src/server/rsc-error-envelope.ts +18 -0
  223. package/src/server/skippable-prefix.ts +105 -7
  224. package/src/server/slot-resolver.ts +43 -12
  225. package/src/server/slot-subtree-contain.ts +255 -0
  226. package/src/server/stream-utils.ts +12 -8
  227. package/src/server/utils/element-type.ts +18 -2
  228. package/src/shared/rsc-error-envelope.ts +0 -15
  229. package/src/shared/status-reason-phrase.ts +61 -0
  230. package/dist/_chunks/actions-BS-m5SLv.js.map +0 -1
  231. package/dist/_chunks/convention-lint-Ph6luW4c.js.map +0 -1
  232. package/dist/_chunks/error-boundary-BvRCCmbN.js +0 -353
  233. package/dist/_chunks/error-boundary-BvRCCmbN.js.map +0 -1
  234. package/dist/_chunks/logger-DDirEsn7.js.map +0 -1
  235. package/dist/_chunks/mdx-file-CXyHGUpS.js +0 -25
  236. package/dist/_chunks/mdx-file-CXyHGUpS.js.map +0 -1
  237. package/dist/_chunks/router-ref-8gr8qsxN.js +0 -28
  238. package/dist/_chunks/router-ref-8gr8qsxN.js.map +0 -1
  239. package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js +0 -40
  240. package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js.map +0 -1
  241. package/dist/_chunks/scanner-tdFPvDYi.js.map +0 -1
  242. package/dist/_chunks/segment-keys-BhqoHiLc.js.map +0 -1
  243. package/dist/_chunks/ssr-data-BQGhTPAK.js.map +0 -1
  244. package/dist/server/tree-builder.d.ts +0 -150
  245. package/dist/server/tree-builder.d.ts.map +0 -1
  246. package/src/server/tree-builder.ts +0 -313
@@ -6,10 +6,12 @@
6
6
  * The RSC plugin delegates to `globalThis.__viteRscCallServer` which is
7
7
  * set by `setServerCallback`.
8
8
  *
9
- * The callback:
10
- * 1. Serializes args via `encodeReply` (RSC wire format)
11
- * 2. POSTs to the current URL with `Accept: text/x-component`
12
- * 3. Decodes the RSC response stream
9
+ * Actions are dispatched sequentially through a client-wide queue
10
+ * (TIM-1475): each action's POST starts only after the previous one has
11
+ * settled and its result has been applied. Navigations do not enter the
12
+ * queue — they preempt via the NavigationEpoch, marking any running
13
+ * action as discarded (TIM-1474). Queries (TIM-1415) will bypass the
14
+ * queue entirely.
13
15
  *
14
16
  * See design/08-forms-and-actions.md §"Client-Side Form Mechanics"
15
17
  */
@@ -20,111 +22,176 @@ import { setHardNavigating } from '../navigation-root.tsx';
20
22
  import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../rsc-fetch.ts';
21
23
  import { markClientStale } from '../stale-client.ts';
22
24
  import { RSC_CONTENT_TYPE } from '../../shared/rsc-media-type.ts';
25
+ import { createActionQueue } from './action-queue.ts';
26
+
27
+ /**
28
+ * Wait for a navigation or refresh to both settle (promise resolved) AND
29
+ * commit (pushState / renderPayload's commit thunk ran). The queue must
30
+ * not release the next action until the URL is correct in the address bar
31
+ * — same rule `<Link>` uses for `isPending` (design/19 §Per-Link Pending
32
+ * State). `onCommit` fires exactly once: on commit, supersession, or
33
+ * failure, so nothing awaiting this can hang (codex on #1125).
34
+ */
35
+ function settledAndCommitted(start: (onCommit: () => void) => Promise<void>): Promise<void> {
36
+ let commitResolve!: () => void;
37
+ const committed = new Promise<void>((r) => (commitResolve = r));
38
+ return Promise.all([start(commitResolve).catch(() => {}), committed]).then(() => {});
39
+ }
40
+
23
41
  export function setupServerActions(): void {
24
- setServerCallback(async (id: string, args: unknown[]) => {
25
- const body = await encodeReply(args);
26
-
27
- // Track the X-Timber-Revalidation header from the response.
28
- // We intercept the fetch promise to read headers before createFromFetch
29
- // consumes the body stream.
30
- let hasRevalidation = false;
31
- let hasRedirect = false;
32
-
33
- // Build action request headers. Include deployment ID for version
34
- // skew detection (TIM-446) — the server rejects stale actions gracefully.
35
- const actionHeaders: Record<string, string> = {
36
- 'Accept': RSC_CONTENT_TYPE,
37
- 'x-rsc-action': id,
38
- };
39
- const actionDeploymentId = getClientDeploymentId();
40
- if (actionDeploymentId) {
41
- actionHeaders[DEPLOYMENT_ID_HEADER] = actionDeploymentId;
42
- }
43
-
44
- const response = fetch(window.location.href, {
45
- method: 'POST',
46
- headers: actionHeaders,
47
- body,
48
- }).then((res) => {
49
- // Version skew detection (TIM-446): the server has signalled that this
50
- // client is stale.
51
- if (res.headers.get(RELOAD_HEADER) === '1') {
52
- // Record it and fail the action. No page load: the action cannot
53
- // succeed either way (its ID does not exist in the new build), and
54
- // tearing the document down here would destroy whatever the user had
55
- // typed into the form. Their next navigation recovers (TIM-1275).
56
- markClientStale();
57
- throw new Error('Version skew detected — this client is out of date');
42
+ const queue = createActionQueue({
43
+ refreshWhenIdle(): void {
44
+ const router = getRouterOrNull();
45
+ if (!router) return;
46
+ router.runWhenIdle(() => void router.refresh());
47
+ },
48
+ });
49
+
50
+ setServerCallback((id: string, args: unknown[]) => {
51
+ return queue.run(async ({ hold }) => {
52
+ // encodeReply inside the queue — preserves call order when two
53
+ // calls' args serialize in different numbers of microtasks.
54
+ const body = await encodeReply(args);
55
+
56
+ // Capture the epoch AFTER the previous action has settled and
57
+ // applied, so it reflects the router's real state — not stale
58
+ // from a predecessor's refresh bumping the seq (TIM-1474).
59
+ const router = getRouterOrNull();
60
+ const epoch = router?.epoch();
61
+
62
+ let hasRevalidation = false;
63
+ let hasRedirect = false;
64
+ const reval: { paths: string | null } = { paths: null };
65
+
66
+ const actionHeaders: Record<string, string> = {
67
+ 'Accept': RSC_CONTENT_TYPE,
68
+ 'x-rsc-action': id,
69
+ };
70
+ const actionDeploymentId = getClientDeploymentId();
71
+ if (actionDeploymentId) {
72
+ actionHeaders[DEPLOYMENT_ID_HEADER] = actionDeploymentId;
58
73
  }
59
- hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
60
- hasRedirect = res.headers.get('X-Timber-Redirect') != null;
61
- return res;
62
- });
63
74
 
64
- const decoded = await createFromFetch(response);
65
-
66
- // Handle redirect — server encoded the redirect location in the RSC stream
67
- // instead of returning HTTP 302. Perform a client-side SPA navigation.
68
- if (hasRedirect) {
69
- const wrapper = decoded as { _redirect: string; _status: number };
70
-
71
- // External redirects (e.g., OAuth flows via redirectExternal()) must
72
- // use a full page navigation — the RSC router can only fetch same-origin
73
- // payloads. On browsers with the Navigation API, navigation.navigate()
74
- // happens to perform a real cross-origin navigation, but browsers
75
- // without it (iPad Safari) would attempt an RSC fetch that fails on CORS.
76
- const isExternal =
77
- wrapper._redirect.startsWith('https://') || wrapper._redirect.startsWith('http://');
78
- if (isExternal) {
79
- setHardNavigating(true);
80
- window.location.href = wrapper._redirect;
81
- return undefined;
75
+ const response = fetch(window.location.href, {
76
+ method: 'POST',
77
+ headers: actionHeaders,
78
+ body,
79
+ }).then((res) => {
80
+ if (res.headers.get(RELOAD_HEADER) === '1') {
81
+ markClientStale();
82
+ throw new Error('Version skew detected — this client is out of date');
83
+ }
84
+ hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
85
+ hasRedirect = res.headers.get('X-Timber-Redirect') != null;
86
+ reval.paths = res.headers.get('X-Timber-Revalidation-Path');
87
+ return res;
88
+ });
89
+
90
+ const decoded = await createFromFetch(response);
91
+
92
+ // TIM-1454: Invalidate non-matching paths unconditionally — the
93
+ // mutation happened server-side regardless of navigation state.
94
+ if (reval.paths) {
95
+ if (router) {
96
+ for (const p of reval.paths.split(' ')) {
97
+ router.invalidatePath(p);
98
+ }
99
+ }
82
100
  }
83
101
 
84
- try {
85
- const router = getRouter();
86
- void router.navigate(wrapper._redirect);
87
- } catch (e) {
88
- // Router not yet initialized — fall back to full navigation.
89
- // Set hard-navigating flag to prevent Navigation API interception
90
- // and React from rendering during page teardown. See TIM-626.
91
- console.debug(
92
- '[timber] action redirect: router not ready, using full navigation',
93
- e instanceof Error ? e.message : e
94
- );
95
- setHardNavigating(true);
96
- window.location.href = wrapper._redirect;
102
+ // TIM-1476: When the server reports any revalidation, evict all
103
+ // prefetch cache entries and history stack payloads — the mutation
104
+ // may affect data any route reads, not just the paths the server
105
+ // named. Placed before the redirect branch so an internal redirect
106
+ // via router.navigate() cannot consume a stale prefetch (codex on
107
+ // #1131). In-flight prefetch singleflights are fenced by the
108
+ // eviction generation and will not repopulate the cache.
109
+ if (router && (hasRevalidation || reval.paths)) {
110
+ router.evictStaleCaches();
97
111
  }
98
- return undefined;
99
- }
100
112
 
101
- if (hasRevalidation) {
102
- // Piggybacked response: wrapper object { _action, _tree }
103
- // Apply the revalidated tree directly — no separate router.refresh() needed.
104
- const wrapper = decoded as { _action: unknown; _tree: unknown };
105
- const router = getRouterOrNull();
106
- if (router) {
113
+ // Redirects apply unconditionally — the server mutated and told
114
+ // the client where to go. Return undefined to React NOW so the
115
+ // action scope settles — awaiting the commit inline would deadlock:
116
+ // React entangles the commit lane with the action scope, so the
117
+ // commit waits on the action while the action waits on the commit.
118
+ // The navigate is fire-and-forget: unlike revalidation (where the
119
+ // next action must capture the refreshed epoch), a redirect leaves
120
+ // the page entirely — no subsequent action runs on this URL.
121
+ if (hasRedirect) {
122
+ const wrapper = decoded as { _redirect: string; _status: number };
123
+ const isExternal =
124
+ wrapper._redirect.startsWith('https://') || wrapper._redirect.startsWith('http://');
125
+ if (isExternal) {
126
+ setHardNavigating(true);
127
+ window.location.href = wrapper._redirect;
128
+ // Never settle — the document is going away and queued actions
129
+ // must not resume during page teardown (codex on #1125).
130
+ return new Promise<undefined>(() => {});
131
+ }
107
132
  try {
108
- router.applyRevalidation(wrapper._tree);
133
+ const r = getRouter();
134
+ void r.navigate(wrapper._redirect);
109
135
  } catch (e) {
110
- console.error('[timber] applyRevalidation failed after server action', e);
136
+ console.debug(
137
+ '[timber] action redirect: router not ready, using full navigation',
138
+ e instanceof Error ? e.message : e
139
+ );
140
+ setHardNavigating(true);
141
+ window.location.href = wrapper._redirect;
142
+ }
143
+ return undefined;
144
+ }
145
+
146
+ if (hasRevalidation) {
147
+ const wrapper = decoded as { _action: unknown; _tree: unknown };
148
+ // Return the action result to React NOW — it may be validation errors
149
+ // the form needs immediately — and hold the queue until the piggybacked
150
+ // tree commits. Awaiting applyActionResult inline would deadlock: the
151
+ // action promise settles only after the commit, but React entangles the
152
+ // commit lane with the action scope, so the commit waits on the action.
153
+ // Same pattern as the no-tree branch below (codex on #1126).
154
+ if (router && epoch) {
155
+ hold(
156
+ Promise.resolve()
157
+ .then(() => router.applyActionResult(epoch, wrapper._tree))
158
+ .then(
159
+ (applied) => {
160
+ if (!applied) queue.markNeedsRefresh();
161
+ },
162
+ (e: unknown) => {
163
+ console.error('[timber] applyActionResult failed after server action', e);
164
+ }
165
+ )
166
+ );
111
167
  }
168
+ return wrapper._action;
169
+ }
170
+
171
+ // revalidatePath was called but only for non-current paths —
172
+ // caches were already invalidated above, no refresh needed.
173
+ if (reval.paths) {
174
+ return decoded;
175
+ }
176
+
177
+ // No piggybacked revalidation — return the result to React NOW
178
+ // (it may be validation errors the form needs immediately) and hold
179
+ // the queue until the refresh commits so the next action captures a
180
+ // current epoch (codex on #1125).
181
+ if (router && epoch) {
182
+ hold(
183
+ router.applyActionResult(epoch).then(
184
+ (applied) => {
185
+ if (!applied) queue.markNeedsRefresh();
186
+ },
187
+ (e: unknown) => {
188
+ console.debug('[timber] action refresh failed', e instanceof Error ? e.message : e);
189
+ }
190
+ )
191
+ );
112
192
  }
113
- return wrapper._action;
114
- }
115
-
116
- // No piggybacked revalidation — refresh to pick up any mutations.
117
- // This covers actions that don't call revalidatePath().
118
- try {
119
- const router = getRouter();
120
- void router.refresh();
121
- } catch (e) {
122
- console.debug(
123
- '[timber] action refresh: router not ready',
124
- e instanceof Error ? e.message : e
125
- );
126
- }
127
-
128
- return decoded;
193
+
194
+ return decoded;
195
+ });
129
196
  });
130
197
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Sequential Action Queue — serialises server action dispatch.
3
+ *
4
+ * One action at a time. The next action's POST starts only after the
5
+ * previous one has settled AND its result has been applied (revalidation
6
+ * rendered or refresh awaited). Navigations do not enter the queue —
7
+ * they preempt via the NavigationEpoch. Queries (TIM-1415) bypass it
8
+ * entirely.
9
+ *
10
+ * See design/08-forms-and-actions.md §"Client-Side Form Mechanics"
11
+ * and design/19-client-navigation.md §State Update Invariants (TIM-1475).
12
+ */
13
+
14
+ export interface ActionQueueDeps {
15
+ refreshWhenIdle: () => void;
16
+ }
17
+
18
+ const noop = (): void => {};
19
+
20
+ /**
21
+ * Passed into `work` so it can return a result to React immediately
22
+ * while keeping the queue blocked on a side effect (e.g. a refresh
23
+ * that must commit before the next action captures its epoch).
24
+ */
25
+ export interface ActionQueueRun {
26
+ hold: (p: Promise<unknown>) => void;
27
+ }
28
+
29
+ export interface ActionQueue {
30
+ /**
31
+ * Enqueue `work` to run after all preceding actions have settled.
32
+ * Returns `work`'s own promise so the caller (React) gets the action
33
+ * result or rejection. If the preceding action rejected, `work` still
34
+ * runs — errors do not break the chain.
35
+ *
36
+ * `work` receives a `hold` handle: calling `hold(promise)` keeps the
37
+ * queue blocked until that promise settles, independent of `work`'s
38
+ * return value. Use it for "return the result NOW, await the refresh
39
+ * commit for sequencing."
40
+ */
41
+ run: <T>(work: (q: ActionQueueRun) => Promise<T>) => Promise<T>;
42
+ /**
43
+ * Mark that a discarded action would have revalidated. When the queue
44
+ * drains, one refresh fires via `deps.refreshWhenIdle`.
45
+ */
46
+ markNeedsRefresh: () => void;
47
+ }
48
+
49
+ export function createActionQueue(deps: ActionQueueDeps): ActionQueue {
50
+ let tail: Promise<void> = Promise.resolve();
51
+ let needsRefresh = false;
52
+
53
+ function drain(): void {
54
+ if (!needsRefresh) return;
55
+ needsRefresh = false;
56
+ deps.refreshWhenIdle();
57
+ }
58
+
59
+ function run<T>(work: (q: ActionQueueRun) => Promise<T>): Promise<T> {
60
+ const holds: Promise<unknown>[] = [];
61
+ const hold = (p: Promise<unknown>): void => {
62
+ holds.push(p.then(noop, noop));
63
+ };
64
+ // `p` is the caller-visible promise — it resolves/rejects with work's
65
+ // result (React sees this). `settled` is the chain link: work settled
66
+ // AND every held promise settled. It never rejects, so the chain never
67
+ // breaks and the next action always runs.
68
+ const p = tail.then(
69
+ () => work({ hold }),
70
+ () => work({ hold })
71
+ );
72
+ const settled = p
73
+ .then(noop, noop)
74
+ .then(() => Promise.all(holds))
75
+ .then(noop);
76
+ tail = settled;
77
+ // Drain when the queue empties: tail identity means nobody enqueued
78
+ // behind this action between its start and its settlement.
79
+ void settled.then(() => {
80
+ if (tail === settled) drain();
81
+ });
82
+ return p;
83
+ }
84
+
85
+ function markNeedsRefresh(): void {
86
+ needsRefresh = true;
87
+ }
88
+
89
+ return { run, markNeedsRefresh };
90
+ }
@@ -20,7 +20,7 @@ import {
20
20
  getNavigationState,
21
21
  setNavigationState,
22
22
  } from '../navigation-context.ts';
23
- import { navigateTransition } from '../navigation-transition.ts';
23
+ import { navigateTransition, type RenderOwner } from '../navigation-transition.ts';
24
24
  import type { NavigationRender } from '../navigation-root.tsx';
25
25
  import { SegmentUpdateContext, EMPTY_SEGMENT_UPDATES } from '../segment-update-context.ts';
26
26
  import { SlotContentCacheContext, type SlotContentCache } from '../slot-content-cache-context.ts';
@@ -156,6 +156,11 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
156
156
  currentParams = params;
157
157
  }
158
158
 
159
+ // Late-bound: syncShallowSearch is defined before createRouter, but only
160
+ // called after the router exists (from patched pushState/replaceState or
161
+ // the Navigation API handler, both of which fire from user code).
162
+ let router: RouterInstance | null = null;
163
+
159
164
  // ─── History API Patch ──────────────────────────────────────────
160
165
  // Patch pushState/replaceState to detect external URL changes (nuqs
161
166
  // shallow updates, replaceUrl, third-party libraries). When the router
@@ -203,6 +208,21 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
203
208
  };
204
209
  setNavigationState(newNavState);
205
210
 
211
+ // A shallow search update displaces whatever React is rendering. If a
212
+ // handed-off tree is still suspended (e.g. a cached popstate replay whose
213
+ // client component is lazy-loading), this render replaces it and React
214
+ // will never commit it — so forgetOlderHandoffs never fires and
215
+ // hasUncommittedNav stays true forever, blocking runWhenIdle tasks.
216
+ //
217
+ // Deferred to a microtask so that when a child layout effect fires a
218
+ // pushState during the same commit (e.g. nuqs syncing on mount),
219
+ // forgetOlderHandoffs — which runs in the parent layout effect, i.e.
220
+ // synchronously before the microtask — clears pendingCommit first,
221
+ // and this settle is a no-op. A genuinely displaced suspended tree
222
+ // never fires forgetOlderHandoffs, so the microtask settles it
223
+ // (TIM-1480).
224
+ queueMicrotask(() => router?.settleHandoffs());
225
+
206
226
  publishTree(currentPayload, currentParams);
207
227
  render(renderTree(currentPayload, newNavState, currentParams), null);
208
228
  }
@@ -268,7 +288,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
268
288
  },
269
289
 
270
290
  // Render a decoded RSC tree through the router's React root. Used for
271
- // non-navigation renders (popstate cached replay, applyRevalidation).
291
+ // non-navigation renders (popstate cached replay, applyActionResult).
272
292
  // Wraps with NavigationProvider + TimberNuqsAdapter.
273
293
  //
274
294
  // For navigation renders (navigate, refresh, popstate-with-fetch),
@@ -307,38 +327,43 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
307
327
  //
308
328
  // `_url` names the pending URL the router already published to its own
309
329
  // pending store before calling here; the transition has no use for it.
310
- navigateTransition: (_url: string, perform) => {
311
- return navigateTransition(async () => {
312
- // What wrapPayload built, held until the transition wins. The client's
313
- // record of the displayed tree is navigation state like any other, so
314
- // it rides the same commit as the segment cache rather than being
315
- // written as a side effect of building the element (TIM-1301).
316
- let displayed: { tree: unknown; params: ParamsSource } | null = null;
317
- const { element, decodePromise, commit } = await perform(
318
- (
319
- rawPayload: unknown,
320
- navState: NavigationState,
321
- params: ParamsSource,
322
- segmentUpdates?: Map<string, unknown>
323
- ) => {
324
- displayed = { tree: rawPayload, params };
325
- return renderTree(
326
- rawPayload,
327
- navState,
328
- params,
329
- segmentUpdates as Map<string, React.ReactNode> | undefined
330
- );
331
- }
332
- );
333
- return {
334
- element: element as React.ReactNode,
335
- decodePromise,
336
- commit: () => {
337
- if (displayed) publishTree(displayed.tree, displayed.params);
338
- commit();
339
- },
340
- };
341
- }, render);
330
+ navigateTransition: (_url: string, owner: RenderOwner, perform, onCommit) => {
331
+ return navigateTransition(
332
+ owner,
333
+ async () => {
334
+ // What wrapPayload built, held until the transition wins. The client's
335
+ // record of the displayed tree is navigation state like any other, so
336
+ // it rides the same commit as the segment cache rather than being
337
+ // written as a side effect of building the element (TIM-1301).
338
+ let displayed: { tree: unknown; params: ParamsSource } | null = null;
339
+ const { element, decodePromise, commit } = await perform(
340
+ (
341
+ rawPayload: unknown,
342
+ navState: NavigationState,
343
+ params: ParamsSource,
344
+ segmentUpdates?: Map<string, unknown>
345
+ ) => {
346
+ displayed = { tree: rawPayload, params };
347
+ return renderTree(
348
+ rawPayload,
349
+ navState,
350
+ params,
351
+ segmentUpdates as Map<string, React.ReactNode> | undefined
352
+ );
353
+ }
354
+ );
355
+ return {
356
+ element: element as React.ReactNode,
357
+ decodePromise,
358
+ commit: () => {
359
+ if (displayed) publishTree(displayed.tree, displayed.params);
360
+ commit();
361
+ },
362
+ };
363
+ },
364
+ render,
365
+ onCommit
366
+ );
342
367
  },
343
368
 
344
369
  _getCurrentPayload: () => currentPayload,
@@ -353,7 +378,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
353
378
  },
354
379
  };
355
380
 
356
- const router = createRouter(deps);
381
+ router = createRouter(deps);
357
382
  setGlobalRouter(router);
358
383
 
359
384
  // Set up Navigation API integration after router is created.
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+
3
+ import { statusReasonPhrase } from '../shared/status-reason-phrase.ts';
4
+
5
+ /**
6
+ * The deny chain's terminal, in client form.
7
+ *
8
+ * design/10-error-handling.md §"Fallback Chain" ends every chain in the
9
+ * framework default page. On a document load that page is the HTML string in
10
+ * `server/default-status-page.ts`. This is the same terminal for a deny that
11
+ * streamed to the client as an error row and reached the root boundary with
12
+ * nothing in between able to render it — which happens in exactly one shape:
13
+ * a server component rendered by a layout's own chrome denied, and no status
14
+ * file exists at or above that layout. Files *below* the denying layout are
15
+ * not candidates: rendering any of them means executing that layout again,
16
+ * which denies again. So there is no user file to show, and this renders the
17
+ * default one. See TIM-1450, design/04-authorization.md §"Where a Deny Page
18
+ * Renders".
19
+ *
20
+ * A plain reload button, not an automatic navigation: the status and phrase
21
+ * are already on screen and tell the reader what happened; the button hands
22
+ * them the one recovery the framework can offer (a document request, which
23
+ * takes the re-render fallback) without doing it behind their back. An
24
+ * effect-driven reload could not be told apart from a loop.
25
+ *
26
+ * Same content rules as the HTML page: the status code is the only thing
27
+ * interpolated, and it is clamped by `statusReasonPhrase`. No URL, no error
28
+ * message, no stack. See design/13-security.md principle 4.
29
+ */
30
+ export function DenyLastResort({ status }: { status: number }) {
31
+ const phrase = statusReasonPhrase(status);
32
+ return (
33
+ <main
34
+ data-timber-deny-terminal=""
35
+ style={{
36
+ fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
37
+ textAlign: 'center',
38
+ padding: '2rem',
39
+ }}
40
+ >
41
+ <h1 style={{ fontSize: '3rem', fontWeight: 600, margin: 0, letterSpacing: '-0.02em' }}>
42
+ {status}
43
+ </h1>
44
+ <p style={{ margin: '0.5rem 0 0', opacity: 0.7 }}>{phrase}</p>
45
+ <button
46
+ type="button"
47
+ onClick={() => window.location.reload()}
48
+ style={{ marginTop: '1.5rem', padding: '0.5rem 1rem', font: 'inherit', cursor: 'pointer' }}
49
+ >
50
+ Reload
51
+ </button>
52
+ </main>
53
+ );
54
+ }