@timber-js/app 0.2.0-alpha.212 → 0.2.0-alpha.213
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/{actions-CCdnVtWm.js → actions-CEootpB1.js} +42 -7
- package/dist/_chunks/{actions-CCdnVtWm.js.map → actions-CEootpB1.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/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/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +90 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +8 -7
- package/dist/client/internal.js.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-pipeline.d.ts +3 -1
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +12 -1
- 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.map +1 -1
- package/dist/server/action-client.d.ts +11 -2
- package/dist/server/action-client.d.ts.map +1 -1
- 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.js +37 -23
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +1 -1
- package/dist/server/rsc-entry/action-dispatcher.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/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 +7 -5
- 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/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 +22 -0
- package/docs/api/34-api-config.mdx +1 -1
- package/docs/learn/08-forms-and-actions.mdx +109 -22
- package/package.json +1 -1
- package/src/client/browser-entry/action-dispatch.ts +34 -10
- 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 +9 -18
- package/src/client/browser-entry/index.ts +25 -7
- package/src/client/browser-entry/router-init.ts +3 -2
- package/src/client/index.ts +1 -0
- package/src/client/navigation-transition.ts +13 -2
- package/src/client/router-effects.ts +8 -4
- package/src/client/router-pipeline.ts +7 -3
- package/src/client/router-types.ts +12 -1
- package/src/client/router.ts +16 -3
- package/src/client/use-form-field.ts +132 -0
- package/src/config-types.ts +2 -1
- package/src/server/action-client.ts +68 -53
- 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/rsc-entry/action-dispatcher.ts +3 -0
- package/src/server/rsc-entry/index.ts +1 -0
- package/src/server/rsc-entry/render-route.ts +16 -0
- package/src/server/rsc-entry/ssr-renderer.ts +12 -14
- package/src/server/ssr-bridge-types.ts +7 -6
- package/src/server/ssr-entry.ts +16 -3
- package/src/server/ssr-form-state.ts +58 -0
- package/src/shared/form-state-flight.ts +74 -0
- package/dist/server/form-state-embed.d.ts +0 -32
- package/dist/server/form-state-embed.d.ts.map +0 -1
- package/src/server/form-state-embed.ts +0 -63
|
@@ -84,7 +84,9 @@ export interface NavigationPipeline {
|
|
|
84
84
|
types: readonly string[],
|
|
85
85
|
perform: () => Promise<NavigationPayload>,
|
|
86
86
|
/** See `NavigationOptions.onCommit`. */
|
|
87
|
-
onCommit?: (outcome: CommitOutcome) => void
|
|
87
|
+
onCommit?: (outcome: CommitOutcome) => void,
|
|
88
|
+
/** See `NavigationOptions.onHandOff`. */
|
|
89
|
+
onHandOff?: () => void
|
|
88
90
|
) => Promise<void>;
|
|
89
91
|
}
|
|
90
92
|
|
|
@@ -186,7 +188,8 @@ export function createNavigationPipeline({
|
|
|
186
188
|
owner: RenderOwner,
|
|
187
189
|
types: readonly string[],
|
|
188
190
|
perform: () => Promise<NavigationPayload>,
|
|
189
|
-
onCommit?: (outcome: CommitOutcome) => void
|
|
191
|
+
onCommit?: (outcome: CommitOutcome) => void,
|
|
192
|
+
onHandOff?: () => void
|
|
190
193
|
): Promise<void> {
|
|
191
194
|
// Record that THIS navigation's payload has reached React, at the moment
|
|
192
195
|
// the tree is built and handed back for `navigateTransition` to give to
|
|
@@ -260,7 +263,8 @@ export function createNavigationPipeline({
|
|
|
260
263
|
commit: commitAndForget(result.commit),
|
|
261
264
|
};
|
|
262
265
|
},
|
|
263
|
-
onCommit
|
|
266
|
+
onCommit,
|
|
267
|
+
onHandOff
|
|
264
268
|
);
|
|
265
269
|
}
|
|
266
270
|
|
|
@@ -89,6 +89,15 @@ export interface NavigationOptions {
|
|
|
89
89
|
* which of the three it was.
|
|
90
90
|
*/
|
|
91
91
|
onCommit?: (outcome: CommitOutcome) => void;
|
|
92
|
+
/**
|
|
93
|
+
* @internal Runs once, synchronously after this navigation's tree is handed
|
|
94
|
+
* to React (its transition scheduled) and before React can commit it. Not
|
|
95
|
+
* at all if the navigation is superseded or fails first; the returned
|
|
96
|
+
* promise settles then. A server action that redirects settles its
|
|
97
|
+
* `useActionState` here, so React commits the action's result and the
|
|
98
|
+
* destination together (TIM-1573).
|
|
99
|
+
*/
|
|
100
|
+
onHandOff?: () => void;
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
/**
|
|
@@ -155,7 +164,9 @@ export interface RouterDeps {
|
|
|
155
164
|
) => unknown
|
|
156
165
|
) => Promise<TransitionResult<unknown>>,
|
|
157
166
|
/** See `NavigationOptions.onCommit`; forwarded to `navigateTransition`. */
|
|
158
|
-
onCommit?: (outcome: CommitOutcome) => void
|
|
167
|
+
onCommit?: (outcome: CommitOutcome) => void,
|
|
168
|
+
/** See `NavigationOptions.onHandOff`; forwarded to `navigateTransition`. */
|
|
169
|
+
onHandOff?: () => void
|
|
159
170
|
) => Promise<void>;
|
|
160
171
|
|
|
161
172
|
/**
|
package/src/client/router.ts
CHANGED
|
@@ -102,7 +102,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
102
102
|
currentOwner,
|
|
103
103
|
leaveSpaIfOwned,
|
|
104
104
|
// Hoisted — `navigate` is a function declaration below.
|
|
105
|
-
navigate: (url, types) =>
|
|
105
|
+
navigate: (url, types, onHandOff) =>
|
|
106
|
+
navigate(url, { replace: true, _renderTypes: types, onHandOff }),
|
|
106
107
|
});
|
|
107
108
|
|
|
108
109
|
async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
|
|
@@ -164,7 +165,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
164
165
|
signal: owner.fetchAbort.signal,
|
|
165
166
|
departingUrl,
|
|
166
167
|
}),
|
|
167
|
-
options.onCommit
|
|
168
|
+
options.onCommit,
|
|
169
|
+
options.onHandOff
|
|
168
170
|
);
|
|
169
171
|
|
|
170
172
|
// Scroll-to-top on forward navigation, scroll to the #fragment target
|
|
@@ -180,7 +182,18 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
180
182
|
// load of the destination, fragment included (TIM-1234). Reloading
|
|
181
183
|
// instead would rebuild the page they were *leaving* and discard
|
|
182
184
|
// the click (TIM-1275).
|
|
183
|
-
if (
|
|
185
|
+
if (
|
|
186
|
+
await recoverFromNavigationError(
|
|
187
|
+
error,
|
|
188
|
+
owner,
|
|
189
|
+
url,
|
|
190
|
+
departingUrl,
|
|
191
|
+
types,
|
|
192
|
+
options.onHandOff
|
|
193
|
+
)
|
|
194
|
+
) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
184
197
|
throw error;
|
|
185
198
|
}
|
|
186
199
|
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFormField — state for a form field that needs JavaScript (a combobox, a
|
|
3
|
+
* chip list, a row list), made to behave like a native uncontrolled input.
|
|
4
|
+
*
|
|
5
|
+
* React resets a form after its action settles, so a native field with a
|
|
6
|
+
* `defaultValue` lands on whatever default the page now renders: the action's
|
|
7
|
+
* `submittedValues`, or fresh page data after a redirect. State held in
|
|
8
|
+
* `useState` misses that reset and keeps showing the pre-submit edit. This
|
|
9
|
+
* hook shows `defaultValue` until the user edits, and drops the edit when the
|
|
10
|
+
* form resets, so it lands where the native fields do.
|
|
11
|
+
*
|
|
12
|
+
* See design/08-forms-and-actions.md §"The Form Model".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
useCallback,
|
|
17
|
+
useEffect,
|
|
18
|
+
useLayoutEffect,
|
|
19
|
+
useRef,
|
|
20
|
+
useState,
|
|
21
|
+
type Dispatch,
|
|
22
|
+
type RefCallback,
|
|
23
|
+
type SetStateAction,
|
|
24
|
+
} from 'react';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* State for a field that follows its form's reset.
|
|
28
|
+
*
|
|
29
|
+
* Returns `[value, setValue, ref]`. Attach `ref` to any element inside the
|
|
30
|
+
* `<form>`, and render the value into the form data yourself (usually a
|
|
31
|
+
* hidden input). `value` is `defaultValue` until `setValue` is called, and is
|
|
32
|
+
* `defaultValue` again after the form resets, unless the form cancels its
|
|
33
|
+
* `reset` event. An edit dispatches a bubbling `change` event from the `ref`
|
|
34
|
+
* element after it commits, so a native `change` listener on the form sees it
|
|
35
|
+
* as it sees a native input's (React's synthetic `onChange` reports only
|
|
36
|
+
* inputs, selects and textareas). A reset and a new default fire none, as
|
|
37
|
+
* they fire none for a native input.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* // draft: the form's defaults, read from result?.submittedValues ?? pageData
|
|
42
|
+
* const [tags, setTags, ref] = useFormField(draft.tags);
|
|
43
|
+
* <div ref={ref}>
|
|
44
|
+
* <input type="hidden" name="tags" value={tags.join(',')} />
|
|
45
|
+
* <TagPicker value={tags} onChange={setTags} />
|
|
46
|
+
* </div>
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function useFormField<T>(
|
|
50
|
+
defaultValue: T
|
|
51
|
+
): [value: T, setValue: Dispatch<SetStateAction<T>>, ref: RefCallback<HTMLElement>] {
|
|
52
|
+
// `null` means "not edited": the field shows its default. A box, so an edit
|
|
53
|
+
// back to a value equal to the default still counts as an edit.
|
|
54
|
+
const [edit, setEdit] = useState<{ value: T } | null>(null);
|
|
55
|
+
const value = edit ? edit.value : defaultValue;
|
|
56
|
+
|
|
57
|
+
// The latest default, for an updater called from an old closure. Written
|
|
58
|
+
// after commit, never during render.
|
|
59
|
+
const defaultRef = useRef(defaultValue);
|
|
60
|
+
useLayoutEffect(() => {
|
|
61
|
+
defaultRef.current = defaultValue;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Resets the form has fired whose outcome this field has not applied yet.
|
|
65
|
+
// A reset's outcome is known only once its dispatch is over: our listener
|
|
66
|
+
// runs on the form before React's delegated `onReset`, which may cancel
|
|
67
|
+
// it. `takeResets` applies every reset whose dispatch has ended, in order:
|
|
68
|
+
// if any of them was not cancelled, the field was reset. Both the deferred
|
|
69
|
+
// clear and `setValue` call it, so writes follow a native input's order:
|
|
70
|
+
// - after `form.reset()` returns, the reset has happened and a write lands
|
|
71
|
+
// on top of it (the write takes the reset, and the clear finds nothing);
|
|
72
|
+
// - during the reset's dispatch (an `onReset` handler), the reset is still
|
|
73
|
+
// pending: the write applies to the current value, and the clear then
|
|
74
|
+
// erases it unless the reset is cancelled, as a reset overwrites a
|
|
75
|
+
// native input written in its handler.
|
|
76
|
+
// A list, not one slot: `reset(); reset()` where only the second is
|
|
77
|
+
// cancelled still resets, as it does the native fields.
|
|
78
|
+
const pendingResets = useRef<Event[]>([]);
|
|
79
|
+
const takeResets = useCallback((): boolean => {
|
|
80
|
+
// eventPhase is NONE once dispatch is over.
|
|
81
|
+
const over = pendingResets.current.filter((e) => e.eventPhase === Event.NONE);
|
|
82
|
+
if (over.length === 0) return false;
|
|
83
|
+
pendingResets.current = pendingResets.current.filter((e) => !over.includes(e));
|
|
84
|
+
return over.some((e) => !e.defaultPrevented);
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const setValue = useCallback<Dispatch<SetStateAction<T>>>(
|
|
88
|
+
(next) => {
|
|
89
|
+
const wasReset = takeResets();
|
|
90
|
+
setEdit((latest) => {
|
|
91
|
+
const current = wasReset ? null : latest;
|
|
92
|
+
const base = current ? current.value : defaultRef.current;
|
|
93
|
+
const value = next instanceof Function ? next(base) : next;
|
|
94
|
+
// Unchanged: keep the box, so nothing re-renders and no `change`
|
|
95
|
+
// fires, as a native input fires none for a value it already has.
|
|
96
|
+
return Object.is(value, base) ? current : { value };
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
[takeResets]
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const element = useRef<HTMLElement | null>(null);
|
|
103
|
+
const ref = useCallback<RefCallback<HTMLElement>>(
|
|
104
|
+
(el) => {
|
|
105
|
+
element.current = el;
|
|
106
|
+
const form = el?.closest('form');
|
|
107
|
+
if (!form) return;
|
|
108
|
+
const onReset = (event: Event) => {
|
|
109
|
+
pendingResets.current.push(event);
|
|
110
|
+
// Dispatch is over by the time a microtask runs.
|
|
111
|
+
queueMicrotask(() => {
|
|
112
|
+
if (takeResets()) setEdit(null);
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
form.addEventListener('reset', onReset);
|
|
116
|
+
return () => {
|
|
117
|
+
element.current = null;
|
|
118
|
+
pendingResets.current = [];
|
|
119
|
+
form.removeEventListener('reset', onReset);
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
[takeResets]
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Every `setValue` stores a new box, and nothing else does except a reset
|
|
126
|
+
// (which stores null), so a new non-null box is exactly "the user edited".
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (edit) element.current?.dispatchEvent(new Event('change', { bubbles: true }));
|
|
129
|
+
}, [edit]);
|
|
130
|
+
|
|
131
|
+
return [value, setValue, ref];
|
|
132
|
+
}
|
package/src/config-types.ts
CHANGED
|
@@ -56,7 +56,8 @@ export interface TimberUserConfig {
|
|
|
56
56
|
forms?: {
|
|
57
57
|
/**
|
|
58
58
|
* Strip sensitive fields (passwords, tokens, CVV, SSN, etc.) from the
|
|
59
|
-
* `submittedValues` echoed back
|
|
59
|
+
* `submittedValues` echoed back when an action fails (a validation
|
|
60
|
+
* failure, or a `serverError` from a form submission).
|
|
60
61
|
*
|
|
61
62
|
* Applied to both the with-JS (`createActionClient`) and no-JS (form POST
|
|
62
63
|
* re-render) paths. Safe-by-default: the built-in deny-list is active
|
|
@@ -118,7 +118,11 @@ export type ActionResult<TData = unknown> =
|
|
|
118
118
|
data?: never;
|
|
119
119
|
validationErrors?: never;
|
|
120
120
|
serverError: { code: string; data?: Record<string, unknown> };
|
|
121
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The submitted form, when the input was FormData — for repopulating
|
|
123
|
+
* form fields, which React resets after the action (TIM-1573).
|
|
124
|
+
*/
|
|
125
|
+
submittedValues?: Record<string, unknown>;
|
|
122
126
|
};
|
|
123
127
|
|
|
124
128
|
/** Context passed to the action body. */
|
|
@@ -288,7 +292,9 @@ function extractStandardSchemaErrors(issues: ReadonlyArray<StandardSchemaIssue>)
|
|
|
288
292
|
* `createActionClient` actions only. A raw `'use server'` function that
|
|
289
293
|
* throws is rejected on the client instead (action-handler.ts).
|
|
290
294
|
*/
|
|
291
|
-
export function handleActionError(error: unknown):
|
|
295
|
+
export function handleActionError(error: unknown): {
|
|
296
|
+
serverError: { code: string; data?: Record<string, unknown> };
|
|
297
|
+
} {
|
|
292
298
|
if (error instanceof ActionError) {
|
|
293
299
|
return {
|
|
294
300
|
serverError: {
|
|
@@ -339,43 +345,49 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
339
345
|
fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>
|
|
340
346
|
): ActionFn<TData, TInput> {
|
|
341
347
|
async function actionHandler(...args: unknown[]): Promise<ActionResult<TData>> {
|
|
348
|
+
// The input is the last argument in every call shape: `(input)`,
|
|
349
|
+
// `(formData)`, `(prevState, payload)` from useActionState's
|
|
350
|
+
// dispatch, `(initialState, formData)` on the no-JS path (Fizz binds
|
|
351
|
+
// the initial state into the form, and decodeAction binds the
|
|
352
|
+
// FormData after it), and `(...bound, prevState, payload)` for an
|
|
353
|
+
// action with bound arguments. Nothing before it is input:
|
|
354
|
+
// `prevState` comes from the client, so it is never read.
|
|
355
|
+
const last = args.at(-1);
|
|
356
|
+
const form = last instanceof FormData ? parseFormData(last) : undefined;
|
|
357
|
+
|
|
358
|
+
// Resolve the sensitive-field stripping predicate once per invocation.
|
|
359
|
+
// Precedence: per-action (config.stripSensitiveFields) > global
|
|
360
|
+
// (forms.stripSensitiveFields from timber.config.ts) > built-in deny-list.
|
|
361
|
+
// See TIM-816.
|
|
362
|
+
const sensitivePredicate = resolveSensitivePredicate(
|
|
363
|
+
config.stripSensitiveFields,
|
|
364
|
+
getGlobalSensitiveFieldsConfig()
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// A "safe-to-echo" copy of the input. Files are stripped (can't
|
|
368
|
+
// serialize, shouldn't echo back) and sensitive fields (passwords,
|
|
369
|
+
// tokens, CVV, etc.) are removed before they would land in the RSC
|
|
370
|
+
// payload → client form `defaultValue` → DOM.
|
|
371
|
+
const echo = (value: unknown): Record<string, unknown> | undefined => {
|
|
372
|
+
const withoutFiles = stripFiles(value);
|
|
373
|
+
if (withoutFiles === undefined) return undefined;
|
|
374
|
+
return stripSensitiveFields(withoutFiles, sensitivePredicate);
|
|
375
|
+
};
|
|
376
|
+
|
|
342
377
|
try {
|
|
343
378
|
// Run middleware
|
|
344
379
|
const ctx = await runActionMiddleware(config.middleware);
|
|
345
380
|
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// action with bound arguments. Nothing before it is input:
|
|
352
|
-
// `prevState` comes from the client, so it is never read.
|
|
353
|
-
const last = args.at(-1);
|
|
354
|
-
const rawInput: unknown = schema && last instanceof FormData ? parseFormData(last) : last;
|
|
355
|
-
|
|
356
|
-
// Resolve the sensitive-field stripping predicate once per invocation.
|
|
357
|
-
// Precedence: per-action (config.stripSensitiveFields) > global
|
|
358
|
-
// (forms.stripSensitiveFields from timber.config.ts) > built-in deny-list.
|
|
359
|
-
// See TIM-816.
|
|
360
|
-
const sensitivePredicate = resolveSensitivePredicate(
|
|
361
|
-
config.stripSensitiveFields,
|
|
362
|
-
getGlobalSensitiveFieldsConfig()
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
// Capture a "safe-to-echo" snapshot of the raw input once. Files are
|
|
366
|
-
// stripped (can't serialize, shouldn't echo back) and sensitive fields
|
|
367
|
-
// (passwords, tokens, CVV, etc.) are removed before they would land
|
|
368
|
-
// in the RSC payload → client form `defaultValue` → DOM.
|
|
369
|
-
const buildSubmittedValues = (): Record<string, unknown> | undefined => {
|
|
370
|
-
const withoutFiles = stripFiles(rawInput);
|
|
371
|
-
if (withoutFiles === undefined) return undefined;
|
|
372
|
-
return stripSensitiveFields(withoutFiles, sensitivePredicate);
|
|
373
|
-
};
|
|
381
|
+
// Without a schema the action receives the FormData itself. Checks
|
|
382
|
+
// and echoes read the parsed form: a FormData has no own entries.
|
|
383
|
+
const rawInput: unknown = schema && form ? form : last;
|
|
384
|
+
const submitted: unknown = form ?? rawInput;
|
|
385
|
+
const buildSubmittedValues = () => echo(submitted);
|
|
374
386
|
|
|
375
387
|
// Validate file sizes before schema validation.
|
|
376
|
-
if (config.fileSizeLimit !== undefined &&
|
|
388
|
+
if (config.fileSizeLimit !== undefined && submitted && typeof submitted === 'object') {
|
|
377
389
|
const fileSizeErrors = validateFileSizes(
|
|
378
|
-
|
|
390
|
+
submitted as Record<string, unknown>,
|
|
379
391
|
config.fileSizeLimit
|
|
380
392
|
);
|
|
381
393
|
if (fileSizeErrors) {
|
|
@@ -435,7 +447,12 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
435
447
|
if (isRedirectSignal(error) || isDenySignal(error)) {
|
|
436
448
|
throw error;
|
|
437
449
|
}
|
|
438
|
-
|
|
450
|
+
// A `serverError` echoes the form like a validation failure does.
|
|
451
|
+
// React resets the form after the action, and a form whose defaults
|
|
452
|
+
// fell back to page data would lose what the user typed (TIM-1573).
|
|
453
|
+
const result = handleActionError(error);
|
|
454
|
+
const submittedValues = form && echo(form);
|
|
455
|
+
return submittedValues ? { ...result, submittedValues } : result;
|
|
439
456
|
}
|
|
440
457
|
}
|
|
441
458
|
|
|
@@ -508,6 +525,8 @@ function logValidationFailure(errors: ValidationErrors): void {
|
|
|
508
525
|
/**
|
|
509
526
|
* Validate that all File objects in the input are within the size limit.
|
|
510
527
|
* Returns validation errors keyed by field name, or null if all files are ok.
|
|
528
|
+
* Keys are dot paths (`rows.0.file`), the form a field name and a Standard
|
|
529
|
+
* Schema error use, so `getFieldError` finds a file in a list.
|
|
511
530
|
*/
|
|
512
531
|
function validateFileSizes(input: Record<string, unknown>, limit: number): ValidationErrors | null {
|
|
513
532
|
const limitKb = Math.round(limit / 1024);
|
|
@@ -526,7 +545,7 @@ function validateFileSizes(input: Record<string, unknown>, limit: number): Valid
|
|
|
526
545
|
} else if (Array.isArray(value)) {
|
|
527
546
|
for (let i = 0; i < value.length; i++) {
|
|
528
547
|
const item = value[i];
|
|
529
|
-
const itemPath = `${path}
|
|
548
|
+
const itemPath = `${path}.${i}`;
|
|
530
549
|
if (item instanceof File && item.size > limit) {
|
|
531
550
|
(errors[itemPath] ??= []).push(
|
|
532
551
|
`File "${item.name}" (${formatSize(item.size)}) exceeds the ${limitLabel} limit`
|
|
@@ -546,29 +565,25 @@ function validateFileSizes(input: Record<string, unknown>, limit: number): Valid
|
|
|
546
565
|
}
|
|
547
566
|
|
|
548
567
|
/**
|
|
549
|
-
* Strip File objects from a value, returning a
|
|
550
|
-
*
|
|
568
|
+
* Strip File objects from a value, returning a copy safe for serialization.
|
|
569
|
+
* File objects can't be serialized and shouldn't be echoed back.
|
|
570
|
+
*
|
|
571
|
+
* Mirrors `parseFormData`'s output: objects and arrays at any depth, arrays
|
|
572
|
+
* of arrays included. A File in an array becomes `undefined` rather than
|
|
573
|
+
* being removed, so `rows.2` still names the third element (TIM-1573).
|
|
551
574
|
*/
|
|
552
575
|
function stripFiles(value: unknown): Record<string, unknown> | undefined {
|
|
553
|
-
if (value === null || value
|
|
554
|
-
|
|
576
|
+
if (value === null || typeof value !== 'object' || value instanceof File) return undefined;
|
|
577
|
+
return stripFilesFrom(value) as Record<string, unknown>;
|
|
578
|
+
}
|
|
555
579
|
|
|
580
|
+
function stripFilesFrom(value: unknown): unknown {
|
|
581
|
+
if (value instanceof File) return undefined;
|
|
582
|
+
if (Array.isArray(value)) return value.map(stripFilesFrom);
|
|
583
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
556
584
|
const result: Record<string, unknown> = {};
|
|
557
|
-
for (const [k, v] of Object.entries(value
|
|
558
|
-
if (v instanceof File)
|
|
559
|
-
if (Array.isArray(v)) {
|
|
560
|
-
result[k] = v
|
|
561
|
-
.filter((item) => !(item instanceof File))
|
|
562
|
-
.map((item) =>
|
|
563
|
-
typeof item === 'object' && item !== null && !(item instanceof File)
|
|
564
|
-
? (stripFiles(item) ?? {})
|
|
565
|
-
: item
|
|
566
|
-
);
|
|
567
|
-
} else if (typeof v === 'object' && v !== null && !(v instanceof File)) {
|
|
568
|
-
result[k] = stripFiles(v) ?? {};
|
|
569
|
-
} else {
|
|
570
|
-
result[k] = v;
|
|
571
|
-
}
|
|
585
|
+
for (const [k, v] of Object.entries(value)) {
|
|
586
|
+
if (!(v instanceof File)) result[k] = stripFilesFrom(v);
|
|
572
587
|
}
|
|
573
588
|
return result;
|
|
574
589
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { nonceAttr } from './render-utils.ts';
|
|
19
|
+
import { formStateToBase64 } from '../shared/form-state-flight.ts';
|
|
19
20
|
|
|
20
21
|
// ─── JSON Escaping ────────────────────────────────────────────────────────
|
|
21
22
|
|
|
@@ -65,3 +66,15 @@ export function flightChunkScript(data: string, nonce?: string): string {
|
|
|
65
66
|
const escaped = htmlEscapeJsonString(JSON.stringify([1, data]));
|
|
66
67
|
return `<script${nonceAttr(nonce)}>(${FLIGHT_VAR}=${FLIGHT_VAR}||[]).push(${escaped})</script>`;
|
|
67
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Generate the script that embeds a no-JS action's form state for
|
|
72
|
+
* hydration: its Flight bytes (server/form-state-flight.ts), as base64. Only
|
|
73
|
+
* a page answering a no-JS action has one. Base64 carries Flight's binary
|
|
74
|
+
* rows, which a text chunk would corrupt, and has no character that can end
|
|
75
|
+
* the script element or the string. See design/08-forms-and-actions.md
|
|
76
|
+
* §"No-JS Result Round-Trip".
|
|
77
|
+
*/
|
|
78
|
+
export function formStateScript(bytes: Uint8Array, nonce?: string): string {
|
|
79
|
+
return `<script${nonceAttr(nonce)}>self.__timber_form_state="${formStateToBase64(bytes)}"</script>`;
|
|
80
|
+
}
|
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
|
+
}
|
|
@@ -123,11 +123,14 @@ export function buildActionDispatcher(
|
|
|
123
123
|
// render's request-context cookies, so the page reads what the
|
|
124
124
|
// action wrote, not what the request carried (TIM-868).
|
|
125
125
|
// - Method GET because it is a page render.
|
|
126
|
+
// - The POST's abort signal, so a client that disconnects stops the
|
|
127
|
+
// render (and the form state encode) as it would any page's.
|
|
126
128
|
const rerenderHeaders = new Headers(req.headers);
|
|
127
129
|
rerenderHeaders.delete('cookie');
|
|
128
130
|
const rerenderReq = new Request(req.url, {
|
|
129
131
|
method: 'GET',
|
|
130
132
|
headers: rerenderHeaders,
|
|
133
|
+
signal: req.signal,
|
|
131
134
|
});
|
|
132
135
|
const { cookies } = actionResponse;
|
|
133
136
|
const response = await reenter(
|
|
@@ -322,6 +322,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
322
322
|
clientSegmentCache,
|
|
323
323
|
renderDenyFallback,
|
|
324
324
|
buildManifest: typedBuildManifest,
|
|
325
|
+
renderTimeoutMs: (config as Record<string, unknown>).renderTimeoutMs as number,
|
|
325
326
|
globalError: manifest.globalError,
|
|
326
327
|
},
|
|
327
328
|
interception
|