@timber-js/app 0.2.0-alpha.211 → 0.2.0-alpha.213

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 (183) hide show
  1. package/agent-skill.md +10 -5
  2. package/dist/_chunks/{actions-Rjk4htmA.js → actions-CEootpB1.js} +49 -12
  3. package/dist/_chunks/actions-CEootpB1.js.map +1 -0
  4. package/dist/_chunks/{als-registry-DaxkVjt5.js → als-registry-BZqHCtq-.js} +2 -4
  5. package/dist/_chunks/als-registry-BZqHCtq-.js.map +1 -0
  6. package/dist/_chunks/{cache-api-DGdYfNJn.js → cache-api-LA3sBpUS.js} +5 -5
  7. package/dist/_chunks/{cache-api-DGdYfNJn.js.map → cache-api-LA3sBpUS.js.map} +1 -1
  8. package/dist/_chunks/{chains-DGX9zmg9.js → chains-BfoPFraI.js} +2 -2
  9. package/dist/_chunks/{chains-DGX9zmg9.js.map → chains-BfoPFraI.js.map} +1 -1
  10. package/dist/_chunks/{classify-QwG5rxKI.js → classify-BT66U83D.js} +2 -2
  11. package/dist/_chunks/{classify-QwG5rxKI.js.map → classify-BT66U83D.js.map} +1 -1
  12. package/dist/_chunks/{cli-check-ajNY3B2e.js → cli-check-BfQ54-UJ.js} +3 -3
  13. package/dist/_chunks/{cli-check-ajNY3B2e.js.map → cli-check-BfQ54-UJ.js.map} +1 -1
  14. package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js → cli-schema-sync-czh2dsLs.js} +2 -2
  15. package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js.map → cli-schema-sync-czh2dsLs.js.map} +1 -1
  16. package/dist/_chunks/{client-dep-entries-2HCF09no.js → client-dep-entries-CQwpb8dI.js} +2 -2
  17. package/dist/_chunks/{client-dep-entries-2HCF09no.js.map → client-dep-entries-CQwpb8dI.js.map} +1 -1
  18. package/dist/_chunks/{convention-lint-DLmhGsRS.js → convention-lint-BEVW4EID.js} +3 -3
  19. package/dist/_chunks/{convention-lint-DLmhGsRS.js.map → convention-lint-BEVW4EID.js.map} +1 -1
  20. package/dist/_chunks/{dev-server-TFpEwm3H.js → dev-server-FKxptbnI.js} +2 -2
  21. package/dist/_chunks/{dev-server-TFpEwm3H.js.map → dev-server-FKxptbnI.js.map} +1 -1
  22. package/dist/_chunks/{error-boundary-DsNScGRM.js → error-boundary-9g_Lb2na.js} +3 -3
  23. package/dist/_chunks/{error-boundary-DsNScGRM.js.map → error-boundary-9g_Lb2na.js.map} +1 -1
  24. package/dist/_chunks/{json-lossy-check-CVuRs2hG.js → json-lossy-check-C8zBY2uZ.js} +2 -2
  25. package/dist/_chunks/{json-lossy-check-CVuRs2hG.js.map → json-lossy-check-C8zBY2uZ.js.map} +1 -1
  26. package/dist/_chunks/{live-graph-cNuWMYQI.js → live-graph-C_4v-fHv.js} +4 -4
  27. package/dist/_chunks/{live-graph-cNuWMYQI.js.map → live-graph-C_4v-fHv.js.map} +1 -1
  28. package/dist/_chunks/{logger-BP0LN6vP.js → logger-CbLdcy-W.js} +2 -2
  29. package/dist/_chunks/{logger-BP0LN6vP.js.map → logger-CbLdcy-W.js.map} +1 -1
  30. package/dist/_chunks/{poison-scan-CfQ3unZR.js → poison-scan-C92liMAr.js} +2 -2
  31. package/dist/_chunks/{poison-scan-CfQ3unZR.js.map → poison-scan-C92liMAr.js.map} +1 -1
  32. package/dist/_chunks/{scanner-DmqdxzbW.js → scanner-CQt12vE2.js} +2 -2
  33. package/dist/_chunks/{scanner-DmqdxzbW.js.map → scanner-CQt12vE2.js.map} +1 -1
  34. package/dist/_chunks/{sizeof-BM1409x2.js → sizeof-QPE5nd3u.js} +2 -2
  35. package/dist/_chunks/{sizeof-BM1409x2.js.map → sizeof-QPE5nd3u.js.map} +1 -1
  36. package/dist/_chunks/{walkers-Czu2jXFq.js → walkers-DAT4avhZ.js} +3 -3
  37. package/dist/_chunks/{walkers-Czu2jXFq.js.map → walkers-DAT4avhZ.js.map} +1 -1
  38. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  39. package/dist/analyze/crawl-entry.js +3 -3
  40. package/dist/analyze/graph-command.js +2 -2
  41. package/dist/cache/index.js +2 -2
  42. package/dist/cache/stores/memory.js +1 -1
  43. package/dist/cdn/workers-cache-purge.js +1 -1
  44. package/dist/cli.js +3 -3
  45. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  46. package/dist/client/browser-entry/action-queue.d.ts +1 -0
  47. package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
  48. package/dist/client/browser-entry/form-state.d.ts +22 -0
  49. package/dist/client/browser-entry/form-state.d.ts.map +1 -0
  50. package/dist/client/browser-entry/hydrate.d.ts +9 -1
  51. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  52. package/dist/client/browser-entry/index.d.ts +2 -0
  53. package/dist/client/browser-entry/index.d.ts.map +1 -1
  54. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  55. package/dist/client/error-boundary.js +1 -1
  56. package/dist/client/form.d.ts +17 -64
  57. package/dist/client/form.d.ts.map +1 -1
  58. package/dist/client/index.d.ts +3 -2
  59. package/dist/client/index.d.ts.map +1 -1
  60. package/dist/client/index.js +111 -51
  61. package/dist/client/index.js.map +1 -1
  62. package/dist/client/internal.js +33 -32
  63. package/dist/client/internal.js.map +1 -1
  64. package/dist/client/navigation-api.d.ts +25 -63
  65. package/dist/client/navigation-api.d.ts.map +1 -1
  66. package/dist/client/navigation-transition.d.ts +11 -2
  67. package/dist/client/navigation-transition.d.ts.map +1 -1
  68. package/dist/client/router-effects.d.ts +7 -3
  69. package/dist/client/router-effects.d.ts.map +1 -1
  70. package/dist/client/router-lifecycle.d.ts +17 -11
  71. package/dist/client/router-lifecycle.d.ts.map +1 -1
  72. package/dist/client/router-pipeline.d.ts +3 -2
  73. package/dist/client/router-pipeline.d.ts.map +1 -1
  74. package/dist/client/router-types.d.ts +24 -44
  75. package/dist/client/router-types.d.ts.map +1 -1
  76. package/dist/client/router.d.ts.map +1 -1
  77. package/dist/client/use-form-field.d.ts +39 -0
  78. package/dist/client/use-form-field.d.ts.map +1 -0
  79. package/dist/config-types.d.ts +2 -1
  80. package/dist/config-types.d.ts.map +1 -1
  81. package/dist/index.js +6 -7
  82. package/dist/index.js.map +1 -1
  83. package/dist/plugins/shims.d.ts.map +1 -1
  84. package/dist/routing/index.js +2 -2
  85. package/dist/rsc-runtime/rsc.d.ts +1 -1
  86. package/dist/rsc-runtime/rsc.d.ts.map +1 -1
  87. package/dist/server/action-client.d.ts +20 -7
  88. package/dist/server/action-client.d.ts.map +1 -1
  89. package/dist/server/action-handler.d.ts +27 -8
  90. package/dist/server/action-handler.d.ts.map +1 -1
  91. package/dist/server/als-registry.d.ts +22 -2
  92. package/dist/server/als-registry.d.ts.map +1 -1
  93. package/dist/server/client-error-message.d.ts +12 -0
  94. package/dist/server/client-error-message.d.ts.map +1 -0
  95. package/dist/server/flight-scripts.d.ts +9 -0
  96. package/dist/server/flight-scripts.d.ts.map +1 -1
  97. package/dist/server/form-data.d.ts +13 -4
  98. package/dist/server/form-data.d.ts.map +1 -1
  99. package/dist/server/form-state-flight.d.ts +32 -0
  100. package/dist/server/form-state-flight.d.ts.map +1 -0
  101. package/dist/server/index.d.ts +0 -2
  102. package/dist/server/index.d.ts.map +1 -1
  103. package/dist/server/index.js +41 -60
  104. package/dist/server/index.js.map +1 -1
  105. package/dist/server/internal.js +10 -6
  106. package/dist/server/internal.js.map +1 -1
  107. package/dist/server/logger.d.ts +1 -0
  108. package/dist/server/logger.d.ts.map +1 -1
  109. package/dist/server/pipeline.d.ts +20 -6
  110. package/dist/server/pipeline.d.ts.map +1 -1
  111. package/dist/server/request-context.d.ts +27 -2
  112. package/dist/server/request-context.d.ts.map +1 -1
  113. package/dist/server/route-element-builder.d.ts.map +1 -1
  114. package/dist/server/rsc-entry/action-dispatcher.d.ts +6 -5
  115. package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
  116. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  117. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  118. package/dist/server/rsc-entry/render-route.d.ts +2 -0
  119. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  120. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  121. package/dist/server/rsc-entry/ssr-renderer.d.ts +6 -0
  122. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  123. package/dist/server/ssr-bridge-types.d.ts +10 -0
  124. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  125. package/dist/server/ssr-entry.d.ts.map +1 -1
  126. package/dist/server/ssr-form-state.d.ts +30 -0
  127. package/dist/server/ssr-form-state.d.ts.map +1 -0
  128. package/dist/server/ssr-render.d.ts +3 -0
  129. package/dist/server/ssr-render.d.ts.map +1 -1
  130. package/dist/shared/form-state-flight.d.ts +36 -0
  131. package/dist/shared/form-state-flight.d.ts.map +1 -0
  132. package/docs/api/31-api-client.mdx +31 -3
  133. package/docs/api/34-api-config.mdx +1 -1
  134. package/docs/learn/08-forms-and-actions.mdx +116 -36
  135. package/package.json +1 -1
  136. package/src/client/browser-entry/action-dispatch.ts +104 -24
  137. package/src/client/browser-entry/action-queue.ts +1 -1
  138. package/src/client/browser-entry/form-state.ts +48 -0
  139. package/src/client/browser-entry/hydrate.ts +10 -1
  140. package/src/client/browser-entry/index.ts +25 -7
  141. package/src/client/browser-entry/router-init.ts +7 -20
  142. package/src/client/form.tsx +33 -98
  143. package/src/client/index.ts +3 -2
  144. package/src/client/navigation-api.ts +47 -173
  145. package/src/client/navigation-transition.ts +15 -4
  146. package/src/client/router-effects.ts +8 -4
  147. package/src/client/router-lifecycle.ts +40 -20
  148. package/src/client/router-pipeline.ts +10 -12
  149. package/src/client/router-types.ts +24 -48
  150. package/src/client/router.ts +49 -56
  151. package/src/client/use-form-field.ts +132 -0
  152. package/src/config-types.ts +2 -1
  153. package/src/plugins/shims.ts +0 -2
  154. package/src/rsc-runtime/rsc.ts +3 -0
  155. package/src/rsc-runtime/vendor-types.d.ts +14 -0
  156. package/src/server/action-client.ts +77 -64
  157. package/src/server/action-handler.ts +133 -95
  158. package/src/server/als-registry.ts +23 -9
  159. package/src/server/client-error-message.ts +18 -0
  160. package/src/server/flight-scripts.ts +13 -0
  161. package/src/server/form-data.ts +62 -10
  162. package/src/server/form-state-flight.ts +67 -0
  163. package/src/server/index.ts +0 -4
  164. package/src/server/logger.ts +6 -1
  165. package/src/server/pipeline.ts +27 -8
  166. package/src/server/request-context.ts +39 -2
  167. package/src/server/route-element-builder.ts +12 -1
  168. package/src/server/rsc-entry/action-dispatcher.ts +40 -34
  169. package/src/server/rsc-entry/error-renderer.ts +2 -1
  170. package/src/server/rsc-entry/index.ts +1 -0
  171. package/src/server/rsc-entry/render-route.ts +16 -0
  172. package/src/server/rsc-entry/rsc-stream.ts +20 -11
  173. package/src/server/rsc-entry/ssr-renderer.ts +11 -0
  174. package/src/server/ssr-bridge-types.ts +10 -0
  175. package/src/server/ssr-entry.ts +16 -2
  176. package/src/server/ssr-form-state.ts +58 -0
  177. package/src/server/ssr-render.ts +5 -0
  178. package/src/shared/form-state-flight.ts +74 -0
  179. package/dist/_chunks/actions-Rjk4htmA.js.map +0 -1
  180. package/dist/_chunks/als-registry-DaxkVjt5.js.map +0 -1
  181. package/dist/server/form-flash.d.ts +0 -78
  182. package/dist/server/form-flash.d.ts.map +0 -1
  183. package/src/server/form-flash.ts +0 -89
