@timber-js/app 0.2.0-alpha.212 → 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 (92) hide show
  1. package/dist/_chunks/{actions-CCdnVtWm.js → actions-CEootpB1.js} +42 -7
  2. package/dist/_chunks/{actions-CCdnVtWm.js.map → actions-CEootpB1.js.map} +1 -1
  3. package/dist/_chunks/{error-boundary-DsNScGRM.js → error-boundary-9g_Lb2na.js} +3 -3
  4. package/dist/_chunks/{error-boundary-DsNScGRM.js.map → error-boundary-9g_Lb2na.js.map} +1 -1
  5. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  6. package/dist/client/browser-entry/action-queue.d.ts +1 -0
  7. package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
  8. package/dist/client/browser-entry/form-state.d.ts +22 -0
  9. package/dist/client/browser-entry/form-state.d.ts.map +1 -0
  10. package/dist/client/browser-entry/hydrate.d.ts +9 -1
  11. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  12. package/dist/client/browser-entry/index.d.ts +2 -0
  13. package/dist/client/browser-entry/index.d.ts.map +1 -1
  14. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  15. package/dist/client/error-boundary.js +1 -1
  16. package/dist/client/index.d.ts +1 -0
  17. package/dist/client/index.d.ts.map +1 -1
  18. package/dist/client/index.js +90 -2
  19. package/dist/client/index.js.map +1 -1
  20. package/dist/client/internal.js +8 -7
  21. package/dist/client/internal.js.map +1 -1
  22. package/dist/client/navigation-transition.d.ts +11 -2
  23. package/dist/client/navigation-transition.d.ts.map +1 -1
  24. package/dist/client/router-effects.d.ts +7 -3
  25. package/dist/client/router-effects.d.ts.map +1 -1
  26. package/dist/client/router-pipeline.d.ts +3 -1
  27. package/dist/client/router-pipeline.d.ts.map +1 -1
  28. package/dist/client/router-types.d.ts +12 -1
  29. package/dist/client/router-types.d.ts.map +1 -1
  30. package/dist/client/router.d.ts.map +1 -1
  31. package/dist/client/use-form-field.d.ts +39 -0
  32. package/dist/client/use-form-field.d.ts.map +1 -0
  33. package/dist/config-types.d.ts +2 -1
  34. package/dist/config-types.d.ts.map +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/server/action-client.d.ts +11 -2
  37. package/dist/server/action-client.d.ts.map +1 -1
  38. package/dist/server/flight-scripts.d.ts +9 -0
  39. package/dist/server/flight-scripts.d.ts.map +1 -1
  40. package/dist/server/form-data.d.ts +13 -4
  41. package/dist/server/form-data.d.ts.map +1 -1
  42. package/dist/server/form-state-flight.d.ts +32 -0
  43. package/dist/server/form-state-flight.d.ts.map +1 -0
  44. package/dist/server/index.js +37 -23
  45. package/dist/server/index.js.map +1 -1
  46. package/dist/server/internal.js +1 -1
  47. package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
  48. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  49. package/dist/server/rsc-entry/render-route.d.ts +2 -0
  50. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/ssr-renderer.d.ts +6 -0
  52. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  53. package/dist/server/ssr-bridge-types.d.ts +7 -5
  54. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  55. package/dist/server/ssr-entry.d.ts.map +1 -1
  56. package/dist/server/ssr-form-state.d.ts +30 -0
  57. package/dist/server/ssr-form-state.d.ts.map +1 -0
  58. package/dist/shared/form-state-flight.d.ts +36 -0
  59. package/dist/shared/form-state-flight.d.ts.map +1 -0
  60. package/docs/api/31-api-client.mdx +22 -0
  61. package/docs/api/34-api-config.mdx +1 -1
  62. package/docs/learn/08-forms-and-actions.mdx +109 -22
  63. package/package.json +1 -1
  64. package/src/client/browser-entry/action-dispatch.ts +34 -10
  65. package/src/client/browser-entry/action-queue.ts +1 -1
  66. package/src/client/browser-entry/form-state.ts +48 -0
  67. package/src/client/browser-entry/hydrate.ts +9 -18
  68. package/src/client/browser-entry/index.ts +25 -7
  69. package/src/client/browser-entry/router-init.ts +3 -2
  70. package/src/client/index.ts +1 -0
  71. package/src/client/navigation-transition.ts +13 -2
  72. package/src/client/router-effects.ts +8 -4
  73. package/src/client/router-pipeline.ts +7 -3
  74. package/src/client/router-types.ts +12 -1
  75. package/src/client/router.ts +16 -3
  76. package/src/client/use-form-field.ts +132 -0
  77. package/src/config-types.ts +2 -1
  78. package/src/server/action-client.ts +68 -53
  79. package/src/server/flight-scripts.ts +13 -0
  80. package/src/server/form-data.ts +62 -10
  81. package/src/server/form-state-flight.ts +67 -0
  82. package/src/server/rsc-entry/action-dispatcher.ts +3 -0
  83. package/src/server/rsc-entry/index.ts +1 -0
  84. package/src/server/rsc-entry/render-route.ts +16 -0
  85. package/src/server/rsc-entry/ssr-renderer.ts +12 -14
  86. package/src/server/ssr-bridge-types.ts +7 -6
  87. package/src/server/ssr-entry.ts +16 -3
  88. package/src/server/ssr-form-state.ts +58 -0
  89. package/src/shared/form-state-flight.ts +74 -0
  90. package/dist/server/form-state-embed.d.ts +0 -32
  91. package/dist/server/form-state-embed.d.ts.map +0 -1
  92. package/src/server/form-state-embed.ts +0 -63
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssr-form-state.d.ts","sourceRoot":"","sources":["../../src/server/ssr-form-state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,EAAmB,KAAK,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AAI9F,MAAM,WAAW,YAAY;IAC3B,qCAAqC;IACrC,SAAS,EAAE,cAAc,GAAG,SAAS,CAAC;IACtC,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;CAChB;AAID,wBAAsB,eAAe,CACnC,KAAK,EAAE,UAAU,GAAG,SAAS,EAC7B,OAAO,EAAE;IACP;;;OAGG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,MAAM,EAAE,sBAAsB,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB,GACA,OAAO,CAAC,YAAY,CAAC,CAmBvB"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The no-JS action form state on its way to the two renders that need it
3
+ * (TIM-1572, design/08-forms-and-actions.md §"No-JS Result Round-Trip").
4
+ *
5
+ * The RSC environment serializes the form state with Flight
6
+ * (server/form-state-flight.ts). SSR decodes those bytes for Fizz, and the
7
+ * browser decodes the same bytes, embedded in the page, for `hydrateRoot`.
8
+ * Both sides call `decodeFormState` below with their own environment's
9
+ * Flight client, so the two renders get the same value, and the value a
10
+ * `useActionState` result has with JS (Flight, through the action response):
11
+ * a `Date` stays a `Date`, a `Map` a `Map`, and an `undefined` field is
12
+ * absent on both, because the Flight client drops it.
13
+ *
14
+ * The page carries the bytes as base64: Flight may write binary rows (a typed
15
+ * array in a result), which a text chunk would corrupt, and base64 has no
16
+ * character that can end a `<script>` element.
17
+ */
18
+ import type { ReactFormState } from 'react-dom/client';
19
+ /**
20
+ * A Flight client's `createFromReadableStream`, SSR's or the browser's, typed
21
+ * to the form state tuple it decodes. React builds `ReactFormState`
22
+ * (`decodeFormState` on the server) and only React reads it; the Flight
23
+ * client's type parameter is the only place that type is asserted.
24
+ */
25
+ export type FormStateFlightDecoder = (stream: ReadableStream<Uint8Array>) => PromiseLike<ReactFormState>;
26
+ /**
27
+ * Decode the form state's Flight bytes. Rejects if they do not decode within
28
+ * `timeoutMs`. Every byte is in hand, so only a Flight client bug could
29
+ * stall it, and the caller falls back to rendering without form state.
30
+ */
31
+ export declare function decodeFormState(bytes: Uint8Array, decode: FormStateFlightDecoder, timeoutMs: number): Promise<ReactFormState>;
32
+ /** The form state's Flight bytes as the base64 the page embeds. */
33
+ export declare function formStateToBase64(bytes: Uint8Array): string;
34
+ /** The embedded base64 back to the Flight bytes. */
35
+ export declare function formStateFromBase64(text: string): Uint8Array;
36
+ //# sourceMappingURL=form-state-flight.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"form-state-flight.d.ts","sourceRoot":"","sources":["../../src/shared/form-state-flight.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD;;;;;GAKG;AACH,MAAM,MAAM,sBAAsB,GAAG,CACnC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,KAC/B,WAAW,CAAC,cAAc,CAAC,CAAC;AAEjC;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,UAAU,EACjB,MAAM,EAAE,sBAAsB,EAC9B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,cAAc,CAAC,CAmBzB;AAED,mEAAmE;AACnE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAI3D;AAED,oDAAoD;AACpD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAK5D"}
@@ -143,6 +143,28 @@ errors.serverError; // { code, data? } | null
143
143
  errors.getFieldError('title'); // string | null
