@timber-js/app 0.2.0-alpha.212 → 0.2.0-alpha.214

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 (134) hide show
  1. package/dist/_chunks/{actions-CCdnVtWm.js → actions-CUh3cClk.js} +43 -8
  2. package/dist/_chunks/{actions-CCdnVtWm.js.map → actions-CUh3cClk.js.map} +1 -1
  3. package/dist/_chunks/{canonicalize-CgHoscYO.js → canonicalize-BAWkWiKK.js} +28 -2
  4. package/dist/_chunks/{canonicalize-CgHoscYO.js.map → canonicalize-BAWkWiKK.js.map} +1 -1
  5. package/dist/_chunks/{chains-BfoPFraI.js → chains-CjK1Eu6a.js} +2 -2
  6. package/dist/_chunks/{chains-BfoPFraI.js.map → chains-CjK1Eu6a.js.map} +1 -1
  7. package/dist/_chunks/{classify-BT66U83D.js → classify-GAdt6aiA.js} +2 -2
  8. package/dist/_chunks/{classify-BT66U83D.js.map → classify-GAdt6aiA.js.map} +1 -1
  9. package/dist/_chunks/{cli-check-BfQ54-UJ.js → cli-check-DOzjlycg.js} +3 -3
  10. package/dist/_chunks/{cli-check-BfQ54-UJ.js.map → cli-check-DOzjlycg.js.map} +1 -1
  11. package/dist/_chunks/{cli-schema-sync-czh2dsLs.js → cli-schema-sync-B9wDaQvW.js} +2 -2
  12. package/dist/_chunks/{cli-schema-sync-czh2dsLs.js.map → cli-schema-sync-B9wDaQvW.js.map} +1 -1
  13. package/dist/_chunks/{client-dep-entries-CQwpb8dI.js → client-dep-entries-DyDqXOF9.js} +2 -2
  14. package/dist/_chunks/{client-dep-entries-CQwpb8dI.js.map → client-dep-entries-DyDqXOF9.js.map} +1 -1
  15. package/dist/_chunks/{convention-lint-BEVW4EID.js → convention-lint-B3QEGJX7.js} +2 -2
  16. package/dist/_chunks/{convention-lint-BEVW4EID.js.map → convention-lint-B3QEGJX7.js.map} +1 -1
  17. package/dist/_chunks/{dev-server-FKxptbnI.js → dev-server-_9L4KWC-.js} +3 -3
  18. package/dist/_chunks/{dev-server-FKxptbnI.js.map → dev-server-_9L4KWC-.js.map} +1 -1
  19. package/dist/_chunks/{error-boundary-DsNScGRM.js → error-boundary-xxxLtXt6.js} +38 -5
  20. package/dist/_chunks/{error-boundary-DsNScGRM.js.map → error-boundary-xxxLtXt6.js.map} +1 -1
  21. package/dist/_chunks/{live-graph-C_4v-fHv.js → live-graph-Cd3YHvrH.js} +4 -4
  22. package/dist/_chunks/{live-graph-C_4v-fHv.js.map → live-graph-Cd3YHvrH.js.map} +1 -1
  23. package/dist/_chunks/{poison-scan-C92liMAr.js → poison-scan-BnJjBOkn.js} +2 -2
  24. package/dist/_chunks/{poison-scan-C92liMAr.js.map → poison-scan-BnJjBOkn.js.map} +1 -1
  25. package/dist/_chunks/{scanner-CQt12vE2.js → scanner-CKAT5gRx.js} +2 -2
  26. package/dist/_chunks/{scanner-CQt12vE2.js.map → scanner-CKAT5gRx.js.map} +1 -1
  27. package/dist/_chunks/{walkers-DAT4avhZ.js → walkers-DwCXEyRu.js} +3 -3
  28. package/dist/_chunks/{walkers-DAT4avhZ.js.map → walkers-DwCXEyRu.js.map} +1 -1
  29. package/dist/adapters/nitro-preview.d.ts.map +1 -1
  30. package/dist/adapters/nitro.js +11 -2
  31. package/dist/adapters/nitro.js.map +1 -1
  32. package/dist/analyze/crawl-entry.js +3 -3
  33. package/dist/analyze/graph-command.js +2 -2
  34. package/dist/cli.js +3 -3
  35. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  36. package/dist/client/browser-entry/action-queue.d.ts +1 -0
  37. package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
  38. package/dist/client/browser-entry/form-state.d.ts +22 -0
  39. package/dist/client/browser-entry/form-state.d.ts.map +1 -0
  40. package/dist/client/browser-entry/hydrate.d.ts +9 -1
  41. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  42. package/dist/client/browser-entry/index.d.ts +2 -0
  43. package/dist/client/browser-entry/index.d.ts.map +1 -1
  44. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  45. package/dist/client/error-boundary.js +1 -1
  46. package/dist/client/index.d.ts +1 -0
  47. package/dist/client/index.d.ts.map +1 -1
  48. package/dist/client/index.js +90 -2
  49. package/dist/client/index.js.map +1 -1
  50. package/dist/client/internal.js +26 -10
  51. package/dist/client/internal.js.map +1 -1
  52. package/dist/client/navigation-transition.d.ts +11 -2
  53. package/dist/client/navigation-transition.d.ts.map +1 -1
  54. package/dist/client/router-effects.d.ts +70 -8
  55. package/dist/client/router-effects.d.ts.map +1 -1
  56. package/dist/client/router-pipeline.d.ts +3 -1
  57. package/dist/client/router-pipeline.d.ts.map +1 -1
  58. package/dist/client/router-types.d.ts +12 -1
  59. package/dist/client/router-types.d.ts.map +1 -1
  60. package/dist/client/router.d.ts.map +1 -1
  61. package/dist/client/use-form-field.d.ts +39 -0
  62. package/dist/client/use-form-field.d.ts.map +1 -0
  63. package/dist/config-types.d.ts +2 -1
  64. package/dist/config-types.d.ts.map +1 -1
  65. package/dist/index.js +5 -5
  66. package/dist/index.js.map +1 -1
  67. package/dist/routing/index.js +2 -2
  68. package/dist/server/action-client.d.ts +11 -2
  69. package/dist/server/action-client.d.ts.map +1 -1
  70. package/dist/server/canonicalize.d.ts +22 -0
  71. package/dist/server/canonicalize.d.ts.map +1 -1
  72. package/dist/server/flight-scripts.d.ts +9 -0
  73. package/dist/server/flight-scripts.d.ts.map +1 -1
  74. package/dist/server/form-data.d.ts +13 -4
  75. package/dist/server/form-data.d.ts.map +1 -1
  76. package/dist/server/form-state-flight.d.ts +32 -0
  77. package/dist/server/form-state-flight.d.ts.map +1 -0
  78. package/dist/server/index.js +37 -23
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/internal.js +54 -3
  81. package/dist/server/internal.js.map +1 -1
  82. package/dist/server/pipeline-helpers.d.ts +31 -0
  83. package/dist/server/pipeline-helpers.d.ts.map +1 -1
  84. package/dist/server/pipeline.d.ts.map +1 -1
  85. package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
  86. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  87. package/dist/server/rsc-entry/render-route.d.ts +2 -0
  88. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  89. package/dist/server/rsc-entry/ssr-renderer.d.ts +6 -0
  90. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  91. package/dist/server/ssr-bridge-types.d.ts +7 -5
  92. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  93. package/dist/server/ssr-entry.d.ts.map +1 -1
  94. package/dist/server/ssr-form-state.d.ts +30 -0
  95. package/dist/server/ssr-form-state.d.ts.map +1 -0
  96. package/dist/shared/form-state-flight.d.ts +36 -0
  97. package/dist/shared/form-state-flight.d.ts.map +1 -0
  98. package/docs/api/31-api-client.mdx +22 -0
  99. package/docs/api/34-api-config.mdx +1 -1
  100. package/docs/learn/08-forms-and-actions.mdx +109 -22
  101. package/package.json +1 -1
  102. package/src/adapters/nitro-preview.ts +10 -1
  103. package/src/client/browser-entry/action-dispatch.ts +34 -10
  104. package/src/client/browser-entry/action-queue.ts +1 -1
  105. package/src/client/browser-entry/form-state.ts +48 -0
  106. package/src/client/browser-entry/hydrate.ts +9 -18
  107. package/src/client/browser-entry/index.ts +25 -7
  108. package/src/client/browser-entry/router-init.ts +3 -2
  109. package/src/client/index.ts +1 -0
  110. package/src/client/navigation-transition.ts +13 -2
  111. package/src/client/router-effects.ts +99 -10
  112. package/src/client/router-pipeline.ts +7 -3
  113. package/src/client/router-types.ts +12 -1
  114. package/src/client/router.ts +35 -6
  115. package/src/client/use-form-field.ts +132 -0
  116. package/src/config-types.ts +2 -1
  117. package/src/server/action-client.ts +68 -53
  118. package/src/server/canonicalize.ts +29 -0
  119. package/src/server/flight-scripts.ts +13 -0
  120. package/src/server/form-data.ts +62 -10
  121. package/src/server/form-state-flight.ts +67 -0
  122. package/src/server/pipeline-helpers.ts +61 -0
  123. package/src/server/pipeline.ts +13 -1
  124. package/src/server/rsc-entry/action-dispatcher.ts +3 -0
  125. package/src/server/rsc-entry/index.ts +1 -0
  126. package/src/server/rsc-entry/render-route.ts +16 -0
  127. package/src/server/rsc-entry/ssr-renderer.ts +12 -14
  128. package/src/server/ssr-bridge-types.ts +7 -6
  129. package/src/server/ssr-entry.ts +16 -3
  130. package/src/server/ssr-form-state.ts +58 -0
  131. package/src/shared/form-state-flight.ts +74 -0
  132. package/dist/server/form-state-embed.d.ts +0 -32
  133. package/dist/server/form-state-embed.d.ts.map +0 -1
  134. package/src/server/form-state-embed.ts +0 -63