@@ -1,93 +1,21 @@
1
1
  /**
2
2
  * Client-side form utilities for server actions.
3
3
  *
4
- * Exports a typed `useActionState` that understands the action builder's result shape.
5
- * Result is typed to:
6
- * { data: T } | { validationErrors: Record<string, string[]> } | { serverError: { code, data? } } | null
7
- *
8
- * The action builder emits a function that satisfies both the direct call signature
9
- * and React's `(prevState, formData) => Promise<State>` contract.
4
+ * Forms use React's own `useActionState`: an action built with
5
+ * `createActionClient` already has the `(prevState, payload)` signature it
6
+ * calls, and the result is typed from it. `parseFormErrors` reads the errors
7
+ * out of that result.
10
8
  *
11
9
  * See design/08-forms-and-actions.md §"Client-Side Form Mechanics"
12
10
  */
13
11
 
14
- import { useActionState as reactUseActionState, useTransition } from 'react';
12
+ import { useTransition } from 'react';
15
13
  import type {
16
14
  ActionFn,
17
15
  ActionResult,
18
16
  InputHint,
19
17
  ValidationErrors,
20
18
  } from '../server/action-client.ts';
21
- import type { FormFlashData } from '../server/form-flash.ts';
22
-
23
- // ─── Types ───────────────────────────────────────────────────────────────
24
-
25
- /**
26
- * The action function type accepted by useActionState.
27
- * Must satisfy React's (prevState, formData) => Promise<State> contract.
28
- */
29
- export type UseActionStateFn<TData> = (
30
- prevState: ActionResult<TData> | null,
31
- formData: FormData
32
- ) => Promise<ActionResult<TData>>;
33
-
34
- /**
35
- * Return type of useActionState.
36
- * [result, formAction, isPending, errors]
37
- * The 4th element is auto-derived from result via useFormErrors logic.
38
- */
39
- export type UseActionStateReturn<TData> = [
40
- result: ActionResult<TData> | null,
41
- formAction: (formData: FormData) => void,
42
- isPending: boolean,
43
- errors: FormErrorsResult,
44
- ];
45
-
46
- // ─── useActionState ──────────────────────────────────────────────────────
47
-
48
- /**
49
- * Typed wrapper around React 19's `useActionState` that understands
50
- * the timber action builder's result shape.
51
- *
52
- * @param action - A server action created with createActionClient or a raw 'use server' function.
53
- * @param initialState - Initial state, typically `null`. Pass `getFormFlash()` for no-JS
54
- * progressive enhancement — the flash seeds the initial state so the form has a
55
- * single source of truth for both with-JS and no-JS paths.
56
- * @param permalink - Optional permalink for progressive enhancement (no-JS fallback URL).
57
- *
58
- * @example
59
- * ```tsx
60
- * 'use client'
61
- * import { useActionState } from '@timber-js/app/client'
62
- * import { createTodo } from './actions'
63
- *
64
- * export function NewTodoForm({ flash }) {
65
- * const [result, action, isPending] = useActionState(createTodo, flash)
66
- * return (
67
- * <form action={action}>
68
- * <input name="title" />
69
- * {result?.validationErrors?.title && <p>{result.validationErrors.title}</p>}
70
- * <button disabled={isPending}>Add</button>
71
- * </form>
72
- * )
73
- * }
74
- * ```
75
- */
76
- export function useActionState<TData>(
77
- action: UseActionStateFn<TData>,
78
- initialState: ActionResult<TData> | FormFlashData | null,
79
- permalink?: string
80
- ): UseActionStateReturn<TData> {
81
- // FormFlashData is structurally compatible with ActionResult at runtime —
82
- // the cast satisfies React's generic inference which would otherwise widen TData.
83
- const [result, formAction, isPending] = reactUseActionState(
84
- action,
85
- initialState as ActionResult<TData> | null,
86
- permalink
87
- );
88
- const errors = deriveFormErrors(result);
89
- return [result, formAction, isPending, errors];
90
- }
91
19
 
