@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
@@ -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. */
@@ -161,7 +165,8 @@ export interface ActionBuilderWithSchema<TCtx, TInput> {
161
165
  /**
162
166
  * The final action function. Callable three ways:
163
167
  * - Direct: action(input) → Promise<ActionResult<TData>>
164
- * - React useActionState: action(prevState, formData) → Promise<ActionResult<TData>>
168
+ * - React useActionState: action(prevState, payload) → Promise<ActionResult<TData>>,
169
+ * where payload is the submitted FormData or the value dispatch was called with
165
170
  * - React <form action={fn}>: action(formData) → void (return value ignored by React)
166
171
  *
167
172
  * The third overload exists purely for type compatibility with React's
@@ -193,8 +198,11 @@ export type ActionFn<TData = unknown, TInput = unknown> = {
193
198
  (
194
199
  ...args: undefined extends TInput ? [input?: TInput] : [input: TInput]
195
200
  ): Promise<ActionResult<TData>>;
196
- /** React useActionState: action(prevState, formData) */
197
- (prevState: ActionResult<TData> | null, formData: FormData): Promise<ActionResult<TData>>;
201
+ /**
202
+ * React useActionState: action(prevState, payload). The payload is the
203
+ * submitted FormData, or whatever the hook's dispatch was called with.
204
+ */
205
+ (prevState: ActionResult<TData> | null, payload: TInput | FormData): Promise<ActionResult<TData>>;
198
206
  };
199
207
 
200
208
  // ─── Implementation ──────────────────────────────────────────────────────
@@ -281,10 +289,12 @@ function extractStandardSchemaErrors(issues: ReadonlyArray<StandardSchemaIssue>)
281
289
  * Wrap unexpected errors into a safe server error result.
282
290
  * ActionError → typed result. Other errors → INTERNAL_ERROR (no leak).
283
291
  *
284
- * Exported for use by action-handler.ts to catch errors from raw 'use server'
285
- * functions that don't use createActionClient.
292
+ * `createActionClient` actions only. A raw `'use server'` function that
293
+ * throws is rejected on the client instead (action-handler.ts).
286
294
  */
