@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
@@ -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
  /**
@@ -102,7 +102,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
102
102
  currentOwner,
103
103
  leaveSpaIfOwned,
104
104
  // Hoisted — `navigate` is a function declaration below.
105
- navigate: (url, types) => navigate(url, { replace: true, _renderTypes: types }),
105
+ navigate: (url, types, onHandOff) =>
106
+ navigate(url, { replace: true, _renderTypes: types, onHandOff }),
106
107
  });
107
108
 
108
109
  async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
@@ -164,7 +165,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
164
165
  signal: owner.fetchAbort.signal,
165
166
  departingUrl,
166
167
  }),
167
- options.onCommit
168
+ options.onCommit,
169
+ options.onHandOff
168
170
  );
169
171
 
170
172
  // Scroll-to-top on forward navigation, scroll to the #fragment target
@@ -180,7 +182,18 @@ export function createRouter(deps: RouterDeps): RouterInstance {
180
182
  // load of the destination, fragment included (TIM-1234). Reloading
181
183
  // instead would rebuild the page they were *leaving* and discard
182
184
  // the click (TIM-1275).
183
- if (await recoverFromNavigationError(error, owner, url, departingUrl, types)) return;
185
+ if (
186
+ await recoverFromNavigationError(
187
+ error,
188
+ owner,
189
+ url,
190
+ departingUrl,
191
+ types,
192
+ options.onHandOff
193
+ )
194
+ ) {
195
+ return;
196
+ }
184
197
  throw error;
185
198
  }
186
199
  });
@@ -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
@@ -118,7 +118,11 @@ export type ActionResult<TData = unknown> =
118
118
  data?: never;
119
119
  validationErrors?: never;
120
120
  serverError: { code: string; data?: Record<string, unknown> };
121
- submittedValues?: never;
121
+ /**
122
+ * The submitted form, when the input was FormData — for repopulating
123
+ * form fields, which React resets after the action (TIM-1573).
124
+ */
125
+ submittedValues?: Record<string, unknown>;
122
126
  };
123
127
 
124
128
  /** Context passed to the action body. */
@@ -288,7 +292,9 @@ function extractStandardSchemaErrors(issues: ReadonlyArray<StandardSchemaIssue>)
288
292
  * `createActionClient` actions only. A raw `'use server'` function that
289
293
  * throws is rejected on the client instead (action-handler.ts).
290
294
  */