144
144
  ```
145
145
 
146
+ ### `useFormField(defaultValue)`
147
+
148
+ State for a form field that needs JavaScript, such as a combobox or a chip list, that follows the form's reset like a native input with a `defaultValue`. React resets a form after every action, so use this instead of `useState` for any field inside a form. See [Keeping What the User Typed](/docs/forms-and-actions#keeping-what-the-user-typed).
149
+
150
+ ```tsx
151
+ import { useFormField } from '@timber-js/app/client';
152
+
153
+ // draft = toDraft(result?.submittedValues ?? post): the form's defaults
154
+ const [tags, setTags, ref] = useFormField(draft.tags);
155
+
156
+ <div ref={ref}>
157
+ <input type="hidden" name="tags" value={tags.join(',')} />
158
+ <TagPicker value={tags} onChange={setTags} />
159
+ </div>
160
+ ```
161
+
162
+ - `value`: `defaultValue` until `setValue` is called, then the edit. An unedited field follows a new `defaultValue`.
163
+ - `setValue`: like a `useState` setter; accepts a value or an updater.
164
+ - `ref`: attach to any element inside the `<form>`. When the form resets, the edit is dropped and `value` is `defaultValue` again, unless the form cancels the `reset` event.
165
+
166
+ An edit dispatches a bubbling `change` event from the `ref` element, so a native `change` listener on the form sees it. React's `onChange` prop does not: it only reports inputs, selects and textareas. Render the value into the form data yourself, usually with a hidden input.
167
+
146
168
  ### `useFormAction(action)`
147
169
 
148
170
  Bind a server action to a form with pending state.
@@ -153,7 +153,7 @@ limits: {
153
153
  - **Type:** `boolean | string[]`
154
154
  - **Default:** `true`
155
155
 
156
- Strip sensitive fields (passwords, tokens, CVV, SSN, etc.) from the `submittedValues` echoed back on validation failure. `true` uses the built-in deny-list; an array adds extra field names to the built-in list; `false` disables stripping (never do this in production). See [Forms & Server Actions](/docs/forms-and-actions) for the full pattern list.
156
+ Strip sensitive fields (passwords, tokens, CVV, SSN, etc.) from the `submittedValues` echoed back when an action fails. `true` uses the built-in deny-list; an array adds extra field names to the built-in list; `false` disables stripping (never do this in production). See [Forms & Server Actions](/docs/forms-and-actions) for the full pattern list.
157
157
 
158
158
  ### `pageExtensions`
159
159
 
@@ -95,45 +95,132 @@ errors.hasErrors; // boolean
95
95
  errors.getFieldError('title'); // string | null (first error for field)
96
96
  ```
