@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.
- package/agent-skill.md +10 -5
- package/dist/_chunks/{actions-Rjk4htmA.js → actions-CEootpB1.js} +49 -12
- package/dist/_chunks/actions-CEootpB1.js.map +1 -0
- package/dist/_chunks/{als-registry-DaxkVjt5.js → als-registry-BZqHCtq-.js} +2 -4
- package/dist/_chunks/als-registry-BZqHCtq-.js.map +1 -0
- package/dist/_chunks/{cache-api-DGdYfNJn.js → cache-api-LA3sBpUS.js} +5 -5
- package/dist/_chunks/{cache-api-DGdYfNJn.js.map → cache-api-LA3sBpUS.js.map} +1 -1
- package/dist/_chunks/{chains-DGX9zmg9.js → chains-BfoPFraI.js} +2 -2
- package/dist/_chunks/{chains-DGX9zmg9.js.map → chains-BfoPFraI.js.map} +1 -1
- package/dist/_chunks/{classify-QwG5rxKI.js → classify-BT66U83D.js} +2 -2
- package/dist/_chunks/{classify-QwG5rxKI.js.map → classify-BT66U83D.js.map} +1 -1
- package/dist/_chunks/{cli-check-ajNY3B2e.js → cli-check-BfQ54-UJ.js} +3 -3
- package/dist/_chunks/{cli-check-ajNY3B2e.js.map → cli-check-BfQ54-UJ.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js → cli-schema-sync-czh2dsLs.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-D2eI8jEg.js.map → cli-schema-sync-czh2dsLs.js.map} +1 -1
- package/dist/_chunks/{client-dep-entries-2HCF09no.js → client-dep-entries-CQwpb8dI.js} +2 -2
- package/dist/_chunks/{client-dep-entries-2HCF09no.js.map → client-dep-entries-CQwpb8dI.js.map} +1 -1
- package/dist/_chunks/{convention-lint-DLmhGsRS.js → convention-lint-BEVW4EID.js} +3 -3
- package/dist/_chunks/{convention-lint-DLmhGsRS.js.map → convention-lint-BEVW4EID.js.map} +1 -1
- package/dist/_chunks/{dev-server-TFpEwm3H.js → dev-server-FKxptbnI.js} +2 -2
- package/dist/_chunks/{dev-server-TFpEwm3H.js.map → dev-server-FKxptbnI.js.map} +1 -1
- package/dist/_chunks/{error-boundary-DsNScGRM.js → error-boundary-9g_Lb2na.js} +3 -3
- package/dist/_chunks/{error-boundary-DsNScGRM.js.map → error-boundary-9g_Lb2na.js.map} +1 -1
- package/dist/_chunks/{json-lossy-check-CVuRs2hG.js → json-lossy-check-C8zBY2uZ.js} +2 -2
- package/dist/_chunks/{json-lossy-check-CVuRs2hG.js.map → json-lossy-check-C8zBY2uZ.js.map} +1 -1
- package/dist/_chunks/{live-graph-cNuWMYQI.js → live-graph-C_4v-fHv.js} +4 -4
- package/dist/_chunks/{live-graph-cNuWMYQI.js.map → live-graph-C_4v-fHv.js.map} +1 -1
- package/dist/_chunks/{logger-BP0LN6vP.js → logger-CbLdcy-W.js} +2 -2
- package/dist/_chunks/{logger-BP0LN6vP.js.map → logger-CbLdcy-W.js.map} +1 -1
- package/dist/_chunks/{poison-scan-CfQ3unZR.js → poison-scan-C92liMAr.js} +2 -2
- package/dist/_chunks/{poison-scan-CfQ3unZR.js.map → poison-scan-C92liMAr.js.map} +1 -1
- package/dist/_chunks/{scanner-DmqdxzbW.js → scanner-CQt12vE2.js} +2 -2
- package/dist/_chunks/{scanner-DmqdxzbW.js.map → scanner-CQt12vE2.js.map} +1 -1
- package/dist/_chunks/{sizeof-BM1409x2.js → sizeof-QPE5nd3u.js} +2 -2
- package/dist/_chunks/{sizeof-BM1409x2.js.map → sizeof-QPE5nd3u.js.map} +1 -1
- package/dist/_chunks/{walkers-Czu2jXFq.js → walkers-DAT4avhZ.js} +3 -3
- package/dist/_chunks/{walkers-Czu2jXFq.js.map → walkers-DAT4avhZ.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/analyze/crawl-entry.js +3 -3
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cache/index.js +2 -2
- package/dist/cache/stores/memory.js +1 -1
- package/dist/cdn/workers-cache-purge.js +1 -1
- package/dist/cli.js +3 -3
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/action-queue.d.ts +1 -0
- package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
- package/dist/client/browser-entry/form-state.d.ts +22 -0
- package/dist/client/browser-entry/form-state.d.ts.map +1 -0
- package/dist/client/browser-entry/hydrate.d.ts +9 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/index.d.ts +2 -0
- package/dist/client/browser-entry/index.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/form.d.ts +17 -64
- package/dist/client/form.d.ts.map +1 -1
- package/dist/client/index.d.ts +3 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +111 -51
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +33 -32
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-api.d.ts +25 -63
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-transition.d.ts +11 -2
- package/dist/client/navigation-transition.d.ts.map +1 -1
- package/dist/client/router-effects.d.ts +7 -3
- package/dist/client/router-effects.d.ts.map +1 -1
- package/dist/client/router-lifecycle.d.ts +17 -11
- package/dist/client/router-lifecycle.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts +3 -2
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +24 -44
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/use-form-field.d.ts +39 -0
- package/dist/client/use-form-field.d.ts.map +1 -0
- package/dist/config-types.d.ts +2 -1
- package/dist/config-types.d.ts.map +1 -1
- package/dist/index.js +6 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/server/action-client.d.ts +20 -7
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts +27 -8
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +22 -2
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/client-error-message.d.ts +12 -0
- package/dist/server/client-error-message.d.ts.map +1 -0
- package/dist/server/flight-scripts.d.ts +9 -0
- package/dist/server/flight-scripts.d.ts.map +1 -1
- package/dist/server/form-data.d.ts +13 -4
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/form-state-flight.d.ts +32 -0
- package/dist/server/form-state-flight.d.ts.map +1 -0
- package/dist/server/index.d.ts +0 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +41 -60
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +10 -6
- package/dist/server/internal.js.map +1 -1
- package/dist/server/logger.d.ts +1 -0
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +20 -6
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +27 -2
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/action-dispatcher.d.ts +6 -5
- package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts +2 -0
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +6 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +10 -0
- package/dist/server/ssr-bridge-types.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-form-state.d.ts +30 -0
- package/dist/server/ssr-form-state.d.ts.map +1 -0
- package/dist/server/ssr-render.d.ts +3 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/shared/form-state-flight.d.ts +36 -0
- package/dist/shared/form-state-flight.d.ts.map +1 -0
- package/docs/api/31-api-client.mdx +31 -3
- package/docs/api/34-api-config.mdx +1 -1
- package/docs/learn/08-forms-and-actions.mdx +116 -36
- package/package.json +1 -1
- package/src/client/browser-entry/action-dispatch.ts +104 -24
- package/src/client/browser-entry/action-queue.ts +1 -1
- package/src/client/browser-entry/form-state.ts +48 -0
- package/src/client/browser-entry/hydrate.ts +10 -1
- package/src/client/browser-entry/index.ts +25 -7
- package/src/client/browser-entry/router-init.ts +7 -20
- package/src/client/form.tsx +33 -98
- package/src/client/index.ts +3 -2
- package/src/client/navigation-api.ts +47 -173
- package/src/client/navigation-transition.ts +15 -4
- package/src/client/router-effects.ts +8 -4
- package/src/client/router-lifecycle.ts +40 -20
- package/src/client/router-pipeline.ts +10 -12
- package/src/client/router-types.ts +24 -48
- package/src/client/router.ts +49 -56
- package/src/client/use-form-field.ts +132 -0
- package/src/config-types.ts +2 -1
- package/src/plugins/shims.ts +0 -2
- package/src/rsc-runtime/rsc.ts +3 -0
- package/src/rsc-runtime/vendor-types.d.ts +14 -0
- package/src/server/action-client.ts +77 -64
- package/src/server/action-handler.ts +133 -95
- package/src/server/als-registry.ts +23 -9
- package/src/server/client-error-message.ts +18 -0
- package/src/server/flight-scripts.ts +13 -0
- package/src/server/form-data.ts +62 -10
- package/src/server/form-state-flight.ts +67 -0
- package/src/server/index.ts +0 -4
- package/src/server/logger.ts +6 -1
- package/src/server/pipeline.ts +27 -8
- package/src/server/request-context.ts +39 -2
- package/src/server/route-element-builder.ts +12 -1
- package/src/server/rsc-entry/action-dispatcher.ts +40 -34
- package/src/server/rsc-entry/error-renderer.ts +2 -1
- package/src/server/rsc-entry/index.ts +1 -0
- package/src/server/rsc-entry/render-route.ts +16 -0
- package/src/server/rsc-entry/rsc-stream.ts +20 -11
- package/src/server/rsc-entry/ssr-renderer.ts +11 -0
- package/src/server/ssr-bridge-types.ts +10 -0
- package/src/server/ssr-entry.ts +16 -2
- package/src/server/ssr-form-state.ts +58 -0
- package/src/server/ssr-render.ts +5 -0
- package/src/shared/form-state-flight.ts +74 -0
- package/dist/_chunks/actions-Rjk4htmA.js.map +0 -1
- package/dist/_chunks/als-registry-DaxkVjt5.js.map +0 -1
- package/dist/server/form-flash.d.ts +0 -78
- package/dist/server/form-flash.d.ts.map +0 -1
- 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
|
+
}
|
package/src/server/form-data.ts
CHANGED
|
@@ -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
|
-
* - **
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
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
|
-
*
|
|
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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
|
package/src/server/logger.ts
CHANGED
|
@@ -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: {
|
|
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
|
}
|
package/src/server/pipeline.ts
CHANGED
|
@@ -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
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* the pipeline needing the same `Request` object to reach
|
|
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,
|
|
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,
|
|
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, {
|
|
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 {
|
|
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 (
|
|
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
|
|
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 {
|
|
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
|
|
73
|
-
*
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
}
|