@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
@@ -51,11 +51,13 @@ The `.schema()` method accepts any Standard Schema-compatible validator — Zod,
51
51
  ```tsx title="app/todos/todo-form.tsx"
52
52
  'use client';
53
53
 
54
- import { useActionState } from '@timber-js/app/client';
54
+ import { useActionState } from 'react';
55
+ import { parseFormErrors } from '@timber-js/app/client';
55
56
  import { createTodo } from './actions';
56
57
 
57
58
  export default function TodoForm() {
58
- const [state, action, pending, errors] = useActionState(createTodo, null);
59
+ const [state, action, pending] = useActionState(createTodo, null);
60
+ const errors = parseFormErrors(state);
59
61
 
60
62
  return (
61
63
  <form action={action}>
@@ -76,14 +78,15 @@ export default function TodoForm() {
76
78
  }
77
79
  ```
78
80
 
79
- Without JS, this submits as a standard form POST. With JS, `useActionState` handles pending state and error display.
81
+ `useActionState` is React's own hook. An action built with `createActionClient` passes to it directly, and `state` is typed from the action's result. Without JS, this submits as a standard form POST. With JS, `useActionState` handles pending state and error display.
80
82
 
81
83
  ### Form Errors
82
84
 
83
- `useActionState` from `@timber-js/app/client` returns a 4-tuple where the 4th element is auto-derived form errors:
85
+ `parseFormErrors` from `@timber-js/app/client` reads the errors out of the result:
84
86
 
85
87
  ```tsx
86
- const [state, action, pending, errors] = useActionState(myAction, null);
88
+ const [state, action, pending] = useActionState(myAction, null);
89
+ const errors = parseFormErrors(state);
87
90
 
88
91
  errors.fieldErrors; // Record<string, string[]>
89
92
  errors.formErrors; // string[] (form-level errors)
@@ -92,55 +95,132 @@ errors.hasErrors; // boolean
92
95
  errors.getFieldError('title'); // string | null (first error for field)
93
96
  ```
94
97
 
95
- ## After Mutation
98
+ ## Keeping What the User Typed
96
99
 
97
- | Pattern | When to use |
98
- | ------------------------- | ------------------------------------------------ |
99
- | `redirect('/path')` | User should land somewhere new |
100
- | `revalidatePath('/path')` | Current page needs fresh data |
101
- | `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:
102
101
 
103
- ## 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.
105
+
106
+ ```ts title="app/events/[id]/actions.ts"
107
+ 'use server';
104
108
 
105
- When JavaScript is disabled and a server action returns validation errors, timber re-renders the page instead of redirecting. The errors and submitted values are available via `getFormFlash()`:
109
+ import { z } from 'zod/v4';
110
+ import { coerce, redirect } from '@timber-js/app/server';
111
+ import { action } from '@/lib/action';
106
112
 
107
- ```tsx title="app/todos/page.tsx"
108
- import { getFormFlash } from '@timber-js/app/server';
109
- import { TodoForm } from './todo-form';
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
+ ```
110
131
 
111
- export default async function TodosPage() {
112
- const flash = getFormFlash();
113
- return <TodoForm flash={flash} />;
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
+ };
114
144
  }
115
145
  ```
116
146
 
117
- ```tsx title="app/todos/todo-form.tsx"
147
+ ```tsx title="app/events/[id]/event-form.tsx"
118
148
  'use client';
119
149
 
120
- import { useActionState } from '@timber-js/app/client';
121
- import { createTodo } from './actions';
122
- import type { FormFlashData } from '@timber-js/app/server';
150
+ import { useActionState } from 'react';
151
+ import { parseFormErrors } from '@timber-js/app/client';
152
+ import { saveEvent } from './actions';
153
+ import { toDraft } from './draft';
123
154
 
124
- export function TodoForm({ flash }: { flash?: FormFlashData | null }) {
125
- const [state, action, pending, errors] = useActionState(createTodo, null);
155
+ type Event = { id: string; title: string; category: string; ticketed: boolean; sets: { name: string }[] };
126
156
 
127
- const fieldError = (field: string) =>
128
- errors.getFieldError(field) ?? flash?.validationErrors?.[field]?.[0];
129
- const defaultValue = (field: string) =>
130
- (flash?.submittedValues?.[field] as string) ?? '';
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);
131
162
 
132
163
  return (
133
164
  <form action={action}>
134
- <input name="title" defaultValue={defaultValue('title')} />
135
- {fieldError('title') && <p className="text-red-600">{fieldError('title')}</p>}
136
- <button type="submit" disabled={pending}>
137
- {pending ? 'Adding...' : 'Add Todo'}
138
- </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>
139
183
  </form>
140
184
  );
141
185
  }
142
186
  ```