97
97
 
98
- ## After Mutation
98
+ ## Keeping What the User Typed
99
99
 
100
- | Pattern | When to use |
101
- | ------------------------- | ------------------------------------------------ |
102
- | `redirect('/path')` | User should land somewhere new |
103
- | `revalidatePath('/path')` | Current page needs fresh data |
104
- | `useOptimistic` | Instant UI update, action confirms in background |
100
+ React resets a form after every action: each field goes back to its `defaultValue`. Build forms on that reset, and they keep the user's input after a failure and show the saved values after a save, with or without JavaScript:
105
101
 
106
- ## Forms Without JavaScript
102
+ 1. Leave fields **uncontrolled**: a `name` and a `defaultValue`, not `value` + `onChange`.
103
+ 2. Take defaults from the result, then from the page: `result?.submittedValues ?? pageData`.
104
+ 3. On success, `redirect()` (or `revalidatePath()`), so the page renders the saved data.
107
105
 
108
- The same form works with JavaScript disabled, with no extra code. When a no-JS submission's action returns instead of redirecting, timber renders the page again and React hands the result to the `useActionState` hook that submitted it. `state` holds the result on both paths, so errors and repopulated fields render the same way:
106
+ ```ts title="app/events/[id]/actions.ts"
107
+ 'use server';
109
108
 
110
- ```tsx title="app/todos/todo-form.tsx"
109
+ import { z } from 'zod/v4';
110
+ import { coerce, redirect } from '@timber-js/app/server';
111
+ import { action } from '@/lib/action';
112
+
113
+ declare const db: { events: { update(data: unknown): Promise<void> } };
114
+
115
+ export const saveEvent = action
116
+ .schema(
117
+ z.object({
118
+ id: z.string(),
119
+ title: z.string().min(1, 'Title is required'),
120
+ category: z.string(),
121
+ ticketed: z.preprocess(coerce.checkbox, z.boolean()),
122
+ // sets.0.name, sets.1.name… arrive as an array
123
+ sets: z.array(z.object({ name: z.string().min(1) })).default([]), // no rows: no key
124
+ })
125
+ )
126
+ .action(async ({ input }) => {
127
+ await db.events.update(input);
128
+ redirect(`/events/${input.id}`);
129
+ });
130
+ ```
131
+
132
+ `submittedValues` is the raw form: strings, lists as arrays, an unchecked box absent. Page data is typed. One small, lenient function reads either into what the fields render:
133
+
134
+ ```ts title="app/events/[id]/draft.ts"
135
+ export function toDraft(values: Record<string, unknown>) {
136
+ const sets: { name?: unknown }[] = Array.isArray(values.sets) ? values.sets : [];
137
+ return {
138
+ title: String(values.title ?? ''),
139
+ category: String(values.category ?? ''),
140
+ // Submitted: "on", or absent when unchecked. Saved: a boolean.
141
+ ticketed: values.ticketed === true || values.ticketed === 'on',
142
+ sets: sets.map((set) => ({ name: String(set?.name ?? '') })),
143
+ };
144
+ }
145
+ ```
146
+
147
+ ```tsx title="app/events/[id]/event-form.tsx"
111
148
  'use client';
