@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssr-form-state.d.ts","sourceRoot":"","sources":["../../src/server/ssr-form-state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,EAAmB,KAAK,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AAI9F,MAAM,WAAW,YAAY;IAC3B,qCAAqC;IACrC,SAAS,EAAE,cAAc,GAAG,SAAS,CAAC;IACtC,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;CAChB;AAID,wBAAsB,eAAe,CACnC,KAAK,EAAE,UAAU,GAAG,SAAS,EAC7B,OAAO,EAAE;IACP;;;OAGG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,MAAM,EAAE,sBAAsB,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB,GACA,OAAO,CAAC,YAAY,CAAC,CAmBvB"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The no-JS action form state on its way to the two renders that need it
|
|
3
|
+
* (TIM-1572, design/08-forms-and-actions.md §"No-JS Result Round-Trip").
|
|
4
|
+
*
|
|
5
|
+
* The RSC environment serializes the form state with Flight
|
|
6
|
+
* (server/form-state-flight.ts). SSR decodes those bytes for Fizz, and the
|
|
7
|
+
* browser decodes the same bytes, embedded in the page, for `hydrateRoot`.
|
|
8
|
+
* Both sides call `decodeFormState` below with their own environment's
|
|
9
|
+
* Flight client, so the two renders get the same value, and the value a
|
|
10
|
+
* `useActionState` result has with JS (Flight, through the action response):
|
|
11
|
+
* a `Date` stays a `Date`, a `Map` a `Map`, and an `undefined` field is
|
|
12
|
+
* absent on both, because the Flight client drops it.
|
|
13
|
+
*
|
|
14
|
+
* The page carries the bytes as base64: Flight may write binary rows (a typed
|
|
15
|
+
* array in a result), which a text chunk would corrupt, and base64 has no
|
|
16
|
+
* character that can end a `<script>` element.
|
|
17
|
+
*/
|
|
18
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
19
|
+
/**
|
|
20
|
+
* A Flight client's `createFromReadableStream`, SSR's or the browser's, typed
|
|
21
|
+
* to the form state tuple it decodes. React builds `ReactFormState`
|
|
22
|
+
* (`decodeFormState` on the server) and only React reads it; the Flight
|
|
23
|
+
* client's type parameter is the only place that type is asserted.
|
|
24
|
+
*/
|
|
25
|
+
export type FormStateFlightDecoder = (stream: ReadableStream<Uint8Array>) => PromiseLike<ReactFormState>;
|
|
26
|
+
/**
|
|
27
|
+
* Decode the form state's Flight bytes. Rejects if they do not decode within
|
|
28
|
+
* `timeoutMs`. Every byte is in hand, so only a Flight client bug could
|
|
29
|
+
* stall it, and the caller falls back to rendering without form state.
|
|
30
|
+
*/
|
|
31
|
+
export declare function decodeFormState(bytes: Uint8Array, decode: FormStateFlightDecoder, timeoutMs: number): Promise<ReactFormState>;
|
|
32
|
+
/** The form state's Flight bytes as the base64 the page embeds. */
|
|
33
|
+
export declare function formStateToBase64(bytes: Uint8Array): string;
|
|
34
|
+
/** The embedded base64 back to the Flight bytes. */
|
|
35
|
+
export declare function formStateFromBase64(text: string): Uint8Array;
|
|
36
|
+
//# sourceMappingURL=form-state-flight.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"form-state-flight.d.ts","sourceRoot":"","sources":["../../src/shared/form-state-flight.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD;;;;;GAKG;AACH,MAAM,MAAM,sBAAsB,GAAG,CACnC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,KAC/B,WAAW,CAAC,cAAc,CAAC,CAAC;AAEjC;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,UAAU,EACjB,MAAM,EAAE,sBAAsB,EAC9B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,cAAc,CAAC,CAmBzB;AAED,mEAAmE;AACnE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAI3D;AAED,oDAAoD;AACpD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAK5D"}
|
|
@@ -143,6 +143,28 @@ errors.serverError; // { code, data? } | null
|
|
|
143
143
|
errors.getFieldError('title'); // string | null
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
+
### `useFormField(defaultValue)`
|
|
147
|
+
|
|
148
|
+
State for a form field that needs JavaScript, such as a combobox or a chip list, that follows the form's reset like a native input with a `defaultValue`. React resets a form after every action, so use this instead of `useState` for any field inside a form. See [Keeping What the User Typed](/docs/forms-and-actions#keeping-what-the-user-typed).
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
import { useFormField } from '@timber-js/app/client';
|
|
152
|
+
|
|
153
|
+
// draft = toDraft(result?.submittedValues ?? post): the form's defaults
|
|
154
|
+
const [tags, setTags, ref] = useFormField(draft.tags);
|
|
155
|
+
|
|
156
|
+
<div ref={ref}>
|
|
157
|
+
<input type="hidden" name="tags" value={tags.join(',')} />
|
|
158
|
+
<TagPicker value={tags} onChange={setTags} />
|
|
159
|
+
</div>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- `value`: `defaultValue` until `setValue` is called, then the edit. An unedited field follows a new `defaultValue`.
|
|
163
|
+
- `setValue`: like a `useState` setter; accepts a value or an updater.
|
|
164
|
+
- `ref`: attach to any element inside the `<form>`. When the form resets, the edit is dropped and `value` is `defaultValue` again, unless the form cancels the `reset` event.
|
|
165
|
+
|
|
166
|
+
An edit dispatches a bubbling `change` event from the `ref` element, so a native `change` listener on the form sees it. React's `onChange` prop does not: it only reports inputs, selects and textareas. Render the value into the form data yourself, usually with a hidden input.
|
|
167
|
+
|
|
146
168
|
### `useFormAction(action)`
|
|
147
169
|
|
|
148
170
|
Bind a server action to a form with pending state.
|
|
@@ -153,7 +153,7 @@ limits: {
|
|
|
153
153
|
- **Type:** `boolean | string[]`
|
|
154
154
|
- **Default:** `true`
|
|
155
155
|
|
|
156
|
-
Strip sensitive fields (passwords, tokens, CVV, SSN, etc.) from the `submittedValues` echoed back
|
|
156
|
+
Strip sensitive fields (passwords, tokens, CVV, SSN, etc.) from the `submittedValues` echoed back when an action fails. `true` uses the built-in deny-list; an array adds extra field names to the built-in list; `false` disables stripping (never do this in production). See [Forms & Server Actions](/docs/forms-and-actions) for the full pattern list.
|
|
157
157
|
|
|
158
158
|
### `pageExtensions`
|
|
159
159
|
|
|
@@ -95,45 +95,132 @@ errors.hasErrors; // boolean
|
|
|
95
95
|
errors.getFieldError('title'); // string | null (first error for field)
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
##
|
|
98
|
+
## Keeping What the User Typed
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
| ------------------------- | ------------------------------------------------ |
|
|
102
|
-
| `redirect('/path')` | User should land somewhere new |
|
|
103
|
-
| `revalidatePath('/path')` | Current page needs fresh data |
|
|
104
|
-
| `useOptimistic` | Instant UI update, action confirms in background |
|
|
100
|
+
React resets a form after every action: each field goes back to its `defaultValue`. Build forms on that reset, and they keep the user's input after a failure and show the saved values after a save, with or without JavaScript:
|
|
105
101
|
|
|
106
|
-
|
|
102
|
+
1. Leave fields **uncontrolled**: a `name` and a `defaultValue`, not `value` + `onChange`.
|
|
103
|
+
2. Take defaults from the result, then from the page: `result?.submittedValues ?? pageData`.
|
|
104
|
+
3. On success, `redirect()` (or `revalidatePath()`), so the page renders the saved data.
|
|
107
105
|
|
|
108
|
-
|
|
106
|
+
```ts title="app/events/[id]/actions.ts"
|
|
107
|
+
'use server';
|
|
109
108
|
|
|
110
|
-
|
|
109
|
+
import { z } from 'zod/v4';
|
|
110
|
+
import { coerce, redirect } from '@timber-js/app/server';
|
|
111
|
+
import { action } from '@/lib/action';
|
|
112
|
+
|
|
113
|
+
declare const db: { events: { update(data: unknown): Promise<void> } };
|
|
114
|
+
|
|
115
|
+
export const saveEvent = action
|
|
116
|
+
.schema(
|
|
117
|
+
z.object({
|
|
118
|
+
id: z.string(),
|
|
119
|
+
title: z.string().min(1, 'Title is required'),
|
|
120
|
+
category: z.string(),
|
|
121
|
+
ticketed: z.preprocess(coerce.checkbox, z.boolean()),
|
|
122
|
+
// sets.0.name, sets.1.name… arrive as an array
|
|
123
|
+
sets: z.array(z.object({ name: z.string().min(1) })).default([]), // no rows: no key
|
|
124
|
+
})
|
|
125
|
+
)
|
|
126
|
+
.action(async ({ input }) => {
|
|
127
|
+
await db.events.update(input);
|
|
128
|
+
redirect(`/events/${input.id}`);
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`submittedValues` is the raw form: strings, lists as arrays, an unchecked box absent. Page data is typed. One small, lenient function reads either into what the fields render:
|
|
133
|
+
|
|
134
|
+
```ts title="app/events/[id]/draft.ts"
|
|
135
|
+
export function toDraft(values: Record<string, unknown>) {
|
|
136
|
+
const sets: { name?: unknown }[] = Array.isArray(values.sets) ? values.sets : [];
|
|
137
|
+
return {
|
|
138
|
+
title: String(values.title ?? ''),
|
|
139
|
+
category: String(values.category ?? ''),
|
|
140
|
+
// Submitted: "on", or absent when unchecked. Saved: a boolean.
|
|
141
|
+
ticketed: values.ticketed === true || values.ticketed === 'on',
|
|
142
|
+
sets: sets.map((set) => ({ name: String(set?.name ?? '') })),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```tsx title="app/events/[id]/event-form.tsx"
|
|
111
148
|
'use client';
|
|
112
149
|
|
|
113
150
|
import { useActionState } from 'react';
|
|
114
151
|
import { parseFormErrors } from '@timber-js/app/client';
|
|
115
|
-
import {
|
|
152
|
+
import { saveEvent } from './actions';
|
|
153
|
+
import { toDraft } from './draft';
|
|
116
154
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
155
|
+
type Event = { id: string; title: string; category: string; ticketed: boolean; sets: { name: string }[] };
|
|
156
|
+
|
|
157
|
+
export function EventForm({ event }: { event: Event }) {
|
|
158
|
+
const [result, action, pending] = useActionState(saveEvent, null);
|
|
159
|
+
const errors = parseFormErrors(result);
|
|
160
|
+
// After a failure: what the user submitted. Otherwise: the saved event.
|
|
161
|
+
const d = toDraft(result?.submittedValues ?? event);
|
|
120
162
|
|
|
121
163
|
return (
|
|
122
164
|
<form action={action}>
|
|
123
|
-
<input name="
|
|
124
|
-
{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
165
|
+
<input type="hidden" name="id" value={event.id} />
|
|
166
|
+
<input name="title" defaultValue={d.title} />
|
|
167
|
+
{errors.getFieldError('title')}
|
|
168
|
+
|
|
169
|
+
{/* React applies a select's defaultValue only on mount: key it on the default */}
|
|
170
|
+
<select key={d.category} name="category" defaultValue={d.category}>
|
|
171
|
+
<option value="meetup">Meetup</option>
|
|
172
|
+
<option value="social">Social</option>
|
|
173
|
+
</select>
|
|
174
|
+
|
|
175
|
+
<input type="checkbox" name="ticketed" defaultChecked={d.ticketed} />
|
|
176
|
+
|
|
177
|
+
{d.sets.map((set, i) => (
|
|
178
|
+
<input key={i} name={`sets.${i}.name`} defaultValue={set.name} />
|
|
179
|
+
))}
|
|
180
|
+
|
|
181
|
+
{errors.serverError && <p>Could not save. Please try again.</p>}
|
|
182
|
+
<button disabled={pending}>Save</button>
|
|
130
183
|
</form>
|
|
131
184
|
);
|
|
132
185
|
}
|
|
133
186
|
```
|
|
134
187
|
|
|
135
|
-
|
|
188
|
+
When a form submission fails — a validation error, an `ActionError`, or an unexpected error — the result carries `submittedValues`, so the user's input survives any of them.
|
|
189
|
+
|
|
190
|
+
Three things break the model:
|
|
191
|
+
|
|
192
|
+
- **Controlled fields.** A field whose value lives in state keeps showing that state after the reset has put the DOM back to its default, and the next submit sends what the DOM holds, not what the user sees. If a form must be controlled, cancel the reset: `<form action={action} onReset={(e) => e.preventDefault()}>`.
|
|
193
|
+
- **An unkeyed `<select>`.** React ignores a changed `defaultValue` on a mounted `<select>`. Key it on its default, as above. Inputs, checkboxes and textareas don't need this.
|
|
194
|
+
- **`useState` for a field that needs JavaScript.** A combobox or a chip list can't be a native input. Hold its state in `useFormField` instead, which follows the reset like a native input:
|
|
195
|
+
|
|
196
|
+
```tsx
|
|
197
|
+
import { useFormField } from '@timber-js/app/client';
|
|
198
|
+
|
|
199
|
+
function TagsField({ defaultValue }: { defaultValue: string[] }) {
|
|
200
|
+
const [tags, setTags, ref] = useFormField(defaultValue);
|
|
201
|
+
return (
|
|
202
|
+
<div ref={ref}>
|
|
203
|
+
<input type="hidden" name="tags" value={tags.join(',')} />
|
|
204
|
+
<TagPicker value={tags} onChange={setTags} />
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## After Mutation
|
|
211
|
+
|
|
212
|
+
| Pattern | When to use |
|
|
213
|
+
| ------------------------- | ------------------------------------------------ |
|
|
214
|
+
| `redirect('/path')` | User should land somewhere new |
|
|
215
|
+
| `revalidatePath('/path')` | Current page needs fresh data |
|
|
216
|
+
| `useOptimistic` | Instant UI update, action confirms in background |
|
|
217
|
+
|
|
218
|
+
## Forms Without JavaScript
|
|
219
|
+
|
|
220
|
+
The same form works with JavaScript disabled, with no extra code. When a no-JS submission's action returns instead of redirecting, timber renders the page again and React hands the result to the `useActionState` hook that submitted it. `result` holds the result on both paths, so errors and the fields' defaults render the same way, and the server writes the user's input back into the HTML.
|
|
221
|
+
|
|
222
|
+
The result goes only to the hook that submitted, so two forms on one page never see each other's results. It has the same types on both paths: a `Date` your action returns is a `Date` in `state` whether or not JavaScript ran. Sensitive fields (password, apiKey, cvv, etc.) are automatically stripped from `submittedValues`.
|
|
136
223
|
|
|
137
224
|
A form that doesn't use `useActionState` gets no result back, just as with JavaScript, where React discards a plain `<form action>`'s return value. Such an action should `redirect()` to a page that shows the new state (post-redirect-get). An action that throws renders the nearest `error.tsx`: with JavaScript the action rejects to the nearest error boundary, and without it the page answers from the same `error.tsx` with a 500; return errors from `createActionClient` actions instead.
|
|
138
225
|
|
|
139
|
-
For advanced topics — coercion helpers, `parseFormData`,
|
|
226
|
+
For advanced topics — coercion helpers, `parseFormData`, indexed lists, and sensitive field stripping configuration — see [Advanced Forms](/docs/advanced-forms).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.213",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -23,7 +23,7 @@ import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../r
|
|
|
23
23
|
import { markClientStale } from '../stale-client.ts';
|
|
24
24
|
import { RSC_CONTENT_TYPE } from '../../shared/rsc-media-type.ts';
|
|
25
25
|
import { CSRF_REJECT_HEADER, CSRF_REJECT_VALUE } from '../../shared/csrf-reject.ts';
|
|
26
|
-
import { createActionQueue } from './action-queue.ts';
|
|
26
|
+
import { createActionQueue, DEFAULT_HOLD_TIMEOUT_MS } from './action-queue.ts';
|
|
27
27
|
|
|
28
28
|
export function setupServerActions(): void {
|
|
29
29
|
const queue = createActionQueue({
|
|
@@ -46,10 +46,10 @@ export function setupServerActions(): void {
|
|
|
46
46
|
},
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
49
|
+
// The page that submitted a redirecting action stays mounted until the
|
|
50
|
+
// destination commits. Its own useActionState stays pending until the
|
|
51
|
+
// hand-off (below), but other forms and effects on it can still dispatch,
|
|
52
|
+
// and a second submit would run the mutation again (TIM-1568). So the
|
|
53
53
|
// departing page may not dispatch: `departing` is set from the redirect
|
|
54
54
|
// response until the router is next idle, and a call made
|
|
55
55
|
// meanwhile is never sent. It resolves at once with `undefined` — what the
|
|
@@ -163,10 +163,26 @@ export function setupServerActions(): void {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
// Redirects apply unconditionally — the server mutated and told
|
|
166
|
-
// the client where to go.
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
166
|
+
// the client where to go. The action settles (returning undefined)
|
|
167
|
+
// once the destination's tree has been handed to React, not before
|
|
168
|
+
// and not after:
|
|
169
|
+
//
|
|
170
|
+
// - Not after its commit: React entangles the commit with the action
|
|
171
|
+
// scope, so the commit would wait on the action while the action
|
|
172
|
+
// waited on the commit.
|
|
173
|
+
// - Not before the hand-off: React would commit the action alone —
|
|
174
|
+
// useActionState's result, and React's reset of the submitting form
|
|
175
|
+
// onto the defaults the *departing* page renders — and the form
|
|
176
|
+
// would show the pre-save values until the destination committed
|
|
177
|
+
// (TIM-1573).
|
|
178
|
+
//
|
|
179
|
+
// At the hand-off the destination's render is already entangled with
|
|
180
|
+
// the action, so settling commits both together: the form resets onto
|
|
181
|
+
// the destination's defaults. The wait always ends: the navigation
|
|
182
|
+
// hands off, or fails, or a navigation the user starts supersedes it
|
|
183
|
+
// (its promise settles either way), or DEFAULT_HOLD_TIMEOUT_MS
|
|
184
|
+
// passes. Every transition waits while an action is pending, so this
|
|
185
|
+
// must not hang on a stalled fetch.
|
|
170
186
|
//
|
|
171
187
|
// Set before returning, so React never sees the action settle while
|
|
172
188
|
// the departing page can still dispatch (see `departing`).
|
|
@@ -190,7 +206,9 @@ export function setupServerActions(): void {
|
|
|
190
206
|
// still suspended — can commit afterwards, and must not release
|
|
191
207
|
// the latch: it is the departing page again (codex on #1211).
|
|
192
208
|
const redirectSeq = r.epoch().seq + 1;
|
|
193
|
-
|
|
209
|
+
let handOff!: () => void;
|
|
210
|
+
const handedOff = new Promise<void>((resolve) => (handOff = resolve));
|
|
211
|
+
r.navigate(wrapper._redirect, { onHandOff: handOff }).then(handOff, handOff);
|
|
194
212
|
// Released once the departing page can no longer be on screen:
|
|
195
213
|
// when any navigation's tree commits (a streamed destination
|
|
196
214
|
// commits its shell before its stream finishes, and is live from
|
|
@@ -213,6 +231,12 @@ export function setupServerActions(): void {
|
|
|
213
231
|
// A navigation that failed before taking the router never makes
|
|
214
232
|
// it busy, so no idle transition would come.
|
|
215
233
|
if (r.epoch().idle) arrive();
|
|
234
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
235
|
+
await Promise.race([
|
|
236
|
+
handedOff,
|
|
237
|
+
new Promise<void>((resolve) => (timer = setTimeout(resolve, DEFAULT_HOLD_TIMEOUT_MS))),
|
|
238
|
+
]);
|
|
239
|
+
clearTimeout(timer);
|
|
216
240
|
} catch (e) {
|
|
217
241
|
console.debug(
|
|
218
242
|
'[timber] action redirect: router not ready, using full navigation',
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form state a page answering a no-JS action embeds for hydration
|
|
3
|
+
* (`self.__timber_form_state`, server/form-state-flight.ts).
|
|
4
|
+
*
|
|
5
|
+
* `hydrateRoot` needs the value Fizz rendered with, or the `useActionState`
|
|
6
|
+
* hook that submitted hydrates back to its initial state. The embed is the
|
|
7
|
+
* form state's Flight bytes, the same bytes SSR decoded for Fizz, so the
|
|
8
|
+
* browser decodes them with its own Flight client before it hydrates. Only
|
|
9
|
+
* this page waits for that; a page without the embed hydrates at once
|
|
10
|
+
* (TIM-1297: hydration does not wait for the payload root).
|
|
11
|
+
*
|
|
12
|
+
* See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
16
|
+
import { createFromReadableStream } from '../../rsc-runtime/browser.ts';
|
|
17
|
+
import { decodeFormState, formStateFromBase64 } from '../../shared/form-state-flight.ts';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every byte is already in the document, so decoding takes microtasks. The
|
|
21
|
+
* bound only keeps a Flight client bug from holding hydration forever.
|
|
22
|
+
*/
|
|
23
|
+
const DECODE_TIMEOUT_MS = 5_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Take the embedded form state, and remove it. `null` when the page has
|
|
27
|
+
* none, which is every page but one answering a no-JS action. Otherwise a
|
|
28
|
+
* promise of the decoded state, or of `null` if it could not be decoded: the
|
|
29
|
+
* page then hydrates as though it had none, which resets that one hook.
|
|
30
|
+
*/
|
|
31
|
+
export function takeEmbeddedFormState(): Promise<ReactFormState | null> | null {
|
|
32
|
+
const embedded: unknown = Reflect.get(self, '__timber_form_state');
|
|
33
|
+
Reflect.deleteProperty(self, '__timber_form_state');
|
|
34
|
+
if (typeof embedded !== 'string') return null;
|
|
35
|
+
// Inside the chain, so malformed base64 falls back like a failed decode.
|
|
36
|
+
return Promise.resolve(embedded)
|
|
37
|
+
.then((text) =>
|
|
38
|
+
decodeFormState(
|
|
39
|
+
formStateFromBase64(text),
|
|
40
|
+
(stream) => createFromReadableStream(stream),
|
|
41
|
+
DECODE_TIMEOUT_MS
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
.catch((error: unknown) => {
|
|
45
|
+
console.warn('[timber] the form state of a no-JS action did not decode:', error);
|
|
46
|
+
return null;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -76,6 +76,13 @@ interface HydrateOptions {
|
|
|
76
76
|
reactRoot: ReactRootHost;
|
|
77
77
|
/** Makes the page current and builds its tree — see `RouterInitResult.hydrate`. */
|
|
78
78
|
hydrate: RouterInitResult['hydrate'];
|
|
79
|
+
/**
|
|
80
|
+
* The decoded form state of the no-JS action this page answers, or `null`
|
|
81
|
+
* (./form-state.ts). `hydrateRoot` needs the value Fizz rendered with, or
|
|
82
|
+
* the `useActionState` hook that submitted hydrates back to its initial
|
|
83
|
+
* state. See design/08-forms-and-actions.md §"No-JS Result Round-Trip".
|
|
84
|
+
*/
|
|
85
|
+
formState: ReactFormState | null;
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
/**
|
|
@@ -90,22 +97,6 @@ function takeEmbeddedSegmentInfo(): SegmentInfo[] | null {
|
|
|
90
97
|
return Array.isArray(embedded) ? embedded : null;
|
|
91
98
|
}
|
|
92
99
|
|
|
93
|
-
/**
|
|
94
|
-
* Take the form state the server embedded (`self.__timber_form_state`) when
|
|
95
|
-
* this page answers a no-JS action, and remove it. `hydrateRoot` needs the
|
|
96
|
-
* value Fizz rendered with, or the `useActionState` hook that submitted
|
|
97
|
-
* hydrates back to its initial state. See design/08-forms-and-actions.md
|
|
98
|
-
* §"No-JS Result Round-Trip".
|
|
99
|
-
*/
|
|
100
|
-
function takeEmbeddedFormState(): ReactFormState | null {
|
|
101
|
-
const embedded: unknown = Reflect.get(self, '__timber_form_state');
|
|
102
|
-
Reflect.deleteProperty(self, '__timber_form_state');
|
|
103
|
-
// `ReactFormState` is an opaque type: React builds it (`decodeFormState`
|
|
104
|
-
// on the server) and only React reads it, so there is no shape to check
|
|
105
|
-
// beyond the tuple. It is our own server's JSON of that value.
|
|
106
|
-
return Array.isArray(embedded) ? (embedded as unknown as ReactFormState) : null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
100
|
/**
|
|
110
101
|
* Make the server-rendered page current, and hydrate the React tree with it
|
|
111
102
|
* when an RSC payload is available.
|
|
@@ -122,7 +113,7 @@ function takeEmbeddedFormState(): ReactFormState | null {
|
|
|
122
113
|
* first navigation or revalidation, creates it with the tree it renders
|
|
123
114
|
* (`createReactRoot`, TIM-600 / TIM-580).
|
|
124
115
|
*/
|
|
125
|
-
export function hydrateApp({ rscResult, reactRoot, hydrate }: HydrateOptions): void {
|
|
116
|
+
export function hydrateApp({ rscResult, reactRoot, hydrate, formState }: HydrateOptions): void {
|
|
126
117
|
// The chain is the router's own `renderTree`, not a copy: an element type
|
|
127
118
|
// that is present here and absent on the first navigation (or the
|
|
128
119
|
// reverse) changes the type at that position, and React remounts
|
|
@@ -143,7 +134,7 @@ export function hydrateApp({ rscResult, reactRoot, hydrate }: HydrateOptions): v
|
|
|
143
134
|
// construction when the tree it builds renders.
|
|
144
135
|
hydrate(page, (element) =>
|
|
145
136
|
reactRoot.hydrate(element, {
|
|
146
|
-
formState
|
|
137
|
+
formState,
|
|
147
138
|
// Suppress recoverable hydration errors from deny/error signals
|
|
148
139
|
// inside Suspense boundaries. The server already handled these
|
|
149
140
|
// (wrapStreamWithErrorHandling closes the stream cleanly after
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* Bootstrap call order contract:
|
|
16
16
|
*
|
|
17
17
|
* 1. setupServerActions() — register callServer (independent)
|
|
18
|
+
* takeEmbeddedFormState() — only on a page answering a no-JS action:
|
|
19
|
+
* decode its form state, and run 2–7 after
|
|
18
20
|
* 2. createRscPayloadStream() — decode inlined RSC payload
|
|
19
21
|
* 3. createReactRoot() + — the root host the router renders through,
|
|
20
22
|
* createTimberRouter() then the router + Navigation API
|
|
@@ -41,9 +43,11 @@ import { initStaleClient } from '../stale-client.ts';
|
|
|
41
43
|
|
|
42
44
|
import { setupServerActions } from './action-dispatch.ts';
|
|
43
45
|
import { createRscPayloadStream } from './rsc-stream.ts';
|
|
46
|
+
import { takeEmbeddedFormState } from './form-state.ts';
|
|
44
47
|
import { createTimberRouter } from './router-init.ts';
|
|
45
48
|
import { createReactRoot } from '../react-root.ts';
|
|
46
49
|
import type { TopLoaderConfig } from '../top-loader.tsx';
|
|
50
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
47
51
|
import { hydrateApp } from './hydrate.ts';
|
|
48
52
|
import { setupPostHydration } from './post-hydration.ts';
|
|
49
53
|
import { setupHmr } from './hmr.ts';
|
|
@@ -54,7 +58,7 @@ setupServerActions();
|
|
|
54
58
|
|
|
55
59
|
// ─── 2–6. Bootstrap ─────────────────────────────────────────────
|
|
56
60
|
|
|
57
|
-
function bootstrap(runtimeConfig: typeof config): void {
|
|
61
|
+
function bootstrap(runtimeConfig: typeof config, formState: ReactFormState | null): void {
|
|
58
62
|
// Initialize deployment ID for version skew detection (TIM-446).
|
|
59
63
|
// In dev mode this is null — skew checks are skipped.
|
|
60
64
|
const deploymentId = (runtimeConfig as Record<string, unknown>).deploymentId as string | null;
|
|
@@ -95,7 +99,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
95
99
|
});
|
|
96
100
|
|
|
97
101
|
// Step 4: Make the page current and hydrate (no root without a payload)
|
|
98
|
-
hydrateApp({ rscResult, reactRoot, hydrate });
|
|
102
|
+
hydrateApp({ rscResult, reactRoot, hydrate, formState });
|
|
99
103
|
|
|
100
104
|
// Step 5: Post-hydration wiring
|
|
101
105
|
setupPostHydration({ router, navApiController });
|
|
@@ -104,8 +108,6 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
104
108
|
setupHmr(router);
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
bootstrap(config);
|
|
108
|
-
|
|
109
111
|
// ─── 7. Ready Signal ────────────────────────────────────────────
|
|
110
112
|
|
|
111
113
|
// Signal that the client runtime has been initialized.
|
|
@@ -115,6 +117,22 @@ bootstrap(config);
|
|
|
115
117
|
// via hydrateRoot(document, ...), mutating <html> attributes causes
|
|
116
118
|
// hydration mismatch warnings. Dynamically-added <meta> tags don't
|
|
117
119
|
// conflict because React doesn't reconcile them.
|
|
118
|
-
|
|
119
|
-
readyMeta
|
|
120
|
-
|
|
120
|
+
function signalReady(): void {
|
|
121
|
+
const readyMeta = document.createElement('meta');
|
|
122
|
+
readyMeta.name = 'timber-ready';
|
|
123
|
+
document.head.appendChild(readyMeta);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A page answering a no-JS action must hydrate with the form state Fizz
|
|
127
|
+
// rendered, which is decoded first (design/08 §"No-JS Result Round-Trip").
|
|
128
|
+
// Every other page bootstraps synchronously, as it always has.
|
|
129
|
+
const embeddedFormState = takeEmbeddedFormState();
|
|
130
|
+
if (embeddedFormState) {
|
|
131
|
+
void embeddedFormState.then((formState) => {
|
|
132
|
+
bootstrap(config, formState);
|
|
133
|
+
signalReady();
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
bootstrap(config, null);
|
|
137
|
+
signalReady();
|
|
138
|
+
}
|
|
@@ -297,7 +297,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
297
297
|
//
|
|
298
298
|
// `_url` names the pending URL the router already published to its own
|
|
299
299
|
// pending store before calling here; the transition has no use for it.
|
|
300
|
-
navigateTransition: (_url: string, owner: RenderOwner, types, perform, onCommit) => {
|
|
300
|
+
navigateTransition: (_url: string, owner: RenderOwner, types, perform, onCommit, onHandOff) => {
|
|
301
301
|
return navigateTransition(
|
|
302
302
|
owner,
|
|
303
303
|
async () => {
|
|
@@ -334,7 +334,8 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
334
334
|
},
|
|
335
335
|
render,
|
|
336
336
|
types,
|
|
337
|
-
onCommit
|
|
337
|
+
onCommit,
|
|
338
|
+
onHandOff
|
|
338
339
|
);
|
|
339
340
|
},
|
|
340
341
|
|
package/src/client/index.ts
CHANGED
|
@@ -76,6 +76,7 @@ export {
|
|
|
76
76
|
|
|
77
77
|
// Forms
|
|
78
78
|
export { parseFormErrors, useFormAction } from './form.tsx';
|
|
79
|
+
export { useFormField } from './use-form-field.ts';
|
|
79
80
|
export type { FormErrorsResult } from './form.tsx';
|
|
80
81
|
|
|
81
82
|
// Params. Called with no argument this returns the untyped accumulated params;
|
|
@@ -47,7 +47,9 @@
|
|
|
47
47
|
*
|
|
48
48
|
* Nothing here may reopen an action scope: no `startTransition` callback in
|
|
49
49
|
* this path may return a thenable, and no caller may invoke this from inside
|
|
50
|
-
* a React action scope
|
|
50
|
+
* a React action scope — except one that settles that action at `onHandOff`,
|
|
51
|
+
* which commits the action and the tree together on purpose (a server action
|
|
52
|
+
* redirect, TIM-1573). That is a rule about `startTransition`, not about
|
|
51
53
|
* `perform` — `perform` is async by contract and is awaited OUTSIDE any
|
|
52
54
|
* transition scope, which is exactly why it is safe.
|
|
53
55
|
*
|
|
@@ -233,6 +235,13 @@ export function createRenderOwner(kind: 'navigation' | 'revalidation'): RenderOw
|
|
|
233
235
|
* detected via `owner.outcome` (sync) and `owner.displaced` (async).
|
|
234
236
|
* No module-level state; no globalThis singleton.
|
|
235
237
|
*
|
|
238
|
+
* `onHandOff` runs synchronously right after `render` has scheduled the
|
|
239
|
+
* tree, before React can commit it. A caller inside a React action scope may
|
|
240
|
+
* settle that action here (and only here): the render is already entangled
|
|
241
|
+
* with the scope, so settling commits the action and this tree together,
|
|
242
|
+
* where settling earlier commits the action alone and settling on the commit
|
|
243
|
+
* deadlocks. The server action redirect is that caller (TIM-1573).
|
|
244
|
+
*
|
|
236
245
|
* Used for: navigate(), refresh(), popstate with fetch.
|
|
237
246
|
*/
|
|
238
247
|
export function navigateTransition(
|
|
@@ -240,7 +249,8 @@ export function navigateTransition(
|
|
|
240
249
|
perform: () => Promise<TransitionResult>,
|
|
241
250
|
render: NavigationRender,
|
|
242
251
|
types: readonly string[],
|
|
243
|
-
onCommit?: (outcome: CommitOutcome) => void
|
|
252
|
+
onCommit?: (outcome: CommitOutcome) => void,
|
|
253
|
+
onHandOff?: () => void
|
|
244
254
|
): Promise<void> {
|
|
245
255
|
const superseded = () => new DOMException('Navigation superseded', 'AbortError');
|
|
246
256
|
|
|
@@ -284,6 +294,7 @@ export function navigateTransition(
|
|
|
284
294
|
},
|
|
285
295
|
types
|
|
286
296
|
);
|
|
297
|
+
onHandOff?.();
|
|
287
298
|
// React may commit the tree before this settles — that is the point:
|
|
288
299
|
// the destination reveals as React is able to render it
|
|
289
300
|
// rather than waiting for the whole Flight stream. The await is here so
|
|
@@ -150,9 +150,11 @@ export interface NavigationRecoveryDeps {
|
|
|
150
150
|
* Replace the current entry with `url` — the router's own `navigate()`,
|
|
151
151
|
* rendering with `types`: the view transition types of the render the
|
|
152
152
|
* redirect interrupted, so a redirected Back still animates as Back and a
|
|
153
|
-
* link's own `transitionTypes` survive the hop (TIM-1471).
|
|
153
|
+
* link's own `transitionTypes` survive the hop (TIM-1471). `onHandOff`
|
|
154
|
+
* survives it too: a server action redirect waits for the tree that is
|
|
155
|
+
* finally handed to React, which is the redirected one (TIM-1573).
|
|
154
156
|
*/
|
|
155
|
-
navigate: (url: string, types: readonly string[]) => Promise<void>;
|
|
157
|
+
navigate: (url: string, types: readonly string[], onHandOff?: () => void) => Promise<void>;
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
/**
|
|
@@ -188,13 +190,15 @@ export function createNavigationRecovery({
|
|
|
188
190
|
url: string,
|
|
189
191
|
fromUrl: string,
|
|
190
192
|
/** The view transition types of the render that failed. */
|
|
191
|
-
types: readonly string[]
|
|
193
|
+
types: readonly string[],
|
|
194
|
+
/** The failed navigation's `NavigationOptions.onHandOff`, for the hop. */
|
|
195
|
+
onHandOff?: () => void
|
|
192
196
|
): Promise<boolean> {
|
|
193
197
|
if (error instanceof RedirectError) {
|
|
194
198
|
// Same ownership rule as leaving the SPA: a superseded navigation must
|
|
195
199
|
// not steer the document to the destination it was abandoned for.
|
|
196
200
|
if (currentOwner() !== owner) return true;
|
|
197
|
-
await navigate(error.redirectUrl, types);
|
|
201
|
+
await navigate(error.redirectUrl, types, onHandOff);
|
|
198
202
|
return true;
|
|
199
203
|
}
|
|
200
204
|
// A server error, a non-RSC response and a version skew all end the same
|