143
187
 
144
- Flash data is server-side only — stored in AsyncLocalStorage for the duration of the re-render. 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`.
223
+
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.
145
225
 
146
- 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.211",
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,21 +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';
27
-
28
- /**
29
- * Wait for a navigation or refresh to both settle (promise resolved) AND
30
- * commit (pushState / the navigation's commit thunk ran). The queue must
31
- * not release the next action until the URL is correct in the address bar
32
- * — same rule `<Link>` uses for `isPending` (design/19 §Per-Link Pending
33
- * State). `onCommit` fires exactly once: on commit, supersession, or
34
- * failure, so nothing awaiting this can hang (codex on #1125).
35
- */
36
- function settledAndCommitted(start: (onCommit: () => void) => Promise<void>): Promise<void> {
37
- let commitResolve!: () => void;
38
- const committed = new Promise<void>((r) => (commitResolve = r));
39
- return Promise.all([start(commitResolve).catch(() => {}), committed]).then(() => {});
40
- }
26
+ import { createActionQueue, DEFAULT_HOLD_TIMEOUT_MS } from './action-queue.ts';
41
27
 
42
28
  export function setupServerActions(): void {
43
29
  const queue = createActionQueue({
@@ -60,8 +46,30 @@ export function setupServerActions(): void {
60
46
  },
61
47
  });
62
48
 
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
+ // departing page may not dispatch: `departing` is set from the redirect
54
+ // response until the router is next idle, and a call made
55
+ // meanwhile is never sent. It resolves at once with `undefined` — what the
56
+ // redirecting action itself returned. It must settle: React entangles every
57
+ // transition started while an action is pending, so one left pending would
58
+ // hold the redirect's own commit forever. `pageGen` names
59
+ // the page a call was made on, so one queued behind the redirecting
60
+ // action is dropped too. The check is here rather than in the DOM
61
+ // (`inert`) because it covers every way a call is made: a click inside a
62
+ // modal dialog or a shadow root, `requestSubmit()` from a keyboard
63
+ // shortcut, an effect.
64
+ let departing = false;
65
+ let pageGen = 0;
66
+
63
67
  setServerCallback((id: string, args: unknown[]) => {
68
+ if (departing) return Promise.resolve(undefined);
69
+ const calledOn = pageGen;
64
70
  return queue.run(async ({ hold }) => {
71
+ if (calledOn !== pageGen) return undefined;
72
+
65
73
  // encodeReply inside the queue — preserves call order when two
66
74
  // calls' args serialize in different numbers of microtasks.
67
75
  const body = await encodeReply(args);
@@ -75,6 +83,9 @@ export function setupServerActions(): void {
75
83
  let hasRevalidation = false;
76
84
  let hasRedirect = false;
77
85
  let invalidated = false;
86
+ // Set once the server answered the action itself — past the version
87
+ // skew and CSRF rejections, which run nothing.
88
+ let answered = false;
78
89
 
79
90
  const actionHeaders: Record<string, string> = {
80
91
  'Accept': RSC_CONTENT_TYPE,
@@ -104,10 +115,26 @@ export function setupServerActions(): void {
104
115
  hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
105
116
  hasRedirect = res.headers.get('X-Timber-Redirect') != null;
106
117
  invalidated = res.headers.get('X-Timber-Invalidated') === '1';
118
+ answered = true;
107
119
  return res;
108
120
  });
109
121
 
110
- const decoded = await createFromFetch(response);
122
+ let decoded: unknown;
123
+ try {
124
+ decoded = await createFromFetch(response);
125
+ } catch (error) {
126
+ // A raw server function that throws arrives as a rejected root
127
+ // (TIM-1570). It may have mutated before it threw, so no payload
128
+ // cached before it may be reused, nor the mounted shared layouts on
129
+ // the next navigation. No refresh: the rejection renders the nearest
130
+ // error boundary, and a refresh would hand that boundary new
131
+ // children, which resets it and hides the error (error-boundary.tsx).
132
+ if (answered && router) {
133
+ router.suppressSegmentReuse();
134
+ router.evictStaleCaches();
135
+ }
136
+ throw error;
137
+ }
111
138
 
112
139
  // The mutation happened server-side, and the next thing on screen is
113
140
  // not a full render of the current page: revalidatePath named only
@@ -136,14 +163,32 @@ export function setupServerActions(): void {
136
163
  }
137
164
 
138
165
  // Redirects apply unconditionally — the server mutated and told
139
- // the client where to go. Return undefined to React NOW so the
140
- // action scope settles — awaiting the commit inline would deadlock:
141
- // React entangles the commit lane with the action scope, so the
142
- // commit waits on the action while the action waits on the commit.
143
- // The navigate is fire-and-forget: unlike revalidation (where the
144
- // next action must capture the refreshed epoch), a redirect leaves
145
- // the page entirely — no subsequent action runs on this URL.
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.
186
+ //
187
+ // Set before returning, so React never sees the action settle while
188
+ // the departing page can still dispatch (see `departing`).
146
189
  if (hasRedirect) {
190
+ departing = true;
191
+ pageGen += 1;
147
192
  const wrapper = decoded as { _redirect: string; _status: number };
148
193
  const isExternal =
149
194
  wrapper._redirect.startsWith('https://') || wrapper._redirect.startsWith('http://');
@@ -156,7 +201,42 @@ export function setupServerActions(): void {
156
201
  }
157
202
  try {
158
203
  const r = getRouter();
159
- void r.navigate(wrapper._redirect);
204
+ // The redirect's own navigation takes the next sequence number.
205
+ // A tree handed to React before it — a refresh of this very page
206
+ // still suspended — can commit afterwards, and must not release
207
+ // the latch: it is the departing page again (codex on #1211).
208
+ const redirectSeq = r.epoch().seq + 1;
209
+ let handOff!: () => void;
210
+ const handedOff = new Promise<void>((resolve) => (handOff = resolve));
211
+ r.navigate(wrapper._redirect, { onHandOff: handOff }).then(handOff, handOff);
212
+ // Released once the departing page can no longer be on screen:
213
+ // when any navigation's tree commits (a streamed destination
214
+ // commits its shell before its stream finishes, and is live from
215
+ // then), or when the router is idle with nothing committed (every
216
+ // navigation was abandoned; the user stays and may act again).
217
+ // Not on the redirect navigation's own outcome: a navigation the
218
+ // user starts meanwhile supersedes it at once, but the departing
219
+ // page stays until that successor commits (codex on #1211).
220
+ const stops: (() => void)[] = [];
221
+ const arrive = (): void => {
222
+ departing = false;
223
+ for (const stop of stops) stop();
224
+ };
225
+ stops.push(
226
+ r.onTreeCommit((seq) => {
227
+ if (seq >= redirectSeq) arrive();
228
+ }),
229
+ r.onIdle(arrive)
230
+ );
231
+ // A navigation that failed before taking the router never makes
232
+ // it busy, so no idle transition would come.
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);
160
240
  } catch (e) {
161
241
  console.debug(
162
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
+ }
@@ -22,6 +22,7 @@
22
22
  * See design/19-client-navigation.md §"NavigationContext"
23
23
  */
24
24
 
25
+ import type { ReactFormState } from 'react-dom/client';
25
26
  import { isPageUnloading } from '../unload-guard.ts';
26
27
  import type { ReactRootHost } from '../react-root.ts';
27
28
  import { readPublishedParams } from '../../shared/payload-root.ts';
@@ -75,6 +76,13 @@ interface HydrateOptions {
75
76
  reactRoot: ReactRootHost;
76
77
  /** Makes the page current and builds its tree — see `RouterInitResult.hydrate`. */
77
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;
78
86
  }
79
87
 
80
88
  /**
@@ -105,7 +113,7 @@ function takeEmbeddedSegmentInfo(): SegmentInfo[] | null {
105
113
  * first navigation or revalidation, creates it with the tree it renders
106
114
  * (`createReactRoot`, TIM-600 / TIM-580).
107
115
  */
108
- export function hydrateApp({ rscResult, reactRoot, hydrate }: HydrateOptions): void {
116
+ export function hydrateApp({ rscResult, reactRoot, hydrate, formState }: HydrateOptions): void {
109
117
  // The chain is the router's own `renderTree`, not a copy: an element type
110
118
  // that is present here and absent on the first navigation (or the
111
119
  // reverse) changes the type at that position, and React remounts
@@ -126,6 +134,7 @@ export function hydrateApp({ rscResult, reactRoot, hydrate }: HydrateOptions): v
126
134
  // construction when the tree it builds renders.
127
135
  hydrate(page, (element) =>
128
136
  reactRoot.hydrate(element, {
137
+ formState,
129
138
  // Suppress recoverable hydration errors from deny/error signals
130
139
  // inside Suspense boundaries. The server already handled these
131
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
+ }
@@ -159,9 +159,8 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
159
159
  // itself calls pushState/replaceState, it sets a flag so the patch
160
160
  // skips the sync — the router already updates NavigationContext.
161
161
  //
162
- // On Navigation API browsers, the navigate event catches most URL
163
- // changes. The patch is defense-in-depth and the primary mechanism
164
- // for History API-only browsers (older Safari/Firefox).
162
+ // This is the mechanism with or without the Navigation API: its navigate
163
+ // listener leaves same-document pushState/replaceState alone.
165
164
  const ROUTER_HISTORY_FLAG = Symbol.for('__timber_router_history_update');
166
165
  const gFlags = globalThis as Record<symbol, boolean>;
167
166
 
@@ -258,7 +257,6 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
258
257
  window.history.replaceState(data, unused, url);
259
258
  gFlags[ROUTER_HISTORY_FLAG] = false;
260
259
  },
261
- navigationApiActive: useNavApi,
262
260
  scrollTo: (x, y) => {
263
261
  // Scroll the document viewport.
264
262
  window.scrollTo(x, y);
@@ -299,7 +297,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
299
297
  //
300
298
  // `_url` names the pending URL the router already published to its own
301
299
  // pending store before calling here; the transition has no use for it.
302
- navigateTransition: (_url: string, owner: RenderOwner, types, perform, onCommit) => {
300
+ navigateTransition: (_url: string, owner: RenderOwner, types, perform, onCommit, onHandOff) => {
303
301
  return navigateTransition(
304
302
  owner,
305
303
  async () => {
@@ -336,7 +334,8 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
336
334
  },
337
335
  render,
338
336
  types,
339
- onCommit
337
+ onCommit,
338
+ onHandOff
340
339
  );
341
340
  },
342
341
 
@@ -361,15 +360,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
361
360
  let navApiController: NavigationApiController | null = null;
362
361
  if (useNavApi) {
363
362
  navApiController = setupNavigationApi({
364
- onExternalNavigate: async (url, { replace, signal, scroll, departingUrl }) => {
365
- await router.navigate(url, {
366
- replace,
367
- scroll,
368
- _signal: signal,
369
- _skipHistory: true,
370
- _departingUrl: departingUrl,
371
- });
372
- },
363
+ onExternalNavigate: (url, { replace }) => router.navigate(url, { replace }),
373
364
  onTraverse: async (url, scrollY, signal, direction) => {
374
365
  // Back/forward — delegate to the router's popstate handler.
375
366
  await router.handlePopState(url, scrollY, signal, direction);
@@ -380,12 +371,8 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
380
371
  },
381
372
  });
382
373
 
383
- // Wire the router-navigating flag into RouterDeps.
384
- // This must be done after setupNavigationApi returns the controller.
385
- deps.setRouterNavigating = (v) => navApiController!.setRouterNavigating(v);
374
+ // Wired after setupNavigationApi returns the controller.
386
375
  deps.saveNavigationEntryScroll = (y) => navApiController!.saveScrollPosition(y);
387
- deps.completeRouterNavigation = () => navApiController!.completeRouterNavigation();
388
- deps.navigationNavigate = (url, replace) => navApiController!.navigate(url, replace);
389
376
  }
390
377
 
391
378
  /**