112
149
 
113
150
  import { useActionState } from 'react';
114
151
  import { parseFormErrors } from '@timber-js/app/client';
115
- import { createTodo } from './actions';
152
+ import { saveEvent } from './actions';
153
+ import { toDraft } from './draft';
116
154
 
117
- export function TodoForm() {
118
- const [state, action, pending] = useActionState(createTodo, null);
119
- const errors = parseFormErrors(state);
155
+ type Event = { id: string; title: string; category: string; ticketed: boolean; sets: { name: string }[] };
156
+
157
+ export function EventForm({ event }: { event: Event }) {
158
+ const [result, action, pending] = useActionState(saveEvent, null);
159
+ const errors = parseFormErrors(result);
160
+ // After a failure: what the user submitted. Otherwise: the saved event.
161
+ const d = toDraft(result?.submittedValues ?? event);
120
162
 
121
163
  return (
122
164
  <form action={action}>
123
- <input name="title" defaultValue={(state?.submittedValues?.title as string) ?? ''} />
124
- {errors.getFieldError('title') && (
125
- <p className="text-red-600">{errors.getFieldError('title')}</p>
126
- )}
127
- <button type="submit" disabled={pending}>
128
- {pending ? 'Adding...' : 'Add Todo'}
129
- </button>
165
+ <input type="hidden" name="id" value={event.id} />
166
+ <input name="title" defaultValue={d.title} />
167
+ {errors.getFieldError('title')}
168
+
169
+ {/* React applies a select's defaultValue only on mount: key it on the default */}
170
+ <select key={d.category} name="category" defaultValue={d.category}>
171
+ <option value="meetup">Meetup</option>
172
+ <option value="social">Social</option>
173
+ </select>
174
+
175
+ <input type="checkbox" name="ticketed" defaultChecked={d.ticketed} />
176
+
177
+ {d.sets.map((set, i) => (
178
+ <input key={i} name={`sets.${i}.name`} defaultValue={set.name} />
179
+ ))}
180
+
181
+ {errors.serverError && <p>Could not save. Please try again.</p>}
182
+ <button disabled={pending}>Save</button>
130
183
  </form>
131
184
  );
132
185
  }
133
186
  ```
134
187
 
135
- The result goes only to the hook that submitted, so two forms on one page never see each other's results. Sensitive fields (password, apiKey, cvv, etc.) are automatically stripped from `submittedValues`.
188
+ When a form submission fails — a validation error, an `ActionError`, or an unexpected error — the result carries `submittedValues`, so the user's input survives any of them.
189
+
190
+ Three things break the model:
191
+
192
+ - **Controlled fields.** A field whose value lives in state keeps showing that state after the reset has put the DOM back to its default, and the next submit sends what the DOM holds, not what the user sees. If a form must be controlled, cancel the reset: `<form action={action} onReset={(e) => e.preventDefault()}>`.
193
+ - **An unkeyed `<select>`.** React ignores a changed `defaultValue` on a mounted `<select>`. Key it on its default, as above. Inputs, checkboxes and textareas don't need this.
194
+ - **`useState` for a field that needs JavaScript.** A combobox or a chip list can't be a native input. Hold its state in `useFormField` instead, which follows the reset like a native input:
195
+
196
+ ```tsx
197
+ import { useFormField } from '@timber-js/app/client';
198
+
199
+ function TagsField({ defaultValue }: { defaultValue: string[] }) {
200
+ const [tags, setTags, ref] = useFormField(defaultValue);
201
+ return (
202
+ <div ref={ref}>
203
+ <input type="hidden" name="tags" value={tags.join(',')} />
204
+ <TagPicker value={tags} onChange={setTags} />
205
+ </div>
206
+ );
207
+ }
208
+ ```
209
+
210
+ ## After Mutation
211
+
212
+ | Pattern | When to use |
213
+ | ------------------------- | ------------------------------------------------ |
214
+ | `redirect('/path')` | User should land somewhere new |
215
+ | `revalidatePath('/path')` | Current page needs fresh data |
216
+ | `useOptimistic` | Instant UI update, action confirms in background |
217
+
218
+ ## Forms Without JavaScript
219
+
220
+ The same form works with JavaScript disabled, with no extra code. When a no-JS submission's action returns instead of redirecting, timber renders the page again and React hands the result to the `useActionState` hook that submitted it. `result` holds the result on both paths, so errors and the fields' defaults render the same way, and the server writes the user's input back into the HTML.
221
+
222
+ The result goes only to the hook that submitted, so two forms on one page never see each other's results. It has the same types on both paths: a `Date` your action returns is a `Date` in `state` whether or not JavaScript ran. Sensitive fields (password, apiKey, cvv, etc.) are automatically stripped from `submittedValues`.
136
223
 
137
224
  A form that doesn't use `useActionState` gets no result back, just as with JavaScript, where React discards a plain `<form action>`'s return value. Such an action should `redirect()` to a page that shows the new state (post-redirect-get). An action that throws renders the nearest `error.tsx`: with JavaScript the action rejects to the nearest error boundary, and without it the page answers from the same `error.tsx` with a 500; return errors from `createActionClient` actions instead.
138
225
 
139
- For advanced topics — coercion helpers, `parseFormData`, sensitive field stripping configuration, and the full end-to-end example — see [Advanced Forms](/docs/advanced-forms).
226
+ For advanced topics — coercion helpers, `parseFormData`, indexed lists, and sensitive field stripping configuration — see [Advanced Forms](/docs/advanced-forms).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.212",
3
+ "version": "0.2.0-alpha.213",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -23,7 +23,7 @@ import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../r
23
23
  import { markClientStale } from '../stale-client.ts';
24
24
  import { RSC_CONTENT_TYPE } from '../../shared/rsc-media-type.ts';
25
25
  import { CSRF_REJECT_HEADER, CSRF_REJECT_VALUE } from '../../shared/csrf-reject.ts';
26
- import { createActionQueue } from './action-queue.ts';
26
+ import { createActionQueue, DEFAULT_HOLD_TIMEOUT_MS } from './action-queue.ts';
27
27
 
28
28
  export function setupServerActions(): void {
29
29
  const queue = createActionQueue({
@@ -46,10 +46,10 @@ export function setupServerActions(): void {
46
46
  },
47
47
  });
48
48
 
49
- // An action that redirects settles — ending `useActionState`'s pending
50
- // state — a full round trip before the destination is on screen, and the
51
- // page that submitted stays mounted until then with its button enabled
52
- // again. A second submit would run the mutation again (TIM-1568). So the
49
+ // The page that submitted a redirecting action stays mounted until the
50
+ // destination commits. Its own useActionState stays pending until the
51
+ // hand-off (below), but other forms and effects on it can still dispatch,
52
+ // and a second submit would run the mutation again (TIM-1568). So the
53
53
  // departing page may not dispatch: `departing` is set from the redirect
54
54
  // response until the router is next idle, and a call made
55
55
  // meanwhile is never sent. It resolves at once with `undefined` — what the
@@ -163,10 +163,26 @@ export function setupServerActions(): void {
163
163
  }
164
164
 
165
165
  // Redirects apply unconditionally — the server mutated and told
166
- // the client where to go. Return undefined to React NOW so the
167
- // action scope settles — awaiting the commit inline would deadlock:
168
- // React entangles the commit lane with the action scope, so the
169
- // commit waits on the action while the action waits on the commit.
166
+ // the client where to go. The action settles (returning undefined)
167
+ // once the destination's tree has been handed to React, not before
168
+ // and not after:
169
+ //
170
+ // - Not after its commit: React entangles the commit with the action
171
+ // scope, so the commit would wait on the action while the action
172
+ // waited on the commit.
173
+ // - Not before the hand-off: React would commit the action alone —
174
+ // useActionState's result, and React's reset of the submitting form
175
+ // onto the defaults the *departing* page renders — and the form
176
+ // would show the pre-save values until the destination committed
177
+ // (TIM-1573).
178
+ //
179
+ // At the hand-off the destination's render is already entangled with
180
+ // the action, so settling commits both together: the form resets onto
181
+ // the destination's defaults. The wait always ends: the navigation
182
+ // hands off, or fails, or a navigation the user starts supersedes it
183
+ // (its promise settles either way), or DEFAULT_HOLD_TIMEOUT_MS
184
+ // passes. Every transition waits while an action is pending, so this
185
+ // must not hang on a stalled fetch.
170
186
  //
171
187
  // Set before returning, so React never sees the action settle while
172
188
  // the departing page can still dispatch (see `departing`).
@@ -190,7 +206,9 @@ export function setupServerActions(): void {
190
206
  // still suspended — can commit afterwards, and must not release
191
207
  // the latch: it is the departing page again (codex on #1211).
192
208
  const redirectSeq = r.epoch().seq + 1;
193
- void r.navigate(wrapper._redirect);
209
+ let handOff!: () => void;
210
+ const handedOff = new Promise<void>((resolve) => (handOff = resolve));
211
+ r.navigate(wrapper._redirect, { onHandOff: handOff }).then(handOff, handOff);
194
212
  // Released once the departing page can no longer be on screen:
195
213
  // when any navigation's tree commits (a streamed destination
196
214
  // commits its shell before its stream finishes, and is live from
@@ -213,6 +231,12 @@ export function setupServerActions(): void {
213
231
  // A navigation that failed before taking the router never makes
214
232
  // it busy, so no idle transition would come.
215
233
  if (r.epoch().idle) arrive();
234
+ let timer: ReturnType<typeof setTimeout> | undefined;
235
+ await Promise.race([
236
+ handedOff,
237
+ new Promise<void>((resolve) => (timer = setTimeout(resolve, DEFAULT_HOLD_TIMEOUT_MS))),
238
+ ]);
239
+ clearTimeout(timer);
216
240
  } catch (e) {
217
241
  console.debug(
218
242
  '[timber] action redirect: router not ready, using full navigation',
@@ -31,7 +31,7 @@ export interface ActionQueueDeps {
31
31
  holdTimeoutMs?: number;
32
32
  }
33
33
 
34
- const DEFAULT_HOLD_TIMEOUT_MS = 30_000;
34
+ export const DEFAULT_HOLD_TIMEOUT_MS = 30_000;
35
35
 
36
36
  const noop = (): void => {};
37
37
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The form state a page answering a no-JS action embeds for hydration
3
+ * (`self.__timber_form_state`, server/form-state-flight.ts).
4
+ *
5
+ * `hydrateRoot` needs the value Fizz rendered with, or the `useActionState`
6
+ * hook that submitted hydrates back to its initial state. The embed is the
7
+ * form state's Flight bytes, the same bytes SSR decoded for Fizz, so the
8
+ * browser decodes them with its own Flight client before it hydrates. Only
9
+ * this page waits for that; a page without the embed hydrates at once
10
+ * (TIM-1297: hydration does not wait for the payload root).
11
+ *
12
+ * See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
13
+ */
14
+
15
+ import type { ReactFormState } from 'react-dom/client';
16
+ import { createFromReadableStream } from '../../rsc-runtime/browser.ts';
17
+ import { decodeFormState, formStateFromBase64 } from '../../shared/form-state-flight.ts';
18
+
19
+ /**
20
+ * Every byte is already in the document, so decoding takes microtasks. The
21
+ * bound only keeps a Flight client bug from holding hydration forever.
22
+ */
23
+ const DECODE_TIMEOUT_MS = 5_000;
24
+
25
+ /**
26
+ * Take the embedded form state, and remove it. `null` when the page has
27
+ * none, which is every page but one answering a no-JS action. Otherwise a
28
+ * promise of the decoded state, or of `null` if it could not be decoded: the
29
+ * page then hydrates as though it had none, which resets that one hook.
30
+ */
31
+ export function takeEmbeddedFormState(): Promise<ReactFormState | null> | null {
32
+ const embedded: unknown = Reflect.get(self, '__timber_form_state');
33
+ Reflect.deleteProperty(self, '__timber_form_state');
34
+ if (typeof embedded !== 'string') return null;
35
+ // Inside the chain, so malformed base64 falls back like a failed decode.
36
+ return Promise.resolve(embedded)
37
+ .then((text) =>
38
+ decodeFormState(
39
+ formStateFromBase64(text),
40
+ (stream) => createFromReadableStream(stream),
41
+ DECODE_TIMEOUT_MS
42
+ )
43
+ )
44
+ .catch((error: unknown) => {
45
+ console.warn('[timber] the form state of a no-JS action did not decode:', error);
46
+ return null;
47
+ });
48
+ }
@@ -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
@@ -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