92
20
  // ─── useFormAction ───────────────────────────────────────────────────────
93
21
 
@@ -114,12 +42,20 @@ export function useFormAction<TData = unknown, TInput = unknown>(
114
42
  const [isPending, startTransition] = useTransition();
115
43
 
116
44
  const execute = (input?: InputHint<TInput>): Promise<ActionResult<TData>> => {
117
- return new Promise((resolve) => {
45
+ return new Promise((resolve, reject) => {
118
46
  startTransition(async () => {
119
- const result = await (action as (input: InputHint<TInput>) => Promise<ActionResult<TData>>)(
120
- input as InputHint<TInput>
121
- );
122
- resolve(result);
47
+ // A raw server function that throws rejects (TIM-1570). The caller
48
+ // awaiting `execute` gets that rejection; without the catch the
49
+ // promise would never settle.
50
+ try {
51
+ resolve(
52
+ await (action as (input: InputHint<TInput>) => Promise<ActionResult<TData>>)(
53
+ input as InputHint<TInput>
54
+ )
55
+ );
56
+ } catch (error) {
57
+ reject(error);
58
+ }
123
59
  });
124
60
  });
125
61
  };
@@ -129,7 +65,7 @@ export function useFormAction<TData = unknown, TInput = unknown>(
129
65
 
130
66
  // ─── Form error extraction ────────────────────────────────────────────────
131
67
 
132
- /** Return type of the errors element in useActionState. */
68
+ /** What `parseFormErrors` reads out of an action result. */
133
69
  export interface FormErrorsResult {
134
70
  /** Per-field validation errors keyed by field name. */
135
71
  fieldErrors: Record<string, string[]>;
@@ -144,18 +80,19 @@ export interface FormErrorsResult {
144
80
  }
145
81
 
146
82
  /**
147
- * Derive FormErrorsResult from an action result.
148
- * Used internally by useActionState 4th tuple element.
149
- * @internal — exported for test access only.
83
+ * Read the errors out of an action result — the state `useActionState`
84
+ * returns. `_root` validation errors are form-level; every other key is a
85
+ * field.
86
+ *
87
+ * @example
88
+ * ```tsx
89
+ * const [result, action, isPending] = useActionState(createTodo, null)
90
+ * const errors = parseFormErrors(result)
91
+ * errors.getFieldError('title') // first message for the field, or null
92
+ * ```
150
93
  */
151
- export function deriveFormErrors<TData>(
152
- result:
153
- | ActionResult<TData>
154
- | {
155
- validationErrors?: ValidationErrors;
156
- serverError?: { code: string; data?: Record<string, unknown> };
157
- }
158
- | null
94
+ export function parseFormErrors<TData>(
95
+ result: ActionResult<TData> | null | undefined
159
96
  ): FormErrorsResult {
160
97
  const empty: FormErrorsResult = {
161
98
  fieldErrors: {},
@@ -167,10 +104,8 @@ export function deriveFormErrors<TData>(
167
104
 
168
105
  if (!result) return empty;
169
106
 
170
- const validationErrors = result.validationErrors as ValidationErrors | undefined;
171
- const serverError = result.serverError as
172
- | { code: string; data?: Record<string, unknown> }
173
- | undefined;
107
+ const validationErrors: ValidationErrors | undefined = result.validationErrors;
108
+ const serverError = result.serverError;
174
109
 
175
110
  if (!validationErrors && !serverError) return empty;
176
111
 
@@ -75,8 +75,9 @@ export {
75
75
  } from './use-selected-layout-segment.ts';
76
76
 
77
77
  // Forms
78
- export { useActionState, useFormAction } from './form.tsx';
79
- export type { UseActionStateFn, UseActionStateReturn, FormErrorsResult } from './form.tsx';
78
+ export { parseFormErrors, useFormAction } from './form.tsx';
79
+ export { useFormField } from './use-form-field.ts';
80
+ export type { FormErrorsResult } from './form.tsx';
80
81
 
81
82
  // Params. Called with no argument this returns the untyped accumulated params;
82
83
  // pass the route's SEGMENT_PATH (from its generated `./$segment` module) to get
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * Navigation API integration — progressive enhancement for client navigation.
3
3
  *
4
- * When the Navigation API (`window.navigation`) is available, this module
5
- * provides an intercept-based navigation model that replaces the separate
6
- * popstate + click handler approach with a single navigate event listener.
7
- *
8
- * Key benefits:
9
- * - Intercepts ALL navigations (link clicks, form submissions, back/forward)
10
- * - Built-in AbortSignal per navigation (auto-aborts in-flight fetches)
11
- * - Per-entry state via NavigationHistoryEntry.getState()
12
- * - navigation.transition for progress tracking
4
+ * When the Navigation API (`window.navigation`) is available, a single
5
+ * navigate event listener replaces the popstate handler and routes plain
6
+ * `<a>` clicks through the router:
7
+ * - Traversals are intercepted, with the event's AbortSignal linked to the
8
+ * router's fetch
9
+ * - Cross-document push/replace navigations are cancelled and re-run through
10
+ * the router, so the URL commits with the tree (never at intercept time)
11
+ * - Per-entry scroll state via NavigationHistoryEntry.getState()
13
12
  *
14
13
  * When unavailable, all functions are no-ops and the History API fallback
15
14
  * in browser-entry.ts handles navigation.
@@ -67,20 +66,18 @@ export function traverseDirection(
67
66
  /**
68
67
  * Callbacks for the Navigation API event handler.
69
68
  *
70
- * When the Navigation API intercepts a navigation, it delegates to these
71
- * callbacks which run the RSC fetch + render pipeline.
69
+ * Push/replace navigations are handed to the router, which commits the URL
70
+ * with the tree. Traversals are intercepted and replayed or fetched.
72
71
  */
73
72
  export interface NavigationApiCallbacks {
74
73
  /**
75
- * Handle a push/replace navigation intercepted by the Navigation API.
76
- * This covers both Link <a> clicks (user-initiated) and external
77
- * navigations (plain <a> tags, programmatic).
78
- * The Navigation API handles the URL update via event.intercept().
74
+ * Handle a cross-document push/replace navigation the router did not start
75
+ * itself: a plain `<a>`, `navigation.navigate()`, or `location.assign()`.
76
+ * The handler has already cancelled the browser's navigation, so the
77
+ * address bar has not moved; the router moves it when the destination
78
+ * commits, as it does for its own navigations.
79
79
  */
80
- onExternalNavigate: (
81
- url: string,
82
- options: { replace: boolean; signal: AbortSignal; scroll?: boolean; departingUrl?: string }
83
- ) => Promise<void>;
80
+ onExternalNavigate: (url: string, options: { replace: boolean }) => Promise<void>;
84
81
 
85
82
  /**
86
83
  * Handle a traversal (back/forward button). The Navigation API intercepts
@@ -104,58 +101,15 @@ export interface NavigationApiCallbacks {
104
101
  }
105
102
 
106
103
  /**
107
- * Controller returned by setupNavigationApi. Provides methods to
108
- * coordinate between the router and the navigate event listener.
104
+ * Controller returned by setupNavigationApi.
109
105
  */
110
106
  export interface NavigationApiController {
111
- /**
112
- * Set the router-navigating flag. When `true`, the next navigate event
113
- * (from pushState/replaceState) is recognized as router-initiated. The
114
- * handler still intercepts it — but ties the browser's native loading
115
- * state to a deferred promise instead of running the RSC pipeline again.
116
- *
117
- * This means `navigation.transition` is active for the full duration of
118
- * every router-initiated navigation, giving the browser a native loading
119
- * indicator (tab spinner, address bar) aligned with the TopLoader.
120
- *
121
- * Must be called synchronously around pushState/replaceState:
122
- * controller.setRouterNavigating(true);
123
- * history.pushState(...); // navigate event fires, intercepted
124
- * controller.setRouterNavigating(false); // flag off, deferred stays open
125
- */
126
- setRouterNavigating: (value: boolean) => void;
127
-
128
- /**
129
- * Resolve the deferred promise created by setRouterNavigating(true),
130
- * clearing the browser's native loading state. Call this when the
131
- * navigation fully completes — the same finally block in router.navigate
132
- * that clears the router's pending store.
133
- */
134
- completeRouterNavigation: () => void;
135
-
136
- /**
137
- * Initiate a navigation via the Navigation API (`navigation.navigate()`).
138
- * Unlike `history.pushState()`, this fires the navigate event BEFORE
139
- * committing the URL — allowing Chrome to show its native loading
140
- * indicator while the intercept handler runs.
141
- *
142
- * Must be called with setRouterNavigating(true) active so the handler
143
- * recognizes it as router-initiated and uses the deferred promise.
144
- */
145
- navigate: (url: string, replace: boolean) => void;
146
-
147
107
  /**
148
108
  * Save scroll position into the current navigation entry's state.
149
109
  * Uses navigation.updateCurrentEntry() for per-entry scroll storage.
150
110
  */
151
111
  saveScrollPosition: (scrollY: number) => void;
152
112
 
153
- /**
154
- * Check if the Navigation API has an active transition.
155
- * Returns the transition object if available, null otherwise.
156
- */
157
- hasActiveTransition: () => boolean;
158
-
159
113
  /** Remove the navigate event listener. */
160
114
  cleanup: () => void;
161
115
  }
@@ -163,24 +117,19 @@ export interface NavigationApiController {
163
117
  /**
164
118
  * Set up the Navigation API navigate event listener.
165
119
  *
166
- * Intercepts same-origin navigations and delegates to the provided callbacks.
167
- * Router-initiated navigations (pushState from router.navigate) are detected
168
- * via a synchronous flag and NOT intercepted — the router already handles them.
169
- *
170
- * Returns a controller for coordinating with the router.
120
+ * The address bar moves only when the destination's tree commits
121
+ * (design/19-client-navigation.md §"prepareNavigation"). `event.intercept()`
122
+ * would commit the URL as soon as it is called, a full round trip before the
123
+ * page that belongs to it, so push/replace navigations are never
124
+ * intercepted: the router's own navigations never reach here as
125
+ * cross-document events (Link cancels the click and the router commits with
126
+ * `pushState`), and the rest are cancelled and re-run through the router.
127
+ * Only traversals — which the browser has already moved the URL for in every
128
+ * browser — and shallow updates are intercepted.
171
129
  */
172
130
  export function setupNavigationApi(callbacks: NavigationApiCallbacks): NavigationApiController {
173
131
  const nav = getNavigationApi()!;
174
132
 
175
- let routerNavigating = false;
176
-
177
- // Deferred promise for router-initiated navigations. Created when
178
- // setRouterNavigating(true) is called, resolved by completeRouterNavigation().
179
- // The navigate event handler intercepts with this promise so the browser's
180
- // native loading state (tab spinner) stays active until the navigation
181
- // completes — the same lifecycle the TopLoader is driven by.
182
- let routerNavDeferred: { promise: Promise<void>; resolve: () => void } | null = null;
183
-
184
133
  function handleNavigate(event: NavigateEvent): void {
185
134
  // Skip non-interceptable navigations (cross-origin, etc.)
186
135
  if (!event.canIntercept) return;
@@ -234,20 +183,6 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
234
183
  const destUrl = new URL(event.destination.url);
235
184
  if (destUrl.origin !== location.origin) return;
236
185
 
237
- // Router-initiated navigation (Link click → router.navigate → pushState).
238
- // The router is already running the RSC pipeline — don't run it again.
239
- // Instead, intercept with the deferred promise so the browser's native
240
- // loading state tracks the navigation's full lifecycle. This aligns the
241
- // tab spinner / address bar indicator with the TopLoader.
242
- if (routerNavigating && routerNavDeferred) {
243
- event.intercept({
244
- scroll: 'manual',
245
- focusReset: 'manual',
246
- handler: () => routerNavDeferred!.promise,
247
- });
248
- return;
249
- }
250
-
251
186
  // Skip reload navigations — let the browser handle full page reload
252
187
  if (event.navigationType === 'reload') return;
253
188
 
@@ -274,90 +209,33 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
274
209
  await callbacks.onTraverse(url, scrollY, event.signal, direction);
275
210
  },
276
211
  });
277
- } else if (event.navigationType === 'push' || event.navigationType === 'replace') {
278
- // Push/replace — a Link <a> click or an external navigation
279
- // (plain <a> tag, programmatic).
280
-
281
- // Save the departing page's scroll position BEFORE event.intercept()
282
- // commits the URL change. Once intercept() is called, currentEntry
283
- // switches to the new (destination) entry — any updateCurrentEntry()
284
- // call after that would save to the wrong entry.
285
- // See: router.navigate() also calls saveNavigationEntryScroll(), but
286
- // for Navigation API <a> click navigations (where Link does NOT call
287
- // router.navigate directly), the router's save runs inside the
288
- // intercept handler — too late, currentEntry has already switched.
289
- try {
290
- const currentState = (nav.currentEntry?.getState() ?? {}) as Record<string, unknown>;
291
- nav.updateCurrentEntry({
292
- state: { ...currentState, timber: true, scrollY: window.scrollY },
293
- });
294
- } catch {
295
- // Ignore — entry may be disposed
296
- }
297
-
298
- // Capture the departing URL BEFORE event.intercept() commits the
299
- // destination. Once intercept() is called, currentEntry switches and
300
- // getCurrentUrl() returns the destination (TIM-1232).
301
- const departingUrl = nav.currentEntry?.url
302
- ? new URL(nav.currentEntry.url).pathname +
303
- stripRscCacheKey(new URL(nav.currentEntry.url).search)
304
- : undefined;
305
-
306
- event.intercept({
307
- scroll: 'manual',
308
- focusReset: 'manual',
309
- async handler() {
310
- await callbacks.onExternalNavigate(url + destUrl.hash, {
311
- replace: event.navigationType === 'replace',
312
- signal: event.signal,
313
- scroll: undefined,
314
- departingUrl,
315
- });
316
- },
317
- });
212
+ return;
318
213
  }
214
+
215
+ // A same-document push/replace is `history.pushState()` /
216
+ // `replaceState()`: the router's own commit, or app code (nuqs,
217
+ // replaceUrl, a third-party library). The URL is already the
218
+ // destination and the History API patch in router-init syncs the search
219
+ // params, exactly as in browsers without the Navigation API.
220
+ if (event.destination.sameDocument) return;
221
+
222
+ // A cross-document push/replace the router did not start: a plain
223
+ // `<a>`, `navigation.navigate()`, `location.assign()`. Cancel it and
224
+ // re-run it through the router, which commits the URL with the tree.
225
+ // A navigation the browser will not let us cancel stays a document load.
226
+ // Cancelling rejects a `navigation.navigate()` caller's `committed` and
227
+ // `finished` with AbortError and drops its `state`; the router's own API
228
+ // is `useRouter()` (design/19 §"The address bar moves on commit").
229
+ if (!event.cancelable) return;
230
+ event.preventDefault();
231
+ void callbacks.onExternalNavigate(url + destUrl.hash, {
232
+ replace: event.navigationType === 'replace',
233
+ });
319
234
  }
320
235
 
321
236
  nav.addEventListener('navigate', handleNavigate);
322
237
 
323
238
  return {
324
- setRouterNavigating(value: boolean): void {
325
- routerNavigating = value;
326
- if (value) {
327
- // Create a new deferred promise. The navigate event handler will
328
- // intercept and tie the browser's loading state to this promise.
329
- let resolve!: () => void;
330
- const promise = new Promise<void>((r) => {
331
- resolve = r;
332
- });
333
- routerNavDeferred = { promise, resolve };
334
- } else {
335
- // Flag off — but DON'T resolve the deferred here. The navigation
336
- // is still in flight (RSC fetch + render). completeRouterNavigation()
337
- // resolves it when the navigation fully completes.
338
- routerNavigating = false;
339
- }
340
- },
341
-
342
- completeRouterNavigation(): void {
343
- if (routerNavDeferred) {
344
- routerNavDeferred.resolve();
345
- routerNavDeferred = null;
346
- }
347
- },
348
-
349
- navigate(url: string, replace: boolean): void {
350
- // Use navigation.navigate() instead of history.pushState().
351
- // This fires the navigate event BEFORE committing the URL,
352
- // which lets Chrome show its native loading indicator while
353
- // the intercept handler (deferred promise) is pending.
354
- // history.pushState() commits the URL synchronously, so Chrome
355
- // sees the navigation as already complete and skips the indicator.
356
- nav.navigate(url, {
357
- history: replace ? 'replace' : 'push',
358
- });
359
- },
360
-
361
239
  saveScrollPosition(scrollY: number): void {
362
240
  try {
363
241
  const currentState = (nav.currentEntry?.getState() ?? {}) as Record<string, unknown>;
@@ -369,10 +247,6 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
369
247
  }
370
248
  },
371
249
 
372
- hasActiveTransition(): boolean {
373
- return nav.transition != null;
374
- },
375
-
376
250
  cleanup(): void {
377
251
  nav.removeEventListener('navigate', handleNavigate);
378
252
  },
@@ -47,7 +47,9 @@
47
47
  *
48
48
  * Nothing here may reopen an action scope: no `startTransition` callback in
49
49
  * this path may return a thenable, and no caller may invoke this from inside
50
- * a React action scope. That is a rule about `startTransition`, not about
50
+ * a React action scope — except one that settles that action at `onHandOff`,
51
+ * which commits the action and the tree together on purpose (a server action
52
+ * redirect, TIM-1573). That is a rule about `startTransition`, not about
51
53
  * `perform` — `perform` is async by contract and is awaited OUTSIDE any
52
54
  * transition scope, which is exactly why it is safe.
53
55
  *
@@ -233,6 +235,13 @@ export function createRenderOwner(kind: 'navigation' | 'revalidation'): RenderOw
233
235
  * detected via `owner.outcome` (sync) and `owner.displaced` (async).
234
236
  * No module-level state; no globalThis singleton.
235
237
  *
238
+ * `onHandOff` runs synchronously right after `render` has scheduled the
239
+ * tree, before React can commit it. A caller inside a React action scope may
240
+ * settle that action here (and only here): the render is already entangled
241
+ * with the scope, so settling commits the action and this tree together,
242
+ * where settling earlier commits the action alone and settling on the commit
243
+ * deadlocks. The server action redirect is that caller (TIM-1573).
244
+ *
236
245
  * Used for: navigate(), refresh(), popstate with fetch.
237
246
  */
238
247
  export function navigateTransition(
@@ -240,7 +249,8 @@ export function navigateTransition(
240
249
  perform: () => Promise<TransitionResult>,
241
250
  render: NavigationRender,
242
251
  types: readonly string[],
243
- onCommit?: (outcome: CommitOutcome) => void
252
+ onCommit?: (outcome: CommitOutcome) => void,
253
+ onHandOff?: () => void
244
254
  ): Promise<void> {
245
255
  const superseded = () => new DOMException('Navigation superseded', 'AbortError');
246
256
 
@@ -284,12 +294,13 @@ export function navigateTransition(
284
294
  },
285
295
  types
286
296
  );
297
+ onHandOff?.();
287
298
  // React may commit the tree before this settles — that is the point:
288
299
  // the destination reveals as React is able to render it
289
300
  // rather than waiting for the whole Flight stream. The await is here so
290
301
  // the promise this function hands back still means "the payload is
291
- // decoded", which is what the router's scroll restoration, the
292
- // Navigation API deferred and `<Link>`'s `isPending` are timed against.
302
+ // decoded", which is what the router's scroll restoration and
303
+ // `<Link>`'s `isPending` are timed against.
293
304
  //
294
305
  // ...unless this navigation loses first, in which case it stops waiting
295
306
  // on a stream that is no longer its business.
@@ -150,9 +150,11 @@ export interface NavigationRecoveryDeps {
150
150
  * Replace the current entry with `url` — the router's own `navigate()`,
151
151
  * rendering with `types`: the view transition types of the render the
152
152
  * redirect interrupted, so a redirected Back still animates as Back and a
153
- * link's own `transitionTypes` survive the hop (TIM-1471).
153
+ * link's own `transitionTypes` survive the hop (TIM-1471). `onHandOff`
154
+ * survives it too: a server action redirect waits for the tree that is
155
+ * finally handed to React, which is the redirected one (TIM-1573).
154
156
  */
155
- navigate: (url: string, types: readonly string[]) => Promise<void>;
157
+ navigate: (url: string, types: readonly string[], onHandOff?: () => void) => Promise<void>;
156
158
  }
157
159
 
158
160
  /**
@@ -188,13 +190,15 @@ export function createNavigationRecovery({
188
190
  url: string,
189
191
  fromUrl: string,
190
192
  /** The view transition types of the render that failed. */
191
- types: readonly string[]
193
+ types: readonly string[],
194
+ /** The failed navigation's `NavigationOptions.onHandOff`, for the hop. */
195
+ onHandOff?: () => void
192
196
  ): Promise<boolean> {
193
197
  if (error instanceof RedirectError) {
194
198
  // Same ownership rule as leaving the SPA: a superseded navigation must
195
199
  // not steer the document to the destination it was abandoned for.
196
200
  if (currentOwner() !== owner) return true;
197
- await navigate(error.redirectUrl, types);
201
+ await navigate(error.redirectUrl, types, onHandOff);
198
202
  return true;
199
203
  }
200
204
  // A server error, a non-RSC response and a version skew all end the same