@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
@@ -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
+ }
@@ -49,10 +49,6 @@ export type {
49
49
  // FormData Preprocessing
50
50
  export { coerce, parseFormData } from './form-data.ts';
51
51
 
52
- // Form Flash (no-JS error round-trip)
53
- export { getFormFlash } from './form-flash.ts';
54
- export type { FormFlashData } from './form-flash.ts';
55
-
56
52
  // Revalidation — user-facing revalidation APIs
57
53
  export { revalidatePath, revalidateTag } from './actions.ts';
58
54
 
@@ -168,7 +168,12 @@ export function logProxyError(data: { error: unknown }): void {
168
168
  }
169
169
 
170
170
  /** Log unhandled error in server action. Level: error. */
171
- export function logActionError(data: { method: string; path: string; error: unknown }): void {
171
+ export function logActionError(data: {
172
+ method: string;
173
+ path: string;
174
+ error: unknown;
175
+ errorId?: string;
176
+ }): void {
172
177
  if (isControlFlowSignal(data.error)) return;
173
178
  _logger.error('unhandled server action error', withTraceContext(data));
174
179
  }
@@ -41,6 +41,8 @@ import { enforceRscCacheKey, hardenRscPayloadResponse } from './rsc-cache-key-gu
41
41
  import { stripHeadBody } from './head-response.ts';
42
42
  import { RSC_KEY_HEADERS } from '../shared/rsc-cache-key.ts';
43
43
  import { isDebug } from './debug.ts';
44
+ import type { ReactFormState } from 'react-dom/client';
45
+ import type { ActionErrorForRender } from './als-registry.ts';
44
46
 
45
47
  // ─── Route Match Result ────────────────────────────────────────────────────
46
48
 
@@ -249,11 +251,11 @@ export interface PipelineConfig {
249
251
  * from `req.url` (which re-encodes).
250
252
  *
251
253
  * The `reenter` parameter is the full pipeline function (including proxy,
252
- * ALS, tracing) for the no-JS validation rerender path — the synthetic
253
- * GET flows through the complete pipeline. Its `cookies` argument becomes
254
- * that request's parsed cookies (`runWithRequestContext`'s `cookies`
255
- * option), so the rerender reads the action's post-mutation state without
256
- * the pipeline needing the same `Request` object to reach it.
254
+ * ALS, tracing) for the no-JS action rerender path — the synthetic GET
255
+ * flows through the complete pipeline. Its `cookies` become that request's
256
+ * parsed cookies and its `formState` the action's form state for SSR
257
+ * (`runWithRequestContext`'s options), so the rerender reads both without
258
+ * the pipeline needing the same `Request` object to reach them.
257
259
  *
258
260
  * Moved into the pipeline (TIM-1213) so proxy.ts runs on action POSTs,
259
261
  * matching the design doc contract: "proxy.ts runs on every request,
@@ -262,10 +264,23 @@ export interface PipelineConfig {
262
264
  dispatchAction?: (
263
265
  req: Request,
264
266
  canonicalPath: string,
265
- reenter: (req: Request, cookies: Map<string, string>) => Promise<Response>
267
+ reenter: (req: Request, reentry: PipelineReentry) => Promise<Response>
266
268
  ) => Promise<Response | null>;
267
269
  }
268
270
 
271
+ /**
272
+ * What the no-JS action rerender hands the pipeline when it re-enters it:
273
+ * the action's post-mutation cookies and, when its form used
274
+ * `useActionState`, the form state React decoded from the submission — or,
275
+ * when the action threw, the error to render in place of the page.
276
+ */
277
+ export interface PipelineReentry {
278
+ cookies: Map<string, string>;
279
+ formState?: ReactFormState;
280
+ /** The error a no-JS action threw, rendered where the page would be. */
281
+ actionError?: ActionErrorForRender;
282
+ }
283
+
269
284
  // ─── Pipeline ──────────────────────────────────────────────────────────────
270
285
 
271
286
  /**
@@ -295,7 +310,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
295
310
  // passes a synthetic GET back through the full pipeline (including
296
311
  // proxy, ALS, tracing). The inner closure captures `pipelineFn` by
297
312
  // name; by the time it's invoked, the const binding is initialized.
298
- const pipelineFn = async (req: Request, cookies?: Map<string, string>): Promise<Response> => {
313
+ const pipelineFn = async (req: Request, reentry?: PipelineReentry): Promise<Response> => {
299
314
  const url = new URL(req.url);
300
315
  const method = req.method;
301
316
  const path = url.pathname;
@@ -536,7 +551,11 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
536
551
 
537
552
  return serverTiming === 'detailed' ? runWithTimingCollector(runRequest) : runRequest();
538
553
  };
539
- return runWithRequestContext(req, inRequestContext, { cookies });
554
+ return runWithRequestContext(req, inRequestContext, {
555
+ cookies: reentry?.cookies,
556
+ formState: reentry?.formState,
557
+ actionError: reentry?.actionError,
558
+ });
540
559
  });
541
560
  };
542
561
 
@@ -15,7 +15,11 @@
15
15
  */
16
16
 
17
17
  import type { CoercedParams } from '../shared/param-value.ts';
18
- import { requestContextAls, type RequestContextStore } from './als-registry.ts';
18
+ import {
19
+ requestContextAls,
20
+ type ActionErrorForRender,
21
+ type RequestContextStore,
22
+ } from './als-registry.ts';
19
23
  import { _setGetSearchParamsFn, _setGetSegmentParamsFn } from '../shared/als-slots.ts';
20
24
  import { appVisibleSearch } from '../shared/rsc-cache-key.ts';
21
25
  import {
@@ -25,6 +29,7 @@ import {
25
29
  } from '../shared/slot-params.ts';
26
30
  import { isDebug } from './debug.ts';
27
31
  import { runWithReactCacheScope, type ReactCacheScope } from './react-cache-scope.ts';
32
+ import type { ReactFormState } from 'react-dom/client';
28
33
 
29
34
  // Re-export the ALS for framework-internal consumers that need direct access.
30
35
  export { requestContextAls };
@@ -321,6 +326,16 @@ export interface RequestContextOptions {
321
326
  * (TIM-868).
322
327
  */
323
328
  cookies?: Map<string, string>;
329
+ /**
330
+ * The form state of the no-JS action this render answers. Only the
331
+ * action dispatcher's re-entry passes it; see `RequestContextStore.formState`.
332
+ */
333
+ formState?: ReactFormState;
334
+ /**
335
+ * The error of the no-JS action this render answers. Only the action
336
+ * dispatcher's re-entry passes it; see `RequestContextStore.actionError`.
337
+ */
338
+ actionError?: ActionErrorForRender;
324
339
  }
325
340
 
326
341
  /**
@@ -342,7 +357,7 @@ export interface RequestContextOptions {
342
357
  export function runWithRequestContext<T>(
343
358
  req: Request,
344
359
  fn: () => T,
345
- { reactCacheScope, cookies }: RequestContextOptions = {}
360
+ { reactCacheScope, cookies, formState, actionError }: RequestContextOptions = {}
346
361
  ): T {
347
362
  const originalCopy = new Headers(req.headers);
348
363
  const appVisible = appVisibleSearch(new URL(req.url));
@@ -366,6 +381,8 @@ export function runWithRequestContext<T>(
366
381
  cookieJar: new Map(),
367
382
  flushed: false,
368
383
  mutableContext: false,
384
+ formState,
385
+ actionError,
369
386
  };
370
387
  return requestContextAls.run(store, () => runWithReactCacheScope(fn, reactCacheScope));
371
388
  }
@@ -463,3 +480,23 @@ function freezeHeaders(source: Headers): Headers {
463
480
  },
464
481
  });
465
482
  }
483
+
484
+ /**
485
+ * The form state of the no-JS action this render answers, or `null`. Read by
486
+ * the SSR renderer only (`rsc-entry/ssr-renderer.ts`), which passes it to
487
+ * Fizz and embeds it for `hydrateRoot`. Framework-internal: not exported
488
+ * from `@timber-js/app/server`.
489
+ */
490
+ export function getFormStateForSsr(): ReactFormState | null {
491
+ return requestContextAls.getStore()?.formState ?? null;
492
+ }
493
+
494
+ /**
495
+ * The error of the no-JS action this render answers, or `null`. Read by the
496
+ * route element builder, which renders it in place of the page, and by the
497
+ * Flight `onError`, which gives it the ID the action logged it with.
498
+ * Framework-internal: not exported from `@timber-js/app/server`.
499
+ */
500
+ export function getActionErrorForRender(): ActionErrorForRender | null {
501
+ return requestContextAls.getStore()?.actionError ?? null;
502
+ }
@@ -50,6 +50,7 @@ import { setFirstDeniedSegmentIndex } from './deny-boundary.ts';
50
50
  import { loadModule, type ManifestLoader } from './safe-load.ts';
51
51
  import { loadFallbackModule } from './pipeline-helpers.ts';
52
52
  import { replaceOutermostSegmentOutlet } from './route-element-helpers.ts';
53
+ import { getActionErrorForRender } from './request-context.ts';
53
54
 
54
55
  /**
55
56
  * The `segments` array handed to a layout's `SegmentProvider` — the value
@@ -264,8 +265,18 @@ export async function buildRouteElement(
264
265
  // so they go through createElement normally — no wrapper needed.
265
266
  const leafIndex = segments.length - 1;
266
267
  const leafDenyPages = denyPageChains.get(leafIndex);
268
+ // A no-JS action that threw is answered by this route with the error in
269
+ // place of its page (design/08 §"The Basic Wire-Up"). The layouts and
270
+ // access gates above still render, so the nearest error page answers and
271
+ // a denied segment's layout never wraps it — the render-error path.
272
+ const actionError = getActionErrorForRender();
267
273
  let element: React.ReactElement;
268
- if (isClientReference(PageComponent)) {
274
+ if (actionError) {
275
+ const ActionError = (): never => {
276
+ throw actionError.error;
277
+ };
278
+ element = h(ActionError, {});
279
+ } else if (isClientReference(PageComponent)) {
269
280
  element = h(PageComponent, {});
270
281
  } else if (leafDenyPages && leafDenyPages.length > 0) {
271
282
  element = h(PageDenyBoundary, {
@@ -8,7 +8,8 @@
8
8
  * `createPipeline` calls it after proxy.ts runs (TIM-1213), so proxy.ts
9
9
  * sees action POSTs — "proxy.ts runs on every request, no exclusions." It
10
10
  * encapsulates action detection, the route-type check, action handler
11
- * dispatch, and the no-JS validation rerender path.
11
+ * dispatch, and the answer to a no-JS action that did not redirect: the
12
+ * page rerendered with React's form state, or the error page.
12
13
  *
13
14
  * The CSRF gate is not here: it runs in `createPipeline` before proxy.ts
14
15
  * (`csrfGate` in pipeline-helpers.ts). The action handler repeats the
@@ -19,12 +20,10 @@
19
20
  * auth/validation belongs in `createActionClient({ middleware })`.
20
21
  */
21
22
 
22
- import type { FormRerender } from '../action-handler.ts';
23
23
  import { handleActionRequest, isActionRequest } from '../action-handler.ts';
24
24
  import type { BodyLimitsConfig } from '../body-limits.ts';
25
25
  import type { CsrfConfig } from '../csrf.ts';
26
- import { runWithFormFlash } from '../form-flash.ts';
27
- import type { RouteMatcher } from '../pipeline.ts';
26
+ import type { PipelineReentry, RouteMatcher } from '../pipeline.ts';
28
27
  import { isApiRouteChain } from '../route-matcher.ts';
29
28
  import type { SensitiveFieldsOption } from '../sensitive-fields.ts';
30
29
  import type { RevalidateRenderer } from '../actions.ts';
@@ -69,8 +68,8 @@ export interface ActionDispatcherDeps {
69
68
  *
70
69
  * Called after proxy.ts runs (inside the pipeline) for every request.
71
70
  * Checks if the request is a server action POST, handles the route-type
72
- * check (TIM-870), dispatches to the action handler, and handles the
73
- * no-JS validation rerender path.
71
+ * check (TIM-870), dispatches to the action handler, and answers a no-JS
72
+ * action that did not redirect.
74
73
  *
75
74
  * Returns `Response` if the request was handled as an action, `null` to
76
75
  * fall through to normal routing.
@@ -80,12 +79,12 @@ export function buildActionDispatcher(
80
79
  ): (
81
80
  req: Request,
82
81
  canonicalPath: string,
83
- reenter: (req: Request, cookies: Map<string, string>) => Promise<Response>
82
+ reenter: (req: Request, reentry: PipelineReentry) => Promise<Response>
84
83
  ) => Promise<Response | null> {
85
84
  return async (
86
85
  req: Request,
87
86
  canonicalPath: string,
88
- reenter: (req: Request, cookies: Map<string, string>) => Promise<Response>
87
+ reenter: (req: Request, reentry: PipelineReentry) => Promise<Response>
89
88
  ): Promise<Response | null> => {
90
89
  if (!isActionRequest(req)) return null;
91
90
 
@@ -109,34 +108,41 @@ export function buildActionDispatcher(
109
108
  revalidateRenderer: deps.buildRevalidateRenderer(req),
110
109
  });
111
110
 
112
- if (actionResponse) {
113
- // No-JS validation rerender
114
- if ('rerender' in actionResponse) {
115
- const formRerender = actionResponse as FormRerender;
116
- // Build a synthetic GET request for the rerender pipeline:
117
- // - Same URL (so route matching lands on the same page)
118
- // - Cookie header DROPPED entirely. The post-action RYW state
119
- // is handed to `reenter` as a parsed `Map<string, string>`,
120
- // which becomes the rerender request context's cookies.
121
- // - Method GET because the rerender is conceptually a page render.
122
- const rerenderHeaders = new Headers(req.headers);
123
- rerenderHeaders.delete('cookie');
124
- const rerenderReq = new Request(req.url, {
125
- method: 'GET',
126
- headers: rerenderHeaders,
127
- });
128
- const response = await runWithFormFlash(formRerender.rerender, () =>
129
- reenter(rerenderReq, formRerender.cookies)
130
- );
131
- // Apply Set-Cookie headers snapshotted from the action's ALS scope.
132
- for (const value of formRerender.setCookieHeaders) {
133
- response.headers.append('Set-Cookie', value);
134
- }
135
- return response;
136
- }
111
+ if (actionResponse === null || actionResponse instanceof Response) {
137
112
  return actionResponse;
138
113
  }
139
114
 
140
- return null;
115
+ // Both answers re-enter the pipeline for a page render of the same URL,
116
+ // as a GET — the rerender with the action's form state, the throw with
117
+ // its error, which the route renders in place of its page so the
118
+ // nearest error page answers (SSR has no error boundaries, so that page
119
+ // comes from the shell-failure path):
120
+ // - Same URL (so route matching lands on the same page)
121
+ // - Cookie header DROPPED entirely. The post-action RYW state is
122
+ // handed over as a parsed `Map<string, string>` and becomes the
123
+ // render's request-context cookies, so the page reads what the
124
+ // action wrote, not what the request carried (TIM-868).
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.
128
+ const rerenderHeaders = new Headers(req.headers);
129
+ rerenderHeaders.delete('cookie');
130
+ const rerenderReq = new Request(req.url, {
131
+ method: 'GET',
132
+ headers: rerenderHeaders,
133
+ signal: req.signal,
134
+ });
135
+ const { cookies } = actionResponse;
136
+ const response = await reenter(
137
+ rerenderReq,
138
+ actionResponse.kind === 'error'
139
+ ? { cookies, actionError: { error: actionResponse.error, errorId: actionResponse.errorId } }
140
+ : { cookies, formState: actionResponse.formState }
141
+ );
142
+ // Apply Set-Cookie headers snapshotted from the action's ALS scope.
143
+ for (const value of actionResponse.setCookieHeaders) {
144
+ response.headers.append('Set-Cookie', value);
145
+ }
146
+ return response;
141
147
  };
142
148
  }
@@ -29,6 +29,7 @@ import { getCookiesForSsr } from '../cookie-context.ts';
29
29
  import { callSsr } from './ssr-bridge.ts';
30
30
  import { teeWithErrorPropagation } from '../stream-utils.ts';
31
31
  import { isDevMode } from '../debug.ts';
32
+ import { clientErrorMessage } from '../client-error-message.ts';
32
33
  import { ErrorReconstituter } from '../../client/error-reconstituter.tsx';
33
34
  import type { SerializableError } from '../../client/error-reconstituter.tsx';
34
35
  import type { ManifestLoader } from '../safe-load.ts';
@@ -148,7 +149,7 @@ function toSerializableError(error: unknown): SerializableError {
148
149
  const err = error instanceof Error ? error : new Error(String(error));
149
150
  const devMode = isDevMode();
150
151
  return {
151
- message: devMode ? err.message : 'An unexpected error occurred.',
152
+ message: clientErrorMessage(err),
152
153
  name: devMode ? err.name : 'Error',
153
154
  ...(devMode && err.stack ? { stack: err.stack } : {}),
154
155
  };
@@ -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
@@ -38,6 +38,8 @@ import type { DenyFallbackRenderer } from './deny-fallback.ts';
38
38
  import { isRscRequest } from '../../shared/rsc-media-type.ts';
39
39
  import { buildRscPayloadResponse } from './rsc-payload.ts';
40
40
  import { renderRscStream } from './rsc-stream.ts';
41
+ import { encodeFormState } from '../form-state-flight.ts';
42
+ import { getFormStateForSsr } from '../request-context.ts';
41
43
  import { renderSsrResponse } from './ssr-renderer.ts';
42
44
 
43
45
  /**
@@ -51,6 +53,8 @@ export interface RenderRouteDeps {
51
53
  clientSegmentCache: boolean;
52
54
  renderDenyFallback: DenyFallbackRenderer;
53
55
  buildManifest: BuildManifest;
56
+ /** The resolved `renderTimeoutMs`: bounds encoding a no-JS form state. */
57
+ renderTimeoutMs: number;
54
58
  globalError?: { load: () => Promise<unknown>; filePath: string };
55
59
  }
56
60
 
@@ -210,6 +214,17 @@ export async function renderRoute(
210
214
  );
211
215
  }
212
216
 
217
+ // The form state of the no-JS action this page answers, if any, as the
218
+ // Flight bytes both SSR and the browser decode (design/08 §"No-JS Result
219
+ // Round-Trip"). Encoded while the RSC render above is already running,
220
+ // under the render timeout and the request's abort signal. A promise in
221
+ // the result holds the response until it settles: the page must carry the
222
+ // whole state, as Fizz would wait for it anyway.
223
+ const pendingFormState = getFormStateForSsr();
224
+ const formState = pendingFormState
225
+ ? await encodeFormState(pendingFormState, deps.renderTimeoutMs, req.signal)
226
+ : null;
227
+
213
228
  // Pipe through SSR for HTML rendering with streaming Suspense support.
214
229
  return renderSsrResponse({
215
230
  req,
@@ -225,5 +240,6 @@ export async function renderRoute(
225
240
  deferSuspenseFor,
226
241
  globalError,
227
242
  slotSkipInfo,
243
+ formState,
228
244
  });
229
245
  }