@@ -76,6 +76,13 @@ interface HydrateOptions {
76
76
  reactRoot: ReactRootHost;
77
77
  /** Makes the page current and builds its tree — see `RouterInitResult.hydrate`. */
78
78
  hydrate: RouterInitResult['hydrate'];
79
+ /**
80
+ * The decoded form state of the no-JS action this page answers, or `null`
81
+ * (./form-state.ts). `hydrateRoot` needs the value Fizz rendered with, or
82
+ * the `useActionState` hook that submitted hydrates back to its initial
83
+ * state. See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
84
+ */
85
+ formState: ReactFormState | null;
79
86
  }
80
87
 
81
88
  /**
@@ -90,22 +97,6 @@ function takeEmbeddedSegmentInfo(): SegmentInfo[] | null {
90
97
  return Array.isArray(embedded) ? embedded : null;
91
98
  }
92
99
 
93
- /**
94
- * Take the form state the server embedded (`self.__timber_form_state`) when
95
- * this page answers a no-JS action, and remove it. `hydrateRoot` needs the
96
- * value Fizz rendered with, or the `useActionState` hook that submitted
97
- * hydrates back to its initial state. See design/08-forms-and-actions.md
98
- * §"No-JS Result Round-Trip".
99
- */
100
- function takeEmbeddedFormState(): ReactFormState | null {
101
- const embedded: unknown = Reflect.get(self, '__timber_form_state');
102
- Reflect.deleteProperty(self, '__timber_form_state');
103
- // `ReactFormState` is an opaque type: React builds it (`decodeFormState`
104
- // on the server) and only React reads it, so there is no shape to check
105
- // beyond the tuple. It is our own server's JSON of that value.
106
- return Array.isArray(embedded) ? (embedded as unknown as ReactFormState) : null;
107
- }
108
-
109
100
  /**
110
101
  * Make the server-rendered page current, and hydrate the React tree with it
111
102
  * when an RSC payload is available.
@@ -122,7 +113,7 @@ function takeEmbeddedFormState(): ReactFormState | null {
122
113
  * first navigation or revalidation, creates it with the tree it renders
123
114
  * (`createReactRoot`, TIM-600 / TIM-580).
124
115
  */