291
- export function handleActionError(error: unknown): ActionResult<never> {
295
+ export function handleActionError(error: unknown): {
296
+ serverError: { code: string; data?: Record<string, unknown> };
297
+ } {
292
298
  if (error instanceof ActionError) {
293
299
  return {
294
300
  serverError: {
@@ -339,43 +345,49 @@ export function createActionClient<TCtx = Record<string, never>>(
339
345
  fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>
340
346
  ): ActionFn<TData, TInput> {
341
347
  async function actionHandler(...args: unknown[]): Promise<ActionResult<TData>> {
348
+ // The input is the last argument in every call shape: `(input)`,
349
+ // `(formData)`, `(prevState, payload)` from useActionState's
350
+ // dispatch, `(initialState, formData)` on the no-JS path (Fizz binds
351
+ // the initial state into the form, and decodeAction binds the
352
+ // FormData after it), and `(...bound, prevState, payload)` for an
353
+ // action with bound arguments. Nothing before it is input:
354
+ // `prevState` comes from the client, so it is never read.
355
+ const last = args.at(-1);
356
+ const form = last instanceof FormData ? parseFormData(last) : undefined;
357
+
358
+ // Resolve the sensitive-field stripping predicate once per invocation.
359
+ // Precedence: per-action (config.stripSensitiveFields) > global
360
+ // (forms.stripSensitiveFields from timber.config.ts) > built-in deny-list.
361
+ // See TIM-816.
362
+ const sensitivePredicate = resolveSensitivePredicate(
363
+ config.stripSensitiveFields,
364
+ getGlobalSensitiveFieldsConfig()
365
+ );
366
+
367
+ // A "safe-to-echo" copy of the input. Files are stripped (can't
368
+ // serialize, shouldn't echo back) and sensitive fields (passwords,
369
+ // tokens, CVV, etc.) are removed before they would land in the RSC
370
+ // payload → client form `defaultValue` → DOM.
371
+ const echo = (value: unknown): Record<string, unknown> | undefined => {
372
+ const withoutFiles = stripFiles(value);
373
+ if (withoutFiles === undefined) return undefined;
374
+ return stripSensitiveFields(withoutFiles, sensitivePredicate);
375
+ };
376
+
342
377
  try {
343
378
  // Run middleware
344
379
  const ctx = await runActionMiddleware(config.middleware);
345
380
 
346
- // The input is the last argument in every call shape: `(input)`,
347
- // `(formData)`, `(prevState, payload)` from useActionState's
348
- // dispatch, `(initialState, formData)` on the no-JS path (Fizz binds
349
- // the initial state into the form, and decodeAction binds the
350
- // FormData after it), and `(...bound, prevState, payload)` for an
351
- // action with bound arguments. Nothing before it is input:
352
- // `prevState` comes from the client, so it is never read.
353
- const last = args.at(-1);
354
- const rawInput: unknown = schema && last instanceof FormData ? parseFormData(last) : last;
355
-
356
- // Resolve the sensitive-field stripping predicate once per invocation.
357
- // Precedence: per-action (config.stripSensitiveFields) > global
358
- // (forms.stripSensitiveFields from timber.config.ts) > built-in deny-list.
359
- // See TIM-816.
360
- const sensitivePredicate = resolveSensitivePredicate(
361
- config.stripSensitiveFields,
362
- getGlobalSensitiveFieldsConfig()
363
- );
364
-
365
- // Capture a "safe-to-echo" snapshot of the raw input once. Files are
366
- // stripped (can't serialize, shouldn't echo back) and sensitive fields
367
- // (passwords, tokens, CVV, etc.) are removed before they would land
368
- // in the RSC payload → client form `defaultValue` → DOM.
369
- const buildSubmittedValues = (): Record<string, unknown> | undefined => {
370
- const withoutFiles = stripFiles(rawInput);
371
- if (withoutFiles === undefined) return undefined;
372
- return stripSensitiveFields(withoutFiles, sensitivePredicate);
373
- };
381
+ // Without a schema the action receives the FormData itself. Checks
382
+ // and echoes read the parsed form: a FormData has no own entries.
383
+ const rawInput: unknown = schema && form ? form : last;
384
+ const submitted: unknown = form ?? rawInput;
385
+ const buildSubmittedValues = () => echo(submitted);
374
386
 
375
387
  // Validate file sizes before schema validation.
376
- if (config.fileSizeLimit !== undefined && rawInput && typeof rawInput === 'object') {
388
+ if (config.fileSizeLimit !== undefined && submitted && typeof submitted === 'object') {
377
389
  const fileSizeErrors = validateFileSizes(
378
- rawInput as Record<string, unknown>,
390
+ submitted as Record<string, unknown>,
379
391
  config.fileSizeLimit
380
392
  );
381
393
  if (fileSizeErrors) {
@@ -435,7 +447,12 @@ export function createActionClient<TCtx = Record<string, never>>(
435
447
  if (isRedirectSignal(error) || isDenySignal(error)) {
436
448
  throw error;
437
449
  }
438
- return handleActionError(error);
450
+ // A `serverError` echoes the form like a validation failure does.
451
+ // React resets the form after the action, and a form whose defaults
452
+ // fell back to page data would lose what the user typed (TIM-1573).
453
+ const result = handleActionError(error);
454
+ const submittedValues = form && echo(form);
455
+ return submittedValues ? { ...result, submittedValues } : result;
439
456
  }
440
457
  }
441
458
 
@@ -508,6 +525,8 @@ function logValidationFailure(errors: ValidationErrors): void {
508
525
  /**
509
526
  * Validate that all File objects in the input are within the size limit.
510
527
  * Returns validation errors keyed by field name, or null if all files are ok.
528
+ * Keys are dot paths (`rows.0.file`), the form a field name and a Standard
529
+ * Schema error use, so `getFieldError` finds a file in a list.
511
530
  */
512
531
  function validateFileSizes(input: Record<string, unknown>, limit: number): ValidationErrors | null {
513
532
  const limitKb = Math.round(limit / 1024);
@@ -526,7 +545,7 @@ function validateFileSizes(input: Record<string, unknown>, limit: number): Valid
526
545
  } else if (Array.isArray(value)) {
527
546
  for (let i = 0; i < value.length; i++) {
528
547
  const item = value[i];
529
- const itemPath = `${path}[${i}]`;
548
+ const itemPath = `${path}.${i}`;
530
549
  if (item instanceof File && item.size > limit) {
531
550
  (errors[itemPath] ??= []).push(
532
551
  `File "${item.name}" (${formatSize(item.size)}) exceeds the ${limitLabel} limit`
@@ -546,29 +565,25 @@ function validateFileSizes(input: Record<string, unknown>, limit: number): Valid
546
565
  }
547
566
 
548
567
  /**
549
- * Strip File objects from a value, returning a plain object safe for
550
- * serialization. File objects can't be serialized and shouldn't be echoed back.
568
+ * Strip File objects from a value, returning a copy safe for serialization.
569
+ * File objects can't be serialized and shouldn't be echoed back.
570
+ *
571
+ * Mirrors `parseFormData`'s output: objects and arrays at any depth, arrays
572
+ * of arrays included. A File in an array becomes `undefined` rather than
573
+ * being removed, so `rows.2` still names the third element (TIM-1573).
551
574
  */
552
575
  function stripFiles(value: unknown): Record<string, unknown> | undefined {
553
- if (value === null || value === undefined) return undefined;
554
- if (typeof value !== 'object') return undefined;
576
+ if (value === null || typeof value !== 'object' || value instanceof File) return undefined;
577
+ return stripFilesFrom(value) as Record<string, unknown>;
578
+ }
555
579
 
580
+ function stripFilesFrom(value: unknown): unknown {
581
+ if (value instanceof File) return undefined;
582
+ if (Array.isArray(value)) return value.map(stripFilesFrom);
583
+ if (typeof value !== 'object' || value === null) return value;
556
584
  const result: Record<string, unknown> = {};
557
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
558
- if (v instanceof File) continue;
559
- if (Array.isArray(v)) {
560
- result[k] = v
561
- .filter((item) => !(item instanceof File))
562
- .map((item) =>
563
- typeof item === 'object' && item !== null && !(item instanceof File)
564
- ? (stripFiles(item) ?? {})
565
- : item
566
- );
567
- } else if (typeof v === 'object' && v !== null && !(v instanceof File)) {
568
- result[k] = stripFiles(v) ?? {};
569
- } else {
570
- result[k] = v;
571
- }
585
+ for (const [k, v] of Object.entries(value)) {
586
+ if (!(v instanceof File)) result[k] = stripFilesFrom(v);
572
587
  }
573
588
  return result;
574
589
  }
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { nonceAttr } from './render-utils.ts';
19
+ import { formStateToBase64 } from '../shared/form-state-flight.ts';
19
20
 
20
21
  // ─── JSON Escaping ────────────────────────────────────────────────────────
21
22
 
@@ -65,3 +66,15 @@ export function flightChunkScript(data: string, nonce?: string): string {
65
66
  const escaped = htmlEscapeJsonString(JSON.stringify([1, data]));
66
67
  return `<script${nonceAttr(nonce)}>(${FLIGHT_VAR}=${FLIGHT_VAR}||[]).push(${escaped})</script>`;
67
68
  }
69
+
70
+ /**
71
+ * Generate the script that embeds a no-JS action's form state for
72
+ * hydration: its Flight bytes (server/form-state-flight.ts), as base64. Only
73
+ * a page answering a no-JS action has one. Base64 carries Flight's binary
74
+ * rows, which a text chunk would corrupt, and has no character that can end
75
+ * the script element or the string. See design/08-forms-and-actions.md
76
+ * §"No-JS Result Round-Trip".
77
+ */
78
+ export function formStateScript(bytes: Uint8Array, nonce?: string): string {
79
+ return `<script${nonceAttr(nonce)}>self.__timber_form_state="${formStateToBase64(bytes)}"</script>`;
80
+ }
@@ -18,9 +18,15 @@
18
18
  * Handles:
19
19
  * - **Duplicate keys → arrays**: `tags=js&tags=ts` → `{ tags: ["js", "ts"] }`
20
20
  * - **Nested dot-paths**: `user.name=Alice` → `{ user: { name: "Alice" } }`
21
- * - **Empty strings → undefined**: Enables `.optional()` semantics in schemas
21
+ * - **Indexed lists → arrays**: `rows.0.name=A&rows.1.name=B` →
22
+ * `{ rows: [{ name: "A" }, { name: "B" }] }`, when the indexes are exactly
23
+ * `0..n-1`. A list with a gap stays an object keyed by index.
24
+ * - **Empty strings stay `""`**: a blank text input is `""`, so
25
+ * `z.string().min(1, 'Required')` reports its own message. Use
26
+ * `coerce.text` to make a blank optional field `undefined`
22
27
  * - **Empty Files → undefined**: File inputs with no selection become `undefined`
23
28
  * - **Strips `$ACTION_*` fields**: React's internal hidden fields are excluded
29
+ * - **Drops `__proto__` paths and names deeper than 32 segments**
24
30
  */
25
31
  export function parseFormData(formData: FormData): Record<string, unknown> {
26
32
  const flat: Record<string, unknown> = {};
@@ -97,6 +103,11 @@ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown>
97
103
  // `result['__proto__']` resolves to the prototype object itself.
98
104
  const parts = key.split('.');
99
105
  if (parts.some((p) => DANGEROUS_KEYS.has(p))) continue;
106
+ // Bound the nesting depth. Every walk over the parsed value recurses
107
+ // once per level (list conversion here, then file and sensitive-field
108
+ // stripping and schema validation), and a 10 KB key of 5,000 segments
109
+ // fits well inside the body limits. No form nests this deep.
110
+ if (parts.length > MAX_PATH_DEPTH) continue;
100
111
 
101
112
  if (parts.length === 1) {
102
113
  result[parts[0]] = value;
@@ -106,12 +117,13 @@ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown>
106
117
  let current: Record<string, unknown> = result;
107
118
  for (let i = 0; i < parts.length - 1; i++) {
108
119
  const part = parts[i];
109
- if (current[part] === undefined || current[part] === null) {
110
- current[part] = {};
111
- }
112
- // If current[part] is not an object (e.g., a string from a non-dotted key),
113
- // the dot-path takes precedence
114
- if (typeof current[part] !== 'object' || current[part] instanceof File) {
120
+ // Step only into an object this walk built. Anything else under the
121
+ // name — a string, a File, or an array from a duplicate key
122
+ // (`rows=x&rows=y`) — is replaced: the dot-path takes precedence.
123
+ // Stepping into an array would let `rows.4294967294` set its length
124
+ // to 2^32 - 1, and every later walk (file and sensitive-field
125
+ // stripping) maps over that length (TIM-1573).
126
+ if (!isPlainObject(current[part])) {
115
127
  current[part] = {};
116
128
  }
117
129
  current = current[part] as Record<string, unknown>;
@@ -120,9 +132,39 @@ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown>
120
132
  current[parts[parts.length - 1]] = value;
121
133
  }
122
134
 
135
+ // The top level is always an object: it is the form, not a list.
136
+ for (const [key, value] of Object.entries(result)) {
137
+ if (isPlainObject(value)) result[key] = indexedListsToArrays(value);
138
+ }
123
139
  return result;
124
140
  }
125
141
 
142
+ /**
143
+ * `node`, with every nested object whose keys are exactly `"0".."n-1"`
144
+ * turned into an array, depth first. Only a dense, canonical index set
145
+ * converts: `{ "0", "2" }` (a gap), `{ "01" }` and `{ "0", "name" }` stay
146
+ * objects. So a forged `rows.99999` stays one key instead of allocating a
147
+ * sparse array, and the array's length is bounded by the field count limit.
148
+ *
149
+ * Object.keys lists integer-like keys first, in ascending order, so a dense
150
+ * set reads as `0, 1, … n-1` and `Object.values` is already in index order.
151
+ */
152
+ function indexedListsToArrays(node: Record<string, unknown>): Record<string, unknown> | unknown[] {
153
+ for (const [key, value] of Object.entries(node)) {
154
+ if (isPlainObject(value)) node[key] = indexedListsToArrays(value);
155
+ }
156
+ const keys = Object.keys(node);
157
+ const dense = keys.length > 0 && keys.every((key, i) => key === String(i));
158
+ return dense ? Object.values(node) : node;
159
+ }
160
+
161
+ /** An object the dot-path walk built — not an array, File or other instance. */
162
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
163
+ return (
164
+ typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype
165
+ );
166
+ }
167
+
126
168
  // `__proto__` is the only key in this parser that escapes the dot-path
127
169
  // walk's `typeof !== 'object'` reset: accessing `obj['__proto__']` returns
128
170
  // `Object.prototype` (itself an object), so the walk steps into the global
@@ -132,13 +174,22 @@ function expandDotPaths(flat: Record<string, unknown>): Record<string, unknown>
132
174
  // keys and do not mutate anything global.
133
175
  const DANGEROUS_KEYS = new Set(['__proto__']);
134
176
 
177
+ /**
178
+ * The most segments a dot-path field name may have. A deeper name is dropped,
179
+ * like a `__proto__` path, so no walk over the parsed value can exhaust the
180
+ * stack (TIM-1573).
181
+ */
182
+ const MAX_PATH_DEPTH = 32;
183
+
135
184
  // ─── Coercion Helpers ────────────────────────────────────────────────────
136
185
 
137
186
  /**
138
187
  * Schema-agnostic coercion primitives for common FormData patterns.
139
188
  *
140
189
  * These are plain transform functions — they compose with any schema library's
141
- * `transform`/`preprocess` pipeline:
190
+ * `transform`/`preprocess` pipeline. In Zod, use `z.preprocess`: it runs on an
191
+ * absent key (an unchecked checkbox), where `z.unknown().transform()` fails
192
+ * with "expected nonoptional" before the transform runs.
142
193
  *
143
194
  * ```ts
144
195
  * // Zod
@@ -153,8 +204,9 @@ export const coerce = {
153
204
  * Use with `.optional()` schemas where an empty input means "not provided".
154
205
  *
155
206
  * ```ts
156
- * // Zod
157
- * z.unknown().transform(coerce.text).pipe(z.string().optional())
207
+ * // Zod — preprocess, not `z.unknown().transform(…)`: Zod 4 rejects an
208
+ * // absent key before a transform runs ("expected nonoptional")
209
+ * z.preprocess(coerce.text, z.string().optional())
158
210
  * // Valibot
159
211
  * v.pipe(v.unknown(), v.transform(coerce.text), v.optional(v.string()))
160
212
  * ```
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The no-JS action form state a page render hands React (TIM-1570), encoded
3
+ * with Flight (TIM-1572).
4
+ *
5
+ * Fizz renders the `useActionState` hook that submitted with the action's
6
+ * result, and the browser must hydrate with the same value, or the hook
7
+ * resets. Both get it from the Flight bytes encoded here: SSR decodes them
8
+ * for Fizz (`NavContext.formState`) and, once they decode, embeds them for
9
+ * `hydrateRoot` (server/ssr-form-state.ts; decoded by
10
+ * client/browser-entry/form-state.ts). Flight is what carries a
11
+ * `useActionState` result with JS, so a result has the same types with and
12
+ * without JS. See shared/form-state-flight.ts for the decode.
13
+ *
14
+ * A state Flight cannot encode must not turn the page into a 500 after the
15
+ * action's mutation committed, so it is dropped from both renders: the page
16
+ * renders with no result, as for a form without `useActionState`. That is a
17
+ * value Flight cannot serialize (a plain function, a class instance), which
18
+ * fails the action with JS too, or a nested promise that rejects or does not
19
+ * settle in time, which with JS reaches the hook as that rejected or pending
20
+ * promise. The no-JS page cannot carry a pending promise, and does not embed
21
+ * an error row.
22
+ *
23
+ * See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
24
+ */
25
+
26
+ import type { ReactFormState } from 'react-dom/client';
27
+ import { renderToReadableStream } from '../rsc-runtime/rsc.ts';
28
+ import { swallow } from './logger.ts';
29
+ import { readAllBytesWithDeadline } from './stream-utils.ts';
30
+
31
+ /**
32
+ * Serialize the form state with Flight, or return `null` when it cannot be,
33
+ * with a warning. The whole payload is read under one `timeoutMs` deadline,
34
+ * and the render stops when `signal` (the request's) aborts.
35
+ */
36
+ export async function encodeFormState(
37
+ formState: ReactFormState,
38
+ timeoutMs: number,
39
+ signal?: AbortSignal
40
+ ): Promise<Uint8Array | null> {
41
+ let failure: unknown;
42
+ try {
43
+ // Flight reports a value it cannot serialize (anywhere in the tree,
44
+ // including a rejected nested promise) here, and writes an error row in
45
+ // its place; a result with an error row in it is not the action's result.
46
+ const flightErrors: unknown[] = [];
47
+ const stream = renderToReadableStream(formState, {
48
+ signal,
49
+ onError(error: unknown) {
50
+ flightErrors.push(error);
51
+ },
52
+ });
53
+ const bytes = await readAllBytesWithDeadline(stream, timeoutMs, 'no-JS form state encode');
54
+ if (flightErrors.length === 0) return bytes;
55
+ failure = flightErrors[0];
56
+ } catch (error) {
57
+ // The deadline, reported as itself: cancelling the read after it makes
58
+ // Flight report an abort to onError too, which says nothing of the cause.
59
+ failure = error;
60
+ }
61
+ swallow(
62
+ failure,
63
+ "a no-JS action's result could not be serialized with Flight, so the page renders without it",
64
+ { level: 'warn' }
65
+ );
66
+ return null;
67
+ }
@@ -123,11 +123,14 @@ export function buildActionDispatcher(
123
123
  // render's request-context cookies, so the page reads what the
124
124
  // action wrote, not what the request carried (TIM-868).
125
125
  // - Method GET because it is a page render.
126
+ // - The POST's abort signal, so a client that disconnects stops the
127
+ // render (and the form state encode) as it would any page's.
126
128
  const rerenderHeaders = new Headers(req.headers);
127
129
  rerenderHeaders.delete('cookie');
128
130
  const rerenderReq = new Request(req.url, {
129
131
  method: 'GET',
130
132
  headers: rerenderHeaders,
133
+ signal: req.signal,
131
134
  });
132
135
  const { cookies } = actionResponse;
133
136
  const response = await reenter(
@@ -322,6 +322,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
322
322
  clientSegmentCache,
323
323
  renderDenyFallback,
324
324
  buildManifest: typedBuildManifest,
325
+ renderTimeoutMs: (config as Record<string, unknown>).renderTimeoutMs as number,
325
326
  globalError: manifest.globalError,
326
327
  },
327
328
  interception