287
- export function handleActionError(error: unknown): ActionResult<never> {
295
+ export function handleActionError(error: unknown): {
296
+ serverError: { code: string; data?: Record<string, unknown> };
297
+ } {
288
298
  if (error instanceof ActionError) {
289
299
  return {
290
300
  serverError: {
@@ -335,49 +345,49 @@ export function createActionClient<TCtx = Record<string, never>>(
335
345
  fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>
336
346
  ): ActionFn<TData, TInput> {
337
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
+
338
377
  try {
339
378
  // Run middleware
340
379
  const ctx = await runActionMiddleware(config.middleware);
341
380
 
342
- // Determine input — either FormData (from useActionState) or direct arg
343
- let rawInput: unknown;
344
- if (args.length === 2 && args[1] instanceof FormData) {
345
- // Called as (prevState, formData) by React useActionState (with-JS path)
346
- rawInput = schema ? parseFormData(args[1]) : args[1];
347
- } else if (args.length === 1 && args[0] instanceof FormData) {
348
- // No-JS path: React's decodeAction binds FormData as the sole argument.
349
- // The form POSTs without JavaScript, decodeAction resolves the server
350
- // reference and binds the FormData, then executeAction calls fn() with
351
- // no additional args — so the bound FormData arrives as args[0].
352
- rawInput = schema ? parseFormData(args[0]) : args[0];
353
- } else {
354
- // Direct call: action(input)
355
- rawInput = args[0];
356
- }
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
- // Capture a "safe-to-echo" snapshot of the raw input once. Files are
368
- // stripped (can't serialize, shouldn't echo back) and sensitive fields
369
- // (passwords, tokens, CVV, etc.) are removed before they would land
370
- // in the RSC payload → client form `defaultValue` → DOM.
371
- const buildSubmittedValues = (): Record<string, unknown> | undefined => {
372
- const withoutFiles = stripFiles(rawInput);
373
- if (withoutFiles === undefined) return undefined;
374
- return stripSensitiveFields(withoutFiles, sensitivePredicate);
375
- };
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);
376
386
 
377
387
  // Validate file sizes before schema validation.
378
- if (config.fileSizeLimit !== undefined && rawInput && typeof rawInput === 'object') {
388
+ if (config.fileSizeLimit !== undefined && submitted && typeof submitted === 'object') {
379
389
  const fileSizeErrors = validateFileSizes(
380
- rawInput as Record<string, unknown>,
390
+ submitted as Record<string, unknown>,
381
391
  config.fileSizeLimit
382
392
  );
383
393
  if (fileSizeErrors) {
@@ -437,7 +447,12 @@ export function createActionClient<TCtx = Record<string, never>>(
437
447
  if (isRedirectSignal(error) || isDenySignal(error)) {
438
448
  throw error;
439
449
  }
440
- 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;
441
456
  }
442
457
  }
443
458
 
@@ -510,6 +525,8 @@ function logValidationFailure(errors: ValidationErrors): void {
510
525
  /**
511
526
  * Validate that all File objects in the input are within the size limit.
512
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.
513
530
  */
514
531
  function validateFileSizes(input: Record<string, unknown>, limit: number): ValidationErrors | null {
515
532
  const limitKb = Math.round(limit / 1024);
@@ -528,7 +545,7 @@ function validateFileSizes(input: Record<string, unknown>, limit: number): Valid
528
545
  } else if (Array.isArray(value)) {
529
546
  for (let i = 0; i < value.length; i++) {
530
547
  const item = value[i];
531
- const itemPath = `${path}[${i}]`;
548
+ const itemPath = `${path}.${i}`;
532
549
  if (item instanceof File && item.size > limit) {
533
550
  (errors[itemPath] ??= []).push(
534
551
  `File "${item.name}" (${formatSize(item.size)}) exceeds the ${limitLabel} limit`
@@ -548,29 +565,25 @@ function validateFileSizes(input: Record<string, unknown>, limit: number): Valid
548
565
  }
549
566
 
550
567
  /**
551
- * Strip File objects from a value, returning a plain object safe for
552
- * 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).
553
574
  */
554
575
  function stripFiles(value: unknown): Record<string, unknown> | undefined {
555
- if (value === null || value === undefined) return undefined;
556
- 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
+ }
557
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;
558
584
  const result: Record<string, unknown> = {};
559
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
560
- if (v instanceof File) continue;
561
- if (Array.isArray(v)) {
562
- result[k] = v
563
- .filter((item) => !(item instanceof File))
564
- .map((item) =>
565
- typeof item === 'object' && item !== null && !(item instanceof File)
566
- ? (stripFiles(item) ?? {})
567
- : item
568
- );
569
- } else if (typeof v === 'object' && v !== null && !(v instanceof File)) {
570
- result[k] = stripFiles(v) ?? {};
571
- } else {
572
- result[k] = v;
573
- }
585
+ for (const [k, v] of Object.entries(value)) {
586
+ if (!(v instanceof File)) result[k] = stripFilesFrom(v);
574
587
  }
575
588
  return result;
576
589
  }
@@ -8,7 +8,8 @@
8
8
  * 1. Detect action request (POST with `x-rsc-action` header or form action fields)
9
9
  * 2. CSRF validation
10
10
  * 3. Load and execute the server action
11
- * 4. Return RSC stream (with-JS) or 302 redirect (no-JS)
11
+ * 4. Return RSC stream (with-JS), or on the no-JS path a redirect, a page
12
+ * rerender carrying React's form state, or the error page
12
13
  *
13
14
  * See design/08-forms-and-actions.md
14
15
  */
@@ -17,6 +18,7 @@ import {
17
18
  loadServerAction,
18
19
  decodeReply,
19
20
  decodeAction,
21
+ decodeFormState,
20
22
  renderToReadableStream,
21
23
  } from '../rsc-runtime/rsc.ts';
22
24
 
@@ -26,16 +28,19 @@ import { executeAction, type RevalidateRenderer } from './actions.ts';
26
28
  import { runWithRequestContext, setMutableCookieContext } from './request-context.ts';
27
29
  import { runOutsideReactCacheScope, runWithReactCacheScope } from './react-cache-scope.ts';
28
30
  import { getSetCookieHeaders, getCookiesForSsr } from './cookie-context.ts';
29
- import { ActionError, handleActionError } from './action-client.ts';
31
+ import { ActionError } from './action-client.ts';
30
32
  import { enforceBodyLimits, enforceFieldLimit, type BodyLimitsConfig } from './body-limits.ts';
31
- import { parseFormData } from './form-data.ts';
32
33
  import {
33
34
  stripSensitiveFields,
34
35
  resolveSensitivePredicate,
35
36
  getGlobalSensitiveFieldsConfig,
37
+ type ResolvedSensitivePredicate,
36
38
  type SensitiveFieldsOption,
37
39
  } from './sensitive-fields.ts';
38
- import type { FormFlashData } from './form-flash.ts';
40
+ import { randomUUID } from 'node:crypto';
41
+ import type { ReactFormState } from 'react-dom/client';
42
+ import { encodeErrorDigest } from '../shared/error-digest.ts';
43
+ import { clientErrorMessage } from './client-error-message.ts';
39
44
  import { checkVersionSkew, applyReloadHeaders } from './version-skew.ts';
40
45
  import { logActionError, swallow } from './logger.ts';
41
46
  import { fireOnRequestError } from './pipeline-helpers.ts';
@@ -95,7 +100,18 @@ export function isActionRequest(req: Request): boolean {
95
100
  // ─── Handler ──────────────────────────────────────────────────────────────
96
101
 
97
102
  /**
98
- * Signal from handleFormAction to re-render the page with flash data instead of redirecting.
103
+ * How a no-JS action that did not redirect is answered. The dispatcher
104
+ * (`rsc-entry/action-dispatcher.ts`) turns it into the response:
105
+ *
106
+ * - `rerender`: the action returned. The page is rendered again as a GET,
107
+ * with `formState` — React's `decodeFormState` of the result, present
108
+ * only when the submitted form used `useActionState` — handed to SSR
109
+ * and hydration, where React gives it to the hook that submitted.
110
+ * - `error`: the action threw. The page is rendered again with the error
111
+ * in place of the page component, so the route's nearest error page
112
+ * answers, with the
113
+ * status the pipeline gives any unhandled error. The JS path rejects
114
+ * the action for the same throw, so both reach an error boundary.
99
115
  *
100
116
  * Carries two cookie-related snapshots taken before the action's ALS scope
101
117
  * exits, so the rerender pipeline (which establishes its own fresh cookie
@@ -115,22 +131,36 @@ export function isActionRequest(req: Request): boolean {
115
131
  * previous `cookieHeader: string` shape carried — see
116
132
  * ONGOING_SECURITY.md H-3 (TIM-868) and TIM-837.
117
133
  */
118
- export interface FormRerender {
119
- rerender: FormFlashData;
120
- setCookieHeaders: string[];
121
- cookies: Map<string, string>;
122
- }
134
+ export type FormActionOutcome =
135
+ | {
136
+ kind: 'rerender';
137
+ formState: ReactFormState | undefined;
138
+ setCookieHeaders: string[];
139
+ cookies: Map<string, string>;
140
+ }
141
+ | {
142
+ kind: 'error';
143
+ error: unknown;
144
+ errorId: string;
145
+ setCookieHeaders: string[];
146
+ cookies: Map<string, string>;
147
+ };
148
+
149
+ /** What `handleFormAction` settles with, before the cookie snapshot. */
150
+ type FormActionSettled =
151
+ | { kind: 'rerender'; formState: ReactFormState | undefined }
152
+ | { kind: 'error'; error: unknown; errorId: string };
123
153
 
124
154
  /**
125
155
  * Handle a server action request.
126
156
  *
127
- * Returns a Response, a FormRerender signal (for no-JS validation failure re-render),
157
+ * Returns a Response, a FormActionOutcome (no-JS action that did not redirect),
128
158
  * or null if this isn't actually an action request (e.g., a regular form POST to an API route).
129
159
  */
130
160
  export async function handleActionRequest(
131
161
  req: Request,
132
162
  config: ActionDispatchConfig
133
- ): Promise<Response | FormRerender | null> {
163
+ ): Promise<Response | FormActionOutcome | null> {
134
164
  // Version skew detection — reject actions from stale clients (TIM-446).
135
165
  // On mismatch, return a structured RSC error response that the client
136
166
  // handles by showing a brief "App updated" message and reloading.
@@ -183,38 +213,35 @@ export async function handleActionRequest(
183
213
  setMutableCookieContext(true);
184
214
  const actionId = req.headers.get('x-rsc-action');
185
215
 
186
- let result: Response | FormRerender | null;
187
- if (actionId) {
188
- // With-JS path: client sent action ID in header, args in body
189
- result = await handleRscAction(req, actionId, config);
190
- } else {
191
- // No-JS path: form POST with React's hidden action fields
192
- result = await handleFormAction(req, config);
193
- }
216
+ // With-JS path: client sent action ID in header, args in body.
217
+ // No-JS path: form POST with React's hidden action fields.
218
+ const result = actionId
219
+ ? await handleRscAction(req, actionId, config)
220
+ : await handleFormAction(req, config);
194
221
 
195
222
  // Apply cookie jar to action responses.
196
223
  //
197
- // For Response results we append Set-Cookie directly. For FormRerender
198
- // signals we snapshot the headers here, before this ALS scope exits, so
199
- // the caller can apply them to the rerender pipeline's response (which
224
+ // For a Response we append Set-Cookie directly. For a no-JS outcome we
225
+ // snapshot the headers here, before this ALS scope exits, so the
226
+ // dispatcher can apply them to the response it builds (the rerender
200
227
  // runs in its own request-context scope with a fresh cookie jar).
201
228
  // See LOCAL-740 — without this snapshot, cookies set inside a no-JS
202
- // form action that returns validation errors are silently dropped.
229
+ // form action are silently dropped.
230
+ if (result === null) return null;
203
231
  if (result instanceof Response) {
204
232
  for (const value of getSetCookieHeaders()) {
205
233
  result.headers.append('Set-Cookie', value);
206
234
  }
207
- } else if (result && 'rerender' in result) {
208
- result.setCookieHeaders = getSetCookieHeaders();
209
- // Snapshot the post-action RYW cookie state as a Map so the rerender
210
- // dispatcher can hand it to the rerender request context directly,
211
- // with no string round-trip. See TIM-837
212
- // and ONGOING_SECURITY.md H-3 (TIM-868). `getCookiesForSsr` already
213
- // returns a defensive copy, so the rerender scope cannot mutate the
214
- // snapshot through this reference.
215
- result.cookies = getCookiesForSsr();
235
+ return result;
216
236
  }
217
- return result;
237
+ const setCookieHeaders = getSetCookieHeaders();
238
+ // Snapshot the post-action RYW cookie state as a Map so the rerender
239
+ // dispatcher can hand it to the rerender request context directly,
240
+ // with no string round-trip. See TIM-837
241
+ // and ONGOING_SECURITY.md H-3 (TIM-868). `getCookiesForSsr` already
242
+ // returns a defensive copy, so the rerender scope cannot mutate the
243
+ // snapshot through this reference.
244
+ return { ...result, setCookieHeaders, cookies: getCookiesForSsr() };
218
245
  })
219
246
  );
220
247
  }
@@ -226,8 +253,8 @@ export async function handleActionRequest(
226
253
  * `'action'`. An `ActionError` is not reported: it is an outcome the
227
254
  * action chose to return, not an unhandled error.
228
255
  */
229
- async function reportActionError(req: Request, error: unknown): Promise<void> {
230
- logActionError({ method: req.method, path: new URL(req.url).pathname, error });
256
+ async function reportActionError(req: Request, error: unknown, errorId?: string): Promise<void> {
257
+ logActionError({ method: req.method, path: new URL(req.url).pathname, error, errorId });
231
258
  if (error instanceof ActionError) return;
232
259
  await fireOnRequestError(error, req, 'action');
233
260
  }
@@ -275,17 +302,32 @@ async function rejectActionRequest(
275
302
  }
276
303
 
277
304
  /**
278
- * The no-JS answer to an action error: re-render the page with the error as
279
- * flash data. `handleActionError` produces `{ serverError }` for an
280
- * ActionError and `{ serverError: { code: 'INTERNAL_ERROR' } }` otherwise.
305
+ * The with-JS answer to an action that threw: the action rejects on the
306
+ * client and the nearest error boundary renders, as React does for any
307
+ * server function that throws. The no-JS path answers the same throw with
308
+ * the error page, so both reach an error boundary and neither turns the
309
+ * throw into state the action never returned. `createActionClient`
310
+ * actions never get here: they catch into a `serverError` result.
311
+ *
312
+ * The error crosses as a rejected Flight root carrying the digest a render
313
+ * error carries (shared/error-digest.ts): its message in dev, a fixed one in
314
+ * production (design/13-security.md §"Errors don't leak"), and the
315
+ * correlation ID the server logged it with.
281
316
  */
282
- function errorRerender(error: unknown, submittedValues: Record<string, unknown>): FormRerender {
283
- return {
284
- rerender: { ...handleActionError(error), submittedValues },
285
- // Filled in by handleActionRequest before the ALS scope exits.
286
- setCookieHeaders: [],
287
- cookies: new Map(),
288
- };
317
+ async function rejectedActionResponse(req: Request, error: unknown): Promise<Response> {
318
+ const errorId = randomUUID();
319
+ await reportActionError(req, error, errorId);
320
+ const rejection = Promise.reject(error);
321
+ // Flight subscribes when it renders the root, after this tick; the no-op
322
+ // handler keeps the rejection from being reported as unhandled first.
323
+ rejection.catch(() => {});
324
+ const rscStream = renderToReadableStream(rejection, {
325
+ onError: () => encodeErrorDigest({ message: clientErrorMessage(error), errorId }),
326
+ });
327
+ return new Response(rscStream, {
328
+ status: 500,
329
+ headers: { 'Content-Type': RSC_CONTENT_TYPE },
330
+ });
289
331
  }
290
332
 
291
333
  /**
@@ -355,10 +397,8 @@ async function handleRscAction(
355
397
  const call = await decodeRscActionCall(req, actionId, config);
356
398
  if (call instanceof Response) return call;
357
399
 
358
- // Execute the action with revalidation tracking.
359
- // Errors are caught here so raw 'use server' functions (not using
360
- // createActionClient) still return structured error responses instead
361
- // of leaking stack traces as 500s.
400
+ // Execute the action with revalidation tracking. A throw is answered
401
+ // with a rejected action (below), never a stack trace.
362
402
  let result;
363
403
  try {
364
404
  const requestUrl = new URL(req.url);
@@ -371,16 +411,7 @@ async function handleRscAction(
371
411
  requestSearch: requestUrl.search,
372
412
  });
373
413
  } catch (error) {
374
- await reportActionError(req, error);
375
-
376
- // Return structured error response — ActionError gets its code/data,
377
- // unexpected errors get sanitized { code: 'INTERNAL_ERROR' }
378
- const errorResult = handleActionError(error);
379
- const rscStream = renderToReadableStream(errorResult);
380
- return new Response(rscStream, {
381
- status: 200,
382
- headers: { 'Content-Type': RSC_CONTENT_TYPE },
383
- });
414
+ return rejectedActionResponse(req, error);
384
415
  }
385
416
  // Outside the try: the action succeeded, and a failure to report its
386
417
  // revalidation must not turn it into an action error.
@@ -571,13 +602,14 @@ function concatUint8Arrays(chunks: Uint8Array[]): Uint8Array {
571
602
  * Handle a no-JS form action (progressive enhancement fallback).
572
603
  *
573
604
  * React embeds `$ACTION_REF` / `$ACTION_KEY` hidden fields in the form.
574
- * We use `decodeAction` to resolve the action function from the form data,
575
- * execute it, then redirect back to the form's page.
605
+ * We use `decodeAction` to resolve the action function from the form data
606
+ * and execute it. A redirect is answered here; anything else settles as a
607
+ * `FormActionSettled` for the dispatcher to render.
576
608
  */
577
609
  async function handleFormAction(
578
610
  req: Request,
579
611
  config: ActionDispatchConfig
580
- ): Promise<Response | FormRerender | null> {
612
+ ): Promise<Response | FormActionSettled | null> {
581
613
  // Clone before consuming — if this turns out not to be a server action form,
582
614
  // we return null and the original request body must remain readable for
583
615
  // downstream route handlers. Clone is cheap (shares the body buffer until read).
@@ -595,16 +627,6 @@ async function handleFormAction(
595
627
  return new Response(null, { status: fieldResult.status });
596
628
  }
597
629
 
598
- // Capture submitted values for re-render on validation failure.
599
- // Parse before decodeAction consumes the FormData, then strip sensitive
600
- // fields (passwords, tokens, CVV, etc.) so they are never rendered back
601
- // into the HTML as `defaultValue` attributes. See TIM-816.
602
- const sensitivePredicate = resolveSensitivePredicate(
603
- config.sensitiveFields,
604
- getGlobalSensitiveFieldsConfig()
605
- );
606
- const submittedValues = stripSensitiveFields(parseFormData(formData), sensitivePredicate);
607
-
608
630
  // decodeAction resolves the action function from the form data's hidden fields.
609
631
  // Returns null when no $ACTION_REF_/$ACTION_ID_ fields are present — meaning
610
632
  // this is a regular form POST, not a React server action.
@@ -632,8 +654,9 @@ async function handleFormAction(
632
654
  renderer: config.revalidateRenderer,
633
655
  });
634
656
  } catch (error) {
635
- await reportActionError(req, error);
636
- return errorRerender(error, submittedValues);
657
+ const errorId = randomUUID();
658
+ await reportActionError(req, error, errorId);
659
+ return { kind: 'error', error, errorId };
637
660
  }
638
661
  // Outside the try: the action succeeded, and a failure to report its
639
662
  // revalidation must not turn it into an action error.
@@ -647,25 +670,40 @@ async function handleFormAction(
647
670
  });
648
671
  }
649
672
 
650
- // Re-render the page with the action result as flash data.
651
- // The server component reads the flash via getFormFlash() and passes it
652
- // to the client form component as the initial useActionState value.
653
- // This handles both success ({ data }) and validation failure
654
- // ({ validationErrors, submittedValues }) — the form is the single source of truth.
655
- const actionResult = result.actionResult as FormFlashData;
656
-
657
- // Defense-in-depth: strip sensitive fields from `actionResult.submittedValues`
658
- // even if the action already built it. `createActionClient` strips internally,
659
- // but raw `'use server'` functions that manually return `{ submittedValues }`
660
- // are not covered by the inner strip. See TIM-816.
661
- if (actionResult && actionResult.submittedValues) {
662
- actionResult.submittedValues = stripSensitiveFields(
663
- actionResult.submittedValues,
664
- sensitivePredicate
665
- );
666
- }
673
+ // Defense-in-depth: strip sensitive fields from `submittedValues` before
674
+ // the result is rendered into the page. `createActionClient` strips
675
+ // internally, but a raw `'use server'` function that returns
676
+ // `{ submittedValues }` itself is not covered by that. See TIM-816.
677
+ const actionResult = stripResultSubmittedValues(
678
+ result.actionResult,
679
+ resolveSensitivePredicate(config.sensitiveFields, getGlobalSensitiveFieldsConfig())
680
+ );
667
681
 
668
- // setCookieHeaders + cookies are filled in by handleActionRequest before
669
- // the ALS scope exits.
670
- return { rerender: actionResult, setCookieHeaders: [], cookies: new Map() };
682
+ // React keys the result to the hook that submitted: `$ACTION_KEY` names
683
+ // the useActionState instance, and Fizz hands the result to it as its
684
+ // initial state only when that key and the action match. A form with no
685
+ // useActionState has no key, so this is undefined and the page renders
686
+ // with no result, as the JS path discards the return value of a plain
687
+ // `<form action>`. Such forms should `redirect()` (post-redirect-get).
688
+ const formState = (await decodeFormState(actionResult, formData)) ?? undefined;
689
+ return { kind: 'rerender', formState };
690
+ }
691
+
692
+ /**
693
+ * `result` with sensitive fields removed from its `submittedValues`, when it
694
+ * has any. A copy: the action's own object is not written to.
695
+ */
696
+ function stripResultSubmittedValues(
697
+ result: unknown,
698
+ isSensitive: ResolvedSensitivePredicate
699
+ ): unknown {
700
+ if (typeof result !== 'object' || result === null || !('submittedValues' in result)) {
701
+ return result;
702
+ }
703
+ const { submittedValues } = result;
704
+ if (typeof submittedValues !== 'object' || submittedValues === null) return result;
705
+ return {
706
+ ...result,
707
+ submittedValues: stripSensitiveFields(submittedValues, isSensitive),
708
+ };
671
709
  }
@@ -35,6 +35,7 @@ import '#server-only-guard';
35
35
  import type { CoercedParams } from '../shared/param-value.ts';
36
36
  import type { SlotParamsRecord } from '../shared/slot-params.ts';
37
37
  import { AsyncLocalStorage } from 'node:async_hooks';
38
+ import type { ReactFormState } from 'react-dom/client';
38
39
  /**
39
40
  * Return a process-wide singleton `AsyncLocalStorage` keyed by `symbol`.
40
41
  *
@@ -65,6 +66,12 @@ export const requestContextAls = getOrCreateAls<RequestContextStore>(
65
66
  Symbol.for('timber:request-context-als')
66
67
  );
67
68
 
69
+ /** A no-JS action's thrown error, and the correlation ID it was logged with. */
70
+ export interface ActionErrorForRender {
71
+ error: unknown;
72
+ errorId: string;
73
+ }
74
+
68
75
  export interface RequestContextStore {
69
76
  /**
70
77
  * The request this context was opened for, or `null` in the build-time
@@ -134,6 +141,22 @@ export interface RequestContextStore {
134
141
  flushed: boolean;
135
142
  /** Whether the current context allows cookie mutation. */
136
143
  mutableContext: boolean;
144
+ /**
145
+ * The form state a no-JS action produced (`decodeFormState`), set only on
146
+ * the page render that answers it. Read once, by the SSR renderer, which
147
+ * hands it to Fizz and embeds it for `hydrateRoot`; no user-facing API
148
+ * reads it, because a server component cannot see an action's result on
149
+ * the JS path either (design/08 §"No-JS Result Round-Trip").
150
+ */
151
+ formState?: ReactFormState;
152
+ /**
153
+ * The error a no-JS action threw, set only on the page render that answers
154
+ * it. The route renders it where its page would be, so the nearest error
155
+ * page answers, as the nearest error boundary does with JS. Read by the
156
+ * route element builder and the Flight `onError`; no user-facing API reads
157
+ * it (design/08 §"The Basic Wire-Up").
158
+ */
159
+ actionError?: ActionErrorForRender;
137
160
  /**
138
161
  * Set by AccessGate or PageDenyBoundary when a DenySignal is caught
139
162
  * server-side (inside the React tree, before React Flight sees it).
@@ -236,15 +259,6 @@ export const revalidationAls = getOrCreateAls<RevalidationState>(
236
259
  Symbol.for('timber:revalidation-als')
237
260
  );
238
261
 
239
- // ─── Form Flash ───────────────────────────────────────────────────────────
240
- // Used by: form-flash.ts (getFormFlash())
241
- // Design doc: design/08-forms-and-actions.md §"No-JS Result Round-Trip"
242
-
243
- /** @internal — import via form-flash.ts public API */
244
- export const formFlashAls = getOrCreateAls<import('./form-flash.ts').FormFlashData>(
245
- Symbol.for('timber:form-flash-als')
246
- );
247
-
248
262
  // ─── Early Hints Sender ──────────────────────────────────────────────────
249
263
  // Used by: early-hints-sender.ts (sendEarlyHints103())
250
264
  // Design doc: design/02-rendering-pipeline.md §"Early Hints (103)"
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The message an unexpected server error shows the client: its own message
3
+ * in dev, a fixed one in production (design/13-security.md §"Errors don't
4
+ * leak"). One definition for every place an error crosses to the client —
5
+ * a Flight error row's digest, a rejected action, an error page's error.
6
+ *
7
+ * Uses isDevMode(), not isDebug(): the text reaches the browser, and
8
+ * TIMBER_DEBUG must never make it leak.
9
+ */
10
+
11
+ import { isDevMode } from './debug.ts';
12
+
13
+ export const PRODUCTION_ERROR_MESSAGE = 'An unexpected error occurred.';
14
+
15
+ export function clientErrorMessage(error: unknown): string {
16
+ if (!isDevMode()) return PRODUCTION_ERROR_MESSAGE;
17
+ return error instanceof Error ? error.message : String(error);
18
+ }