125
- export function hydrateApp({ rscResult, reactRoot, hydrate }: HydrateOptions): void {
116
+ export function hydrateApp({ rscResult, reactRoot, hydrate, formState }: HydrateOptions): void {
126
117
  // The chain is the router's own `renderTree`, not a copy: an element type
127
118
  // that is present here and absent on the first navigation (or the
128
119
  // reverse) changes the type at that position, and React remounts
@@ -143,7 +134,7 @@ export function hydrateApp({ rscResult, reactRoot, hydrate }: HydrateOptions): v
143
134
  // construction when the tree it builds renders.
144
135
  hydrate(page, (element) =>
145
136
  reactRoot.hydrate(element, {
146
- formState: takeEmbeddedFormState(),
137
+ formState,
147
138
  // Suppress recoverable hydration errors from deny/error signals
148
139
  // inside Suspense boundaries. The server already handled these
149
140
  // (wrapStreamWithErrorHandling closes the stream cleanly after
@@ -15,6 +15,8 @@
15
15
  * Bootstrap call order contract:
16
16
  *
17
17
  * 1. setupServerActions() — register callServer (independent)
18
+ * takeEmbeddedFormState() — only on a page answering a no-JS action:
19
+ * decode its form state, and run 2–7 after
18
20
  * 2. createRscPayloadStream() — decode inlined RSC payload
19
21
  * 3. createReactRoot() + — the root host the router renders through,
20
22
  * createTimberRouter() then the router + Navigation API
@@ -41,9 +43,11 @@ import { initStaleClient } from '../stale-client.ts';
41
43
 
42
44
  import { setupServerActions } from './action-dispatch.ts';
43
45
  import { createRscPayloadStream } from './rsc-stream.ts';
46
+ import { takeEmbeddedFormState } from './form-state.ts';
44
47
  import { createTimberRouter } from './router-init.ts';
45
48
  import { createReactRoot } from '../react-root.ts';
46
49
  import type { TopLoaderConfig } from '../top-loader.tsx';
50
+ import type { ReactFormState } from 'react-dom/client';
47
51
  import { hydrateApp } from './hydrate.ts';
48
52
  import { setupPostHydration } from './post-hydration.ts';
49
53
  import { setupHmr } from './hmr.ts';
@@ -54,7 +58,7 @@ setupServerActions();
54
58
 
55
59
  // ─── 2–6. Bootstrap ─────────────────────────────────────────────
56
60
 
57
- function bootstrap(runtimeConfig: typeof config): void {
61
+ function bootstrap(runtimeConfig: typeof config, formState: ReactFormState | null): void {
58
62
  // Initialize deployment ID for version skew detection (TIM-446).
59
63
  // In dev mode this is null — skew checks are skipped.
60
64
  const deploymentId = (runtimeConfig as Record<string, unknown>).deploymentId as string | null;
@@ -95,7 +99,7 @@ function bootstrap(runtimeConfig: typeof config): void {
95
99
  });
96
100
 
97
101
  // Step 4: Make the page current and hydrate (no root without a payload)
98
- hydrateApp({ rscResult, reactRoot, hydrate });
102
+ hydrateApp({ rscResult, reactRoot, hydrate, formState });
99
103
 
100
104
  // Step 5: Post-hydration wiring
101
105
  setupPostHydration({ router, navApiController });
@@ -104,8 +108,6 @@ function bootstrap(runtimeConfig: typeof config): void {
104
108
  setupHmr(router);
105
109
  }
106
110
 
107
- bootstrap(config);
108
-
109
111
  // ─── 7. Ready Signal ────────────────────────────────────────────
110
112
 
111
113
  // Signal that the client runtime has been initialized.
@@ -115,6 +117,22 @@ bootstrap(config);
115
117
  // via hydrateRoot(document, ...), mutating <html> attributes causes
116
118
  // hydration mismatch warnings. Dynamically-added <meta> tags don't
117
119
  // conflict because React doesn't reconcile them.
118
- const readyMeta = document.createElement('meta');
119
- readyMeta.name = 'timber-ready';
120
- document.head.appendChild(readyMeta);
120
+ function signalReady(): void {
121
+ const readyMeta = document.createElement('meta');
122
+ readyMeta.name = 'timber-ready';
123
+ document.head.appendChild(readyMeta);
124
+ }
125
+
126
+ // A page answering a no-JS action must hydrate with the form state Fizz
127
+ // rendered, which is decoded first (design/08 §"No-JS Result Round-Trip").
128
+ // Every other page bootstraps synchronously, as it always has.
129
+ const embeddedFormState = takeEmbeddedFormState();
130
+ if (embeddedFormState) {
131
+ void embeddedFormState.then((formState) => {
132
+ bootstrap(config, formState);
133
+ signalReady();
134
+ });
135
+ } else {
136
+ bootstrap(config, null);
137
+ signalReady();
138
+ }
@@ -297,7 +297,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
297
297
  //
298
298
  // `_url` names the pending URL the router already published to its own
299
299
  // pending store before calling here; the transition has no use for it.
300
- navigateTransition: (_url: string, owner: RenderOwner, types, perform, onCommit) => {
300
+ navigateTransition: (_url: string, owner: RenderOwner, types, perform, onCommit, onHandOff) => {
301
301
  return navigateTransition(
302
302
  owner,
303
303
  async () => {
@@ -334,7 +334,8 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
334
334
  },
335
335
  render,
336
336
  types,
337
- onCommit
337
+ onCommit,
338
+ onHandOff
338
339
  );
339
340
  },
340
341
 
@@ -76,6 +76,7 @@ export {
76
76
 
77
77
  // Forms
78
78
  export { parseFormErrors, useFormAction } from './form.tsx';
79
+ export { useFormField } from './use-form-field.ts';
79
80
  export type { FormErrorsResult } from './form.tsx';
80
81
 
81
82
  // Params. Called with no argument this returns the untyped accumulated params;
@@ -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,6 +294,7 @@ 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
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { setHardNavigating } from './navigation-root.tsx';
13
- import type { RenderOwner } from './navigation-transition.ts';
13
+ import type { CommitOutcome, RenderOwner } from './navigation-transition.ts';
14
14
  import { RedirectError, ServerErrorResponse, NonRscResponse } from './rsc-fetch.ts';
15
15
  import { recordSkew } from './router-skew.ts';
16
16
 
@@ -147,12 +147,96 @@ export interface NavigationRecoveryDeps {
147
147
  currentOwner: () => RenderOwner | null;
148
148
  leaveSpaIfOwned: SpaExits['leaveSpaIfOwned'];
149
149
  /**
150
- * Replace the current entry with `url` — the router's own `navigate()`,
151
- * rendering with `types`: the view transition types of the render the
152
- * redirect interrupted, so a redirected Back still animates as Back and a
153
- * link's own `transitionTypes` survive the hop (TIM-1471).
150
+ * Follow a redirect to `url` — the router's own `navigate()`, carrying
151
+ * the interrupted navigation's {@link RedirectHop}.
154
152
  */
155
- navigate: (url: string, types: readonly string[]) => Promise<void>;
153
+ navigate: (url: string, hop: RedirectHop) => Promise<void>;
154
+ }
155
+
156
+ /**
157
+ * What a redirect hop inherits from the navigation the redirect interrupted.
158
+ */
159
+ export interface RedirectHop {
160
+ /**
161
+ * The view transition types of the interrupted render, so a redirected Back
162
+ * still animates as Back and a link's own `transitionTypes` survive the
163
+ * hop (TIM-1471).
164
+ */
165
+ types: readonly string[];
166
+ /**
167
+ * The interrupted navigation's history mode. A push stays a push: the
168
+ * address bar moves only when a destination commits, so the interrupted
169
+ * navigation wrote no entry, and replacing would overwrite the page being
170
+ * left — Back would skip it (TIM-1571). A refresh or a traversal replaces:
171
+ * the browser is already on the entry being redirected.
172
+ */
173
+ replace: boolean;
174
+ /**
175
+ * The interrupted navigation's scroll mode. `<Link scroll={false}>` to a
176
+ * URL that redirects — a non-canonical `/x/` among them — still keeps the
177
+ * scroll position; the hop would otherwise default to scrolling to the top.
178
+ */
179
+ scroll: boolean;
180
+ /**
181
+ * The interrupted navigation's `onCommit`, as {@link HeldCommit.settle}.
182
+ * A followed redirect is one logical navigation: the hop's commit is the
183
+ * one the caller is waiting for. `<Link>` holds `isPending` until it, and
184
+ * a `'failed'` from the interrupted render would release it on the hop's
185
+ * decode, ahead of a commit React is still holding (TIM-1571). Recovery
186
+ * delivers it itself on every path that does not follow the redirect.
187
+ */
188
+ onCommit?: (outcome: CommitOutcome) => void;
189
+ /**
190
+ * The interrupted navigation's `onHandOff`: a server action redirect
191
+ * waits for the tree that is finally handed to React, which is the
192
+ * redirected one (TIM-1573).
193
+ */
194
+ onHandOff?: () => void;
195
+ }
196
+
197
+ /**
198
+ * A navigation's `onCommit`, split around its failure.
199
+ *
200
+ * `navigateTransition` settles `onCommit` `'failed'` as soon as `perform`
201
+ * throws — before the router has seen the error, so before it knows the
202
+ * failure is a redirect it will follow. `transition` is what the render
203
+ * gets: every outcome but `'failed'` reaches the caller at once, and
204
+ * `'failed'` is left to recovery, which either hands `settle` to the
205
+ * redirect hop or delivers it. `settle` runs the caller's callback at most
206
+ * once, so a commit or supersession already delivered wins over a later
207
+ * `'failed'`.
208
+ */
209
+ export interface HeldCommit {
210
+ transition: ((outcome: CommitOutcome) => void) | undefined;
211
+ settle: ((outcome: CommitOutcome) => void) | undefined;
212
+ }
213
+
214
+ export function holdCommit(onCommit?: (outcome: CommitOutcome) => void): HeldCommit {
215
+ if (!onCommit) return { transition: undefined, settle: undefined };
216
+ let settled = false;
217
+ const settle = (outcome: CommitOutcome): void => {
218
+ if (settled) return;
219
+ settled = true;
220
+ onCommit(outcome);
221
+ };
222
+ return {
223
+ transition: (outcome) => {
224
+ if (outcome !== 'failed') settle(outcome);
225
+ },
226
+ settle,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * The URL a redirect hop navigates to. A redirect target without a fragment
232
+ * keeps the one the navigation asked for, as a browser does for a 3xx
233
+ * (RFC 9110 §10.2.2): the server never sees the fragment, so a soft redirect
234
+ * — `<Link href="/docs/#install">` answered with `/docs` — cannot carry it.
235
+ */
236
+ export function redirectHopUrl(redirectUrl: string, requestedUrl: string): string {
237
+ if (redirectUrl.includes('#')) return redirectUrl;
238
+ const hashIndex = requestedUrl.indexOf('#');
239
+ return hashIndex === -1 ? redirectUrl : redirectUrl + requestedUrl.slice(hashIndex);
156
240
  }
157
241
 
158
242
  /**
@@ -187,16 +271,21 @@ export function createNavigationRecovery({
187
271
  owner: RenderOwner,
188
272
  url: string,
189
273
  fromUrl: string,
190
- /** The view transition types of the render that failed. */
191
- types: readonly string[]
274
+ /** What a redirect hop inherits from the navigation that failed. */
275
+ hop: RedirectHop
192
276
  ): Promise<boolean> {
193
277
  if (error instanceof RedirectError) {
194
278
  // Same ownership rule as leaving the SPA: a superseded navigation must
195
279
  // not steer the document to the destination it was abandoned for.
196
- if (currentOwner() !== owner) return true;
197
- await navigate(error.redirectUrl, types);
280
+ if (currentOwner() !== owner) {
281
+ hop.onCommit?.('superseded');
282
+ return true;
283
+ }
284
+ await navigate(redirectHopUrl(error.redirectUrl, url), hop);
198
285
  return true;
199
286
  }
287
+ // Anything else ends this navigation here: its held `'failed'`.
288
+ hop.onCommit?.('failed');
200
289
  // A server error, a non-RSC response and a version skew all end the same
201
290
  // way: hand `url` back to the server as a document load, so it can render
202
291
  // the outcome as HTML (design/10-error-handling.md §"Error Page Rendering
@@ -84,7 +84,9 @@ export interface NavigationPipeline {
84
84
  types: readonly string[],
85
85
  perform: () => Promise<NavigationPayload>,
86
86
  /** See `NavigationOptions.onCommit`. */
87
- onCommit?: (outcome: CommitOutcome) => void
87
+ onCommit?: (outcome: CommitOutcome) => void,
88
+ /** See `NavigationOptions.onHandOff`. */
89
+ onHandOff?: () => void
88
90
  ) => Promise<void>;
89
91
  }
90
92
 
@@ -186,7 +188,8 @@ export function createNavigationPipeline({
186
188
  owner: RenderOwner,
187
189
  types: readonly string[],
188
190
  perform: () => Promise<NavigationPayload>,
189
- onCommit?: (outcome: CommitOutcome) => void
191
+ onCommit?: (outcome: CommitOutcome) => void,
192
+ onHandOff?: () => void
190
193
  ): Promise<void> {
191
194
  // Record that THIS navigation's payload has reached React, at the moment
192
195
  // the tree is built and handed back for `navigateTransition` to give to
@@ -260,7 +263,8 @@ export function createNavigationPipeline({
260
263
  commit: commitAndForget(result.commit),
261
264
  };
262
265
  },
263
- onCommit
266
+ onCommit,
267
+ onHandOff
264
268
  );
265
269
  }
266
270
 
@@ -89,6 +89,15 @@ export interface NavigationOptions {
89
89
  * which of the three it was.
90
90
  */
91
91
  onCommit?: (outcome: CommitOutcome) => void;
92
+ /**
93
+ * @internal Runs once, synchronously after this navigation's tree is handed
94
+ * to React (its transition scheduled) and before React can commit it. Not
95
+ * at all if the navigation is superseded or fails first; the returned
96
+ * promise settles then. A server action that redirects settles its
97
+ * `useActionState` here, so React commits the action's result and the
98
+ * destination together (TIM-1573).
99
+ */
100
+ onHandOff?: () => void;
92
101
  }
93
102
 
94
103
  /**
@@ -155,7 +164,9 @@ export interface RouterDeps {
155
164
  ) => unknown
156
165
  ) => Promise<TransitionResult<unknown>>,
157
166
  /** See `NavigationOptions.onCommit`; forwarded to `navigateTransition`. */
158
- onCommit?: (outcome: CommitOutcome) => void
167
+ onCommit?: (outcome: CommitOutcome) => void,
168
+ /** See `NavigationOptions.onHandOff`; forwarded to `navigateTransition`. */
169
+ onHandOff?: () => void
159
170
  ) => Promise<void>;
160
171
 
161
172
  /**
@@ -18,7 +18,12 @@ import { createNavigationCommitter } from './navigation-commit.ts';
18
18
  import { fetchRscPayload, NonRscResponse } from './rsc-fetch.ts';
19
19
  import { readPayloadTree, readPublishedParams } from '../shared/payload-root.ts';
20
20
  import { isClientStale } from './stale-client.ts';
21
- import { createScrollEffects, createSpaExits, createNavigationRecovery } from './router-effects.ts';
21
+ import {
22
+ createScrollEffects,
23
+ createSpaExits,
24
+ createNavigationRecovery,
25
+ holdCommit,
26
+ } from './router-effects.ts';
22
27
  import { createNavigationLifecycle } from './router-lifecycle.ts';
23
28
  import { createNavigationPipeline, prefetchKeyFor } from './router-pipeline.ts';
24
29
  import { recordSkew } from './router-skew.ts';
@@ -102,7 +107,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
102
107
  currentOwner,
103
108
  leaveSpaIfOwned,
104
109
  // Hoisted — `navigate` is a function declaration below.
105
- navigate: (url, types) => navigate(url, { replace: true, _renderTypes: types }),
110
+ navigate: (url, { types, replace, scroll, onCommit, onHandOff }) =>
111
+ navigate(url, { replace, scroll, _renderTypes: types, onCommit, onHandOff }),
106
112
  });
107
113
 
108
114
  async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
@@ -151,6 +157,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
151
157
  await leaveSpaSuperseding(url, departingUrl);
152
158
  }
153
159
 
160
+ // A redirect this navigation follows inherits its onCommit (RedirectHop).
161
+ const heldCommit = holdCommit(options.onCommit);
162
+
154
163
  await runNavigation(url, async (owner) => {
155
164
  try {
156
165
  await renderViaTransition(
@@ -164,7 +173,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
164
173
  signal: owner.fetchAbort.signal,
165
174
  departingUrl,
166
175
  }),
167
- options.onCommit
176
+ heldCommit.transition,
177
+ options.onHandOff
168
178
  );
169
179
 
170
180
  // Scroll-to-top on forward navigation, scroll to the #fragment target
@@ -180,7 +190,17 @@ export function createRouter(deps: RouterDeps): RouterInstance {
180
190
  // load of the destination, fragment included (TIM-1234). Reloading
181
191
  // instead would rebuild the page they were *leaving* and discard
182
192
  // the click (TIM-1275).
183
- if (await recoverFromNavigationError(error, owner, url, departingUrl, types)) return;
193
+ if (
194
+ await recoverFromNavigationError(error, owner, url, departingUrl, {
195
+ types,
196
+ replace,
197
+ scroll,
198
+ onCommit: heldCommit.settle,
199
+ onHandOff: options.onHandOff,
200
+ })
201
+ ) {
202
+ return;
203
+ }
184
204
  throw error;
185
205
  }
186
206
  });
@@ -209,6 +229,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
209
229
  onCommit?: (outcome: CommitOutcome) => void;
210
230
  } = {}
211
231
  ): Promise<void> {
232
+ const heldCommit = holdCommit(opts.onCommit);
212
233
  await runNavigation(
213
234
  url,
214
235
  async (owner) => {
@@ -234,7 +255,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
234
255
  });
235
256
  return { ...result, params, navState, commit };
236
257
  },
237
- opts.onCommit
258
+ heldCommit.transition
238
259
  );
239
260
  } catch (error) {
240
261
  // Neither path is a navigate(), and neither has a caller that
@@ -248,7 +269,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
248
269
  // browser has already traversed, and `refresh()` is by definition
249
270
  // where it already is. So `hardNavigate()` takes its same-document
250
271
  // branch and reloads rather than pushing an entry.
251
- if (await recoverFromNavigationError(error, owner, url, url, [type])) return;
272
+ if (
273
+ await recoverFromNavigationError(error, owner, url, url, {
274
+ types: [type],
275
+ replace: true,
276
+ scroll: true,
277
+ onCommit: heldCommit.settle,
278
+ })
279
+ )
280
+ return;
252
281
  throw error;
253
282
  }
254
283
 
@@ -0,0 +1,132 @@
1
+ /**
2
+ * useFormField — state for a form field that needs JavaScript (a combobox, a
3
+ * chip list, a row list), made to behave like a native uncontrolled input.
4
+ *
5
+ * React resets a form after its action settles, so a native field with a
6
+ * `defaultValue` lands on whatever default the page now renders: the action's
7
+ * `submittedValues`, or fresh page data after a redirect. State held in
8
+ * `useState` misses that reset and keeps showing the pre-submit edit. This
9
+ * hook shows `defaultValue` until the user edits, and drops the edit when the
10
+ * form resets, so it lands where the native fields do.
11
+ *
12
+ * See design/08-forms-and-actions.md §"The Form Model".
13
+ */
14
+
15
+ import {
16
+ useCallback,
17
+ useEffect,
18
+ useLayoutEffect,
19
+ useRef,
20
+ useState,
21
+ type Dispatch,
22
+ type RefCallback,
23
+ type SetStateAction,
24
+ } from 'react';
25
+
26
+ /**
27
+ * State for a field that follows its form's reset.
28
+ *
29
+ * Returns `[value, setValue, ref]`. Attach `ref` to any element inside the
30
+ * `<form>`, and render the value into the form data yourself (usually a
31
+ * hidden input). `value` is `defaultValue` until `setValue` is called, and is
32
+ * `defaultValue` again after the form resets, unless the form cancels its
33
+ * `reset` event. An edit dispatches a bubbling `change` event from the `ref`
34
+ * element after it commits, so a native `change` listener on the form sees it
35
+ * as it sees a native input's (React's synthetic `onChange` reports only
36
+ * inputs, selects and textareas). A reset and a new default fire none, as
37
+ * they fire none for a native input.
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * // draft: the form's defaults, read from result?.submittedValues ?? pageData
42
+ * const [tags, setTags, ref] = useFormField(draft.tags);
43
+ * <div ref={ref}>
44
+ * <input type="hidden" name="tags" value={tags.join(',')} />
45
+ * <TagPicker value={tags} onChange={setTags} />
46
+ * </div>
47
+ * ```
48
+ */
49
+ export function useFormField<T>(
50
+ defaultValue: T
51
+ ): [value: T, setValue: Dispatch<SetStateAction<T>>, ref: RefCallback<HTMLElement>] {
52
+ // `null` means "not edited": the field shows its default. A box, so an edit
53
+ // back to a value equal to the default still counts as an edit.
54
+ const [edit, setEdit] = useState<{ value: T } | null>(null);
55
+ const value = edit ? edit.value : defaultValue;
56
+
57
+ // The latest default, for an updater called from an old closure. Written
58
+ // after commit, never during render.
59
+ const defaultRef = useRef(defaultValue);
60
+ useLayoutEffect(() => {
61
+ defaultRef.current = defaultValue;
62
+ });
63
+
64
+ // Resets the form has fired whose outcome this field has not applied yet.
65
+ // A reset's outcome is known only once its dispatch is over: our listener
66
+ // runs on the form before React's delegated `onReset`, which may cancel
67
+ // it. `takeResets` applies every reset whose dispatch has ended, in order:
68
+ // if any of them was not cancelled, the field was reset. Both the deferred
69
+ // clear and `setValue` call it, so writes follow a native input's order:
70
+ // - after `form.reset()` returns, the reset has happened and a write lands
71
+ // on top of it (the write takes the reset, and the clear finds nothing);
72
+ // - during the reset's dispatch (an `onReset` handler), the reset is still
73
+ // pending: the write applies to the current value, and the clear then
74
+ // erases it unless the reset is cancelled, as a reset overwrites a
75
+ // native input written in its handler.
76
+ // A list, not one slot: `reset(); reset()` where only the second is
77
+ // cancelled still resets, as it does the native fields.
78
+ const pendingResets = useRef<Event[]>([]);
79
+ const takeResets = useCallback((): boolean => {
80
+ // eventPhase is NONE once dispatch is over.
81
+ const over = pendingResets.current.filter((e) => e.eventPhase === Event.NONE);
82
+ if (over.length === 0) return false;
83
+ pendingResets.current = pendingResets.current.filter((e) => !over.includes(e));
84
+ return over.some((e) => !e.defaultPrevented);
85
+ }, []);
86
+
87
+ const setValue = useCallback<Dispatch<SetStateAction<T>>>(
88
+ (next) => {
89
+ const wasReset = takeResets();
90
+ setEdit((latest) => {
91
+ const current = wasReset ? null : latest;
92
+ const base = current ? current.value : defaultRef.current;
93
+ const value = next instanceof Function ? next(base) : next;
94
+ // Unchanged: keep the box, so nothing re-renders and no `change`
95
+ // fires, as a native input fires none for a value it already has.
96
+ return Object.is(value, base) ? current : { value };
97
+ });
98
+ },
99
+ [takeResets]
100
+ );
101
+
102
+ const element = useRef<HTMLElement | null>(null);
103
+ const ref = useCallback<RefCallback<HTMLElement>>(
104
+ (el) => {
105
+ element.current = el;
106
+ const form = el?.closest('form');
107
+ if (!form) return;
108
+ const onReset = (event: Event) => {
109
+ pendingResets.current.push(event);
110
+ // Dispatch is over by the time a microtask runs.
111
+ queueMicrotask(() => {
112
+ if (takeResets()) setEdit(null);
113
+ });
114
+ };
115
+ form.addEventListener('reset', onReset);
116
+ return () => {
117
+ element.current = null;
118
+ pendingResets.current = [];
119
+ form.removeEventListener('reset', onReset);
120
+ };
121
+ },
122
+ [takeResets]
123
+ );
124
+
125
+ // Every `setValue` stores a new box, and nothing else does except a reset
126
+ // (which stores null), so a new non-null box is exactly "the user edited".
127
+ useEffect(() => {
128
+ if (edit) element.current?.dispatchEvent(new Event('change', { bubbles: true }));
129
+ }, [edit]);
130
+
131
+ return [value, setValue, ref];
132
+ }
@@ -56,7 +56,8 @@ export interface TimberUserConfig {
56
56
  forms?: {
57
57
  /**
58
58
  * Strip sensitive fields (passwords, tokens, CVV, SSN, etc.) from the
59
- * `submittedValues` echoed back on validation failure.
59
+ * `submittedValues` echoed back when an action fails (a validation
60
+ * failure, or a `serverError` from a form submission).
60
61
  *
61
62
  * Applied to both the with-JS (`createActionClient`) and no-JS (form POST
62
63
  * re-render) paths. Safe-by-default: the built-in deny-list is active