@tinvar/react 0.0.0-stage → 0.4.1
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/LICENSE +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- package/README.md +0 -3
package/src/form.js
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// <Form>: a real <form> that posts to an action, and does it better with
|
|
4
|
+
// script than without.
|
|
5
|
+
//
|
|
6
|
+
// tinvar-spec.md, "Forms and mutations":
|
|
7
|
+
//
|
|
8
|
+
// "<Form action={moveUnit}> with uncontrolled `name` inputs, nested names
|
|
9
|
+
// (`report[tags][]`), `transform`, and slot props: `errors`, `processing`,
|
|
10
|
+
// `progress`, `wasSuccessful`, `recentlySuccessful`, `isDirty`, `reset`,
|
|
11
|
+
// `submit`, `cancel`, `setError`, `clearErrors`. Keep Inertia's split:
|
|
12
|
+
// top-level props control the submission, `options` controls the visit
|
|
13
|
+
// after it."
|
|
14
|
+
//
|
|
15
|
+
// It renders <form method="post" action="/_action/{name}"> with the CSRF
|
|
16
|
+
// token in a hidden field, so before JavaScript loads -- or without it -- a
|
|
17
|
+
// submit is an ordinary post, which the endpoint answers with a page: back
|
|
18
|
+
// to the form with its errors flashed (FlashStore), or on to wherever the
|
|
19
|
+
// action redirected. Once hydrated, a submit is a fetch instead, and the
|
|
20
|
+
// answer lands in place: errors on their fields, the page re-rendered, the
|
|
21
|
+
// redirect followed as a client visit.
|
|
22
|
+
//
|
|
23
|
+
// <FormSession> carries what forms need from the server, per request: the
|
|
24
|
+
// token for the hidden field, and what a post made without script flashed
|
|
25
|
+
// for this page. The app renders it once, around the page, from its render
|
|
26
|
+
// entry; the props are plain data, so it can be given them by a server
|
|
27
|
+
// component.
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
createContext,
|
|
31
|
+
createElement,
|
|
32
|
+
useCallback,
|
|
33
|
+
useContext,
|
|
34
|
+
useEffect,
|
|
35
|
+
useMemo,
|
|
36
|
+
useRef,
|
|
37
|
+
useState,
|
|
38
|
+
} from 'react';
|
|
39
|
+
import { actionName, actionUrl } from './action.js';
|
|
40
|
+
import { formToObject, getPath, TOKEN_FIELD } from './form-data.js';
|
|
41
|
+
import { useRouter } from './link.js';
|
|
42
|
+
import { useOptimisticStore } from './optimistic.js';
|
|
43
|
+
import { usePrecognition } from './precognition.js';
|
|
44
|
+
import { assignLocation } from './router-hooks.js';
|
|
45
|
+
import { submitAction } from './submit.js';
|
|
46
|
+
import { normalizeErrors } from './transport.js';
|
|
47
|
+
|
|
48
|
+
/** How long `recentlySuccessful` stays true after a success, in ms -- Inertia's figure. */
|
|
49
|
+
export const RECENTLY_SUCCESSFUL_MS = 2000;
|
|
50
|
+
|
|
51
|
+
const SessionContext = createContext(/** @type {Session | null} */ (null));
|
|
52
|
+
|
|
53
|
+
/** @internal What useFormContext() reads: a <Form>'s slot props, or a useForm() form. */
|
|
54
|
+
export const FormContext = createContext(/** @type {Record<string, unknown> | null} */ (null));
|
|
55
|
+
const EMPTY = Object.freeze({});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a post made without script left for this page.
|
|
59
|
+
*
|
|
60
|
+
* @typedef {{
|
|
61
|
+
* flash?: Record<string, unknown>,
|
|
62
|
+
* form?: { action: string, errors?: Record<string, string[]>, old?: Record<string, unknown>, expired?: boolean },
|
|
63
|
+
* }} Flashed
|
|
64
|
+
*
|
|
65
|
+
* @typedef {{
|
|
66
|
+
* csrfToken: string | null,
|
|
67
|
+
* flashed: Flashed,
|
|
68
|
+
* flash: Record<string, unknown>,
|
|
69
|
+
* setFlash: (flash: Record<string, unknown>) => void,
|
|
70
|
+
* hardNavigate: (url: string) => void,
|
|
71
|
+
* }} Session
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The server's per-request state for every form below it.
|
|
76
|
+
*
|
|
77
|
+
* @param {{
|
|
78
|
+
* csrfToken?: string | null,
|
|
79
|
+
* flash?: Flashed | null,
|
|
80
|
+
* hardNavigate?: (url: string) => void,
|
|
81
|
+
* children?: unknown,
|
|
82
|
+
* }} props
|
|
83
|
+
*/
|
|
84
|
+
export function FormSession({ csrfToken = null, flash = null, hardNavigate = assignLocation, children }) {
|
|
85
|
+
const flashed = useMemo(() => normalizeFlashed(flash), [flash]);
|
|
86
|
+
const router = useOptionalRouter();
|
|
87
|
+
|
|
88
|
+
// An action's flash shows until the next page, whose own flash -- from
|
|
89
|
+
// the server, in this component's props -- replaces it. Kept with the
|
|
90
|
+
// props it was set under, so a new page that brings a flash drops it
|
|
91
|
+
// without an effect; and with the page it was set on, for one that
|
|
92
|
+
// brings none.
|
|
93
|
+
const [live, setLive] = useState(
|
|
94
|
+
/** @type {{ under: Flashed, flash: Record<string, unknown>, page: string } | null} */ (null),
|
|
95
|
+
);
|
|
96
|
+
const current = live !== null && live.under === flashed ? live.flash : (flashed.flash ?? EMPTY);
|
|
97
|
+
const setFlash = useCallback(
|
|
98
|
+
(/** @type {Record<string, unknown>} */ next) =>
|
|
99
|
+
setLive({ under: flashed, flash: next, page: pageOf(globalThis.location?.href) }),
|
|
100
|
+
[flashed],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// A page with nothing flashed renders this with `flash={null}`, as the
|
|
104
|
+
// page before it did: the props are the same, so nothing in them says
|
|
105
|
+
// the page is another. The router does, when it has moved to one. The
|
|
106
|
+
// same page again -- a reload after an action that revalidated it, a
|
|
107
|
+
// jump to an anchor -- keeps what the action said.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (typeof router?.on !== 'function') return undefined;
|
|
110
|
+
return router.on('navigate', (/** @type {CustomEvent} */ event) => {
|
|
111
|
+
const page = pageOf(event.detail?.url);
|
|
112
|
+
setLive((now) => (now !== null && now.page !== page ? null : now));
|
|
113
|
+
});
|
|
114
|
+
}, [router]);
|
|
115
|
+
|
|
116
|
+
const value = useMemo(
|
|
117
|
+
() => ({ csrfToken, flashed, flash: current, setFlash, hardNavigate }),
|
|
118
|
+
[csrfToken, flashed, current, setFlash, hardNavigate],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return createElement(SessionContext.Provider, { value }, children);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The flash the page should show: a post's, carried across its redirect, or an action's since. */
|
|
125
|
+
export function useFlash() {
|
|
126
|
+
return useContext(SessionContext)?.flash ?? EMPTY;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The slot props of the <Form> around a component -- errors, processing,
|
|
131
|
+
* reset and the rest -- for an input nested deep inside it; or the useForm()
|
|
132
|
+
* form a <FormProvider> above it shares. Null outside either.
|
|
133
|
+
*/
|
|
134
|
+
export function useFormContext() {
|
|
135
|
+
return useContext(FormContext);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The router, or null: none is above a form rendered on the server, or in a test. */
|
|
139
|
+
export function useOptionalRouter() {
|
|
140
|
+
try {
|
|
141
|
+
return useRouter();
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @internal The FormSession above, for the hooks that submit. */
|
|
148
|
+
export function useFormSession() {
|
|
149
|
+
return useContext(SessionContext);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {Record<string, any> & { action: import('./action.js').ActionTarget }} props
|
|
154
|
+
*/
|
|
155
|
+
export function Form(props) {
|
|
156
|
+
const {
|
|
157
|
+
action,
|
|
158
|
+
transform,
|
|
159
|
+
options,
|
|
160
|
+
headers,
|
|
161
|
+
onBefore,
|
|
162
|
+
onStart,
|
|
163
|
+
onProgress,
|
|
164
|
+
onSuccess,
|
|
165
|
+
onError,
|
|
166
|
+
onHttpException,
|
|
167
|
+
onNetworkError,
|
|
168
|
+
onCancel,
|
|
169
|
+
onFinish,
|
|
170
|
+
onFlash,
|
|
171
|
+
onExpired,
|
|
172
|
+
resetOnSuccess = false,
|
|
173
|
+
resetOnError = false,
|
|
174
|
+
setDefaultsOnSuccess = false,
|
|
175
|
+
disableWhileProcessing = false,
|
|
176
|
+
validationTimeout,
|
|
177
|
+
validateFiles = false,
|
|
178
|
+
validateAgainst: _validateAgainst,
|
|
179
|
+
children,
|
|
180
|
+
onSubmit: appOnSubmit,
|
|
181
|
+
...attributes
|
|
182
|
+
} = props;
|
|
183
|
+
|
|
184
|
+
const name = actionName(action);
|
|
185
|
+
const session = useContext(SessionContext);
|
|
186
|
+
const router = useOptionalRouter();
|
|
187
|
+
const optimistic = useOptimisticStore();
|
|
188
|
+
|
|
189
|
+
// Errors a post without script was sent back with: shown on the fields
|
|
190
|
+
// as the page renders, on the server as in the browser.
|
|
191
|
+
const flashed = session?.flashed.form?.action === name ? session.flashed.form : null;
|
|
192
|
+
|
|
193
|
+
const formRef = useRef(/** @type {HTMLFormElement | null} */ (null));
|
|
194
|
+
const [errors, setErrors] = useState(() => firstMessages(flashed?.errors));
|
|
195
|
+
const [processing, setProcessing] = useState(false);
|
|
196
|
+
const [progress, setProgress] = useState(/** @type {import('./transport.js').Progress | null} */ (null));
|
|
197
|
+
const [wasSuccessful, setWasSuccessful] = useState(false);
|
|
198
|
+
const [recentlySuccessful, setRecentlySuccessful] = useState(false);
|
|
199
|
+
const [isDirty, setDirty] = useState(false);
|
|
200
|
+
// The page outlived its session: by a 419 to this form's last submit, or
|
|
201
|
+
// -- for a post made without script -- by the flash it was sent back with.
|
|
202
|
+
const [expired, setExpired] = useState(() => flashed?.expired === true);
|
|
203
|
+
|
|
204
|
+
/** The fields as they stood when the form mounted, or were last made the defaults. */
|
|
205
|
+
const defaults = useRef(/** @type {string | null} */ (null));
|
|
206
|
+
/**
|
|
207
|
+
* What each field held when setDefaultsOnSuccess last made it the default,
|
|
208
|
+
* for reset() to put back. Not the elements' own defaults: React sets
|
|
209
|
+
* those again from the `defaultValue` it renders, so a reset after a save
|
|
210
|
+
* went back past it, to what the markup first said.
|
|
211
|
+
*/
|
|
212
|
+
const held = useRef(/** @type {WeakMap<Element, unknown> | null} */ (null));
|
|
213
|
+
const inFlight = useRef(/** @type {AbortController | null} */ (null));
|
|
214
|
+
const recently = useRef(/** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined));
|
|
215
|
+
|
|
216
|
+
// The latest props, for a submission that started under older ones.
|
|
217
|
+
const latest = useRef(props);
|
|
218
|
+
latest.current = props;
|
|
219
|
+
|
|
220
|
+
const snapshot = useCallback(() => (formRef.current ? serialize(formToObject(formRef.current)) : null), []);
|
|
221
|
+
const updateDirty = useCallback(() => {
|
|
222
|
+
const now = snapshot();
|
|
223
|
+
setDirty(now !== null && defaults.current !== null && now !== defaults.current);
|
|
224
|
+
}, [snapshot]);
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
defaults.current = snapshot();
|
|
228
|
+
return () => {
|
|
229
|
+
clearTimeout(recently.current);
|
|
230
|
+
inFlight.current?.abort();
|
|
231
|
+
};
|
|
232
|
+
}, [snapshot]);
|
|
233
|
+
|
|
234
|
+
// The browser's own events, not React's onChange: React reports a change
|
|
235
|
+
// only to a value it is tracking, and these inputs are the form's, not
|
|
236
|
+
// React's -- an input whose value a password manager or a script set
|
|
237
|
+
// never reaches onChange at all.
|
|
238
|
+
//
|
|
239
|
+
// Read once the event is over, not in it. A state update made in a native
|
|
240
|
+
// listener renders at the next microtask -- between this listener and
|
|
241
|
+
// React's own, further up -- and that render put a controlled input's old
|
|
242
|
+
// value back before React saw the new one: typing into one did nothing.
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const form = formRef.current;
|
|
245
|
+
if (!form) return;
|
|
246
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
247
|
+
let pending;
|
|
248
|
+
const later = () => {
|
|
249
|
+
clearTimeout(pending);
|
|
250
|
+
pending = setTimeout(updateDirty, 0);
|
|
251
|
+
};
|
|
252
|
+
form.addEventListener('input', later);
|
|
253
|
+
form.addEventListener('change', later);
|
|
254
|
+
return () => {
|
|
255
|
+
clearTimeout(pending);
|
|
256
|
+
form.removeEventListener('input', later);
|
|
257
|
+
form.removeEventListener('change', later);
|
|
258
|
+
};
|
|
259
|
+
}, [updateDirty]);
|
|
260
|
+
|
|
261
|
+
const reset = useCallback(
|
|
262
|
+
(/** @type {string[]} */ ...fields) => {
|
|
263
|
+
const form = formRef.current;
|
|
264
|
+
if (!form) return;
|
|
265
|
+
if (fields.length === 0) form.reset();
|
|
266
|
+
for (const element of form.elements) {
|
|
267
|
+
if (fields.length > 0 && !fields.includes(fieldOf(element))) continue;
|
|
268
|
+
if (held.current?.has(element)) restoreElement(element, held.current.get(element));
|
|
269
|
+
else if (fields.length > 0) resetElement(element);
|
|
270
|
+
}
|
|
271
|
+
updateDirty();
|
|
272
|
+
},
|
|
273
|
+
[updateDirty],
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const setError = useCallback(
|
|
277
|
+
(/** @type {string | Record<string, string>} */ field, /** @type {string | undefined} */ message) => {
|
|
278
|
+
setErrors((now) =>
|
|
279
|
+
typeof field === 'string' ? { ...now, [field]: message ?? '' } : { ...now, ...field },
|
|
280
|
+
);
|
|
281
|
+
},
|
|
282
|
+
[],
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const clearErrors = useCallback((/** @type {string[]} */ ...fields) => {
|
|
286
|
+
setErrors((now) => {
|
|
287
|
+
if (fields.length === 0) return EMPTY;
|
|
288
|
+
const next = { ...now };
|
|
289
|
+
for (const field of fields) delete next[field];
|
|
290
|
+
return next;
|
|
291
|
+
});
|
|
292
|
+
}, []);
|
|
293
|
+
|
|
294
|
+
const cancel = useCallback(() => {
|
|
295
|
+
inFlight.current?.abort();
|
|
296
|
+
}, []);
|
|
297
|
+
|
|
298
|
+
// validate('email'): the action's own form request, asked about the field
|
|
299
|
+
// as it stands in the form, without the action running -- or an
|
|
300
|
+
// endpoint's, given `validateAgainst={endpoint.precognitive(...)}`
|
|
301
|
+
// (ADR 0125).
|
|
302
|
+
const precognition = usePrecognition({
|
|
303
|
+
action: () => latest.current.validateAgainst ?? latest.current.action,
|
|
304
|
+
data: () => {
|
|
305
|
+
const data = formRef.current ? formToObject(formRef.current) : {};
|
|
306
|
+
return typeof latest.current.transform === 'function' ? latest.current.transform(data) : data;
|
|
307
|
+
},
|
|
308
|
+
errors,
|
|
309
|
+
setErrors,
|
|
310
|
+
timeout: validationTimeout,
|
|
311
|
+
files: validateFiles,
|
|
312
|
+
});
|
|
313
|
+
const { cancelValidation } = precognition;
|
|
314
|
+
|
|
315
|
+
const send = useCallback(
|
|
316
|
+
async (/** @type {HTMLElement | null} */ submitter) => {
|
|
317
|
+
const form = formRef.current;
|
|
318
|
+
if (!form) return;
|
|
319
|
+
const p = latest.current;
|
|
320
|
+
|
|
321
|
+
let data = formToObject(form, submitter);
|
|
322
|
+
if (typeof p.transform === 'function') data = p.transform(data);
|
|
323
|
+
if (typeof p.onBefore === 'function' && p.onBefore(data) === false) return;
|
|
324
|
+
|
|
325
|
+
// The submission validates everything; an answer about one field
|
|
326
|
+
// arriving after it would only be older news.
|
|
327
|
+
cancelValidation();
|
|
328
|
+
|
|
329
|
+
// A second submit replaces the first, as a second click on a link
|
|
330
|
+
// does: the answer that matters is the one to what was sent last.
|
|
331
|
+
inFlight.current?.abort();
|
|
332
|
+
const controller = new AbortController();
|
|
333
|
+
inFlight.current = controller;
|
|
334
|
+
const current = () => inFlight.current === controller;
|
|
335
|
+
|
|
336
|
+
setProcessing(true);
|
|
337
|
+
setProgress(null);
|
|
338
|
+
setWasSuccessful(false);
|
|
339
|
+
setRecentlySuccessful(false);
|
|
340
|
+
setExpired(false);
|
|
341
|
+
clearTimeout(recently.current);
|
|
342
|
+
|
|
343
|
+
const outcome = await submitAction({
|
|
344
|
+
action: p.action,
|
|
345
|
+
data,
|
|
346
|
+
router,
|
|
347
|
+
options: p.options ?? {},
|
|
348
|
+
headers: p.headers ?? {},
|
|
349
|
+
signal: controller.signal,
|
|
350
|
+
hardNavigate: session?.hardNavigate,
|
|
351
|
+
optimistic,
|
|
352
|
+
callbacks: {
|
|
353
|
+
onStart: p.onStart,
|
|
354
|
+
onProgress: (progress) => {
|
|
355
|
+
if (current()) setProgress(progress);
|
|
356
|
+
p.onProgress?.(progress);
|
|
357
|
+
},
|
|
358
|
+
onSuccess: p.onSuccess,
|
|
359
|
+
onError: p.onError,
|
|
360
|
+
onHttpException: p.onHttpException,
|
|
361
|
+
onNetworkError: p.onNetworkError,
|
|
362
|
+
onCancel: p.onCancel,
|
|
363
|
+
onFinish: p.onFinish,
|
|
364
|
+
onExpired: p.onExpired,
|
|
365
|
+
onFlash: (flash) => {
|
|
366
|
+
session?.setFlash(flash);
|
|
367
|
+
p.onFlash?.(flash);
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Superseded: the later submission owns the form's state now.
|
|
373
|
+
if (!current()) return;
|
|
374
|
+
inFlight.current = null;
|
|
375
|
+
setProcessing(false);
|
|
376
|
+
setProgress(null);
|
|
377
|
+
|
|
378
|
+
if (outcome.type === 'expired') setExpired(true);
|
|
379
|
+
|
|
380
|
+
if (outcome.type === 'success') {
|
|
381
|
+
setErrors(EMPTY);
|
|
382
|
+
setWasSuccessful(true);
|
|
383
|
+
setRecentlySuccessful(true);
|
|
384
|
+
recently.current = setTimeout(() => setRecentlySuccessful(false), RECENTLY_SUCCESSFUL_MS);
|
|
385
|
+
resetFields(p.resetOnSuccess);
|
|
386
|
+
if (p.setDefaultsOnSuccess) {
|
|
387
|
+
if (formRef.current) held.current = valuesOf(formRef.current);
|
|
388
|
+
defaults.current = snapshot();
|
|
389
|
+
}
|
|
390
|
+
updateDirty();
|
|
391
|
+
} else if (outcome.type === 'validation') {
|
|
392
|
+
setErrors(firstMessages(outcome.errors));
|
|
393
|
+
resetFields(p.resetOnError);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** @param {boolean | string[] | undefined} which */
|
|
397
|
+
function resetFields(which) {
|
|
398
|
+
if (which === true) reset();
|
|
399
|
+
else if (Array.isArray(which) && which.length > 0) reset(...which);
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
[router, session, optimistic, snapshot, updateDirty, reset, cancelValidation],
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const submit = useCallback(() => {
|
|
406
|
+
const form = formRef.current;
|
|
407
|
+
if (!form) return;
|
|
408
|
+
// Through the form, so its own constraint validation and onSubmit run
|
|
409
|
+
// as for a click on its button.
|
|
410
|
+
if (typeof form.requestSubmit === 'function') form.requestSubmit();
|
|
411
|
+
else void send(null);
|
|
412
|
+
}, [send]);
|
|
413
|
+
|
|
414
|
+
const old = useCallback(
|
|
415
|
+
(/** @type {string} */ field, /** @type {unknown} */ fallback = undefined) => {
|
|
416
|
+
const value = getPath(flashed?.old, field);
|
|
417
|
+
return value === undefined ? fallback : value;
|
|
418
|
+
},
|
|
419
|
+
[flashed],
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const hasErrors = Object.keys(errors).length > 0;
|
|
423
|
+
const { validate, validating, valid, invalid } = precognition;
|
|
424
|
+
const slot = useMemo(
|
|
425
|
+
() => ({
|
|
426
|
+
errors,
|
|
427
|
+
hasErrors,
|
|
428
|
+
processing,
|
|
429
|
+
progress,
|
|
430
|
+
wasSuccessful,
|
|
431
|
+
recentlySuccessful,
|
|
432
|
+
isDirty,
|
|
433
|
+
reset,
|
|
434
|
+
submit,
|
|
435
|
+
cancel,
|
|
436
|
+
setError,
|
|
437
|
+
clearErrors,
|
|
438
|
+
old,
|
|
439
|
+
validate,
|
|
440
|
+
validating,
|
|
441
|
+
expired,
|
|
442
|
+
valid,
|
|
443
|
+
invalid,
|
|
444
|
+
}),
|
|
445
|
+
[
|
|
446
|
+
errors,
|
|
447
|
+
hasErrors,
|
|
448
|
+
processing,
|
|
449
|
+
progress,
|
|
450
|
+
wasSuccessful,
|
|
451
|
+
recentlySuccessful,
|
|
452
|
+
isDirty,
|
|
453
|
+
reset,
|
|
454
|
+
submit,
|
|
455
|
+
cancel,
|
|
456
|
+
setError,
|
|
457
|
+
clearErrors,
|
|
458
|
+
old,
|
|
459
|
+
validate,
|
|
460
|
+
validating,
|
|
461
|
+
expired,
|
|
462
|
+
valid,
|
|
463
|
+
invalid,
|
|
464
|
+
],
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
/** @param {any} event */
|
|
468
|
+
const onSubmit = (event) => {
|
|
469
|
+
appOnSubmit?.(event);
|
|
470
|
+
// The app's handler may stop it -- its own checks found a problem.
|
|
471
|
+
if (event.defaultPrevented) return;
|
|
472
|
+
event.preventDefault();
|
|
473
|
+
void send(event.nativeEvent?.submitter ?? null);
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const token = session?.csrfToken ?? null;
|
|
477
|
+
const content = typeof children === 'function' ? children(slot) : children;
|
|
478
|
+
|
|
479
|
+
return createElement(
|
|
480
|
+
FormContext.Provider,
|
|
481
|
+
{ value: slot },
|
|
482
|
+
createElement(
|
|
483
|
+
'form',
|
|
484
|
+
{
|
|
485
|
+
...attributes,
|
|
486
|
+
ref: formRef,
|
|
487
|
+
method: 'post',
|
|
488
|
+
action: actionUrl(name),
|
|
489
|
+
onSubmit,
|
|
490
|
+
inert: disableWhileProcessing && processing ? true : undefined,
|
|
491
|
+
},
|
|
492
|
+
// Only with a token to put in it: "Stored output never contains a
|
|
493
|
+
// CSRF token", and a form rendered without one reads the cookie when
|
|
494
|
+
// it is submitted by script.
|
|
495
|
+
token === null ? null : createElement('input', { type: 'hidden', name: TOKEN_FIELD, value: token }),
|
|
496
|
+
content,
|
|
497
|
+
),
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The first message for each field, which is what a field shows.
|
|
503
|
+
*
|
|
504
|
+
* @param {unknown} errors
|
|
505
|
+
* @returns {Record<string, string>}
|
|
506
|
+
*/
|
|
507
|
+
export function firstMessages(errors) {
|
|
508
|
+
/** @type {Record<string, string>} */
|
|
509
|
+
const out = {};
|
|
510
|
+
for (const [field, messages] of Object.entries(normalizeErrors(errors))) {
|
|
511
|
+
if (messages[0] !== undefined) out[field] = messages[0];
|
|
512
|
+
}
|
|
513
|
+
return Object.keys(out).length === 0 ? EMPTY : out;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The flash as the server sent it, made safe to read: a PHP empty array
|
|
518
|
+
* crosses as a list, and anything not shaped as expected is nothing.
|
|
519
|
+
*
|
|
520
|
+
* @param {unknown} flash
|
|
521
|
+
* @returns {Flashed}
|
|
522
|
+
*/
|
|
523
|
+
function normalizeFlashed(flash) {
|
|
524
|
+
if (flash === null || typeof flash !== 'object' || Array.isArray(flash)) return EMPTY;
|
|
525
|
+
const raw = /** @type {Record<string, any>} */ (flash);
|
|
526
|
+
/** @type {Flashed} */
|
|
527
|
+
const out = {};
|
|
528
|
+
if (raw.flash && typeof raw.flash === 'object' && !Array.isArray(raw.flash)) out.flash = raw.flash;
|
|
529
|
+
if (raw.form && typeof raw.form === 'object' && typeof raw.form.action === 'string') {
|
|
530
|
+
out.form = {
|
|
531
|
+
action: raw.form.action,
|
|
532
|
+
errors: normalizeErrors(raw.form.errors),
|
|
533
|
+
old:
|
|
534
|
+
raw.form.old && typeof raw.form.old === 'object' && !Array.isArray(raw.form.old) ? raw.form.old : {},
|
|
535
|
+
expired: raw.form.expired === true,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* A page's URL without its fragment, which names a place on the page, not
|
|
543
|
+
* another page.
|
|
544
|
+
*
|
|
545
|
+
* @param {unknown} href
|
|
546
|
+
*/
|
|
547
|
+
function pageOf(href) {
|
|
548
|
+
if (typeof href !== 'string') return '';
|
|
549
|
+
const at = href.indexOf('#');
|
|
550
|
+
return at === -1 ? href : href.slice(0, at);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The fields' values as a string to compare: files by name and size, since their bytes are not read. */
|
|
554
|
+
function serialize(/** @type {unknown} */ data) {
|
|
555
|
+
return JSON.stringify(data, (_key, value) =>
|
|
556
|
+
typeof File !== 'undefined' && value instanceof File ? `file:${value.name}:${value.size}` : value,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** A form element's field name without a trailing `[]`, which is how reset() names it. */
|
|
561
|
+
function fieldOf(/** @type {Element} */ element) {
|
|
562
|
+
const name = /** @type {HTMLInputElement} */ (element).name ?? '';
|
|
563
|
+
return name.endsWith('[]') ? name.slice(0, -2) : name;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* What each field of a form holds, by element: a box's checked state, a
|
|
568
|
+
* select's selected options, any other field's value. Files are left out;
|
|
569
|
+
* a reset empties them, as the browser's does.
|
|
570
|
+
*
|
|
571
|
+
* @param {HTMLFormElement} form
|
|
572
|
+
* @returns {WeakMap<Element, unknown>}
|
|
573
|
+
*/
|
|
574
|
+
function valuesOf(form) {
|
|
575
|
+
const values = new WeakMap();
|
|
576
|
+
for (const element of form.elements) {
|
|
577
|
+
if (element instanceof HTMLInputElement) {
|
|
578
|
+
if (element.type === 'checkbox' || element.type === 'radio') values.set(element, element.checked);
|
|
579
|
+
else if (element.type !== 'file') values.set(element, element.value);
|
|
580
|
+
} else if (element instanceof HTMLTextAreaElement) {
|
|
581
|
+
values.set(element, element.value);
|
|
582
|
+
} else if (element instanceof HTMLSelectElement) {
|
|
583
|
+
values.set(
|
|
584
|
+
element,
|
|
585
|
+
[...element.options].map((option) => option.selected),
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return values;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Puts back what valuesOf() found a field holding. */
|
|
593
|
+
function restoreElement(/** @type {Element} */ element, /** @type {unknown} */ value) {
|
|
594
|
+
if (element instanceof HTMLInputElement) {
|
|
595
|
+
if (element.type === 'checkbox' || element.type === 'radio') element.checked = value === true;
|
|
596
|
+
else element.value = String(value);
|
|
597
|
+
} else if (element instanceof HTMLTextAreaElement) {
|
|
598
|
+
element.value = String(value);
|
|
599
|
+
} else if (element instanceof HTMLSelectElement && Array.isArray(value)) {
|
|
600
|
+
[...element.options].forEach((option, i) => (option.selected = value[i] === true));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Puts one field back to its default, as form.reset() would. */
|
|
605
|
+
function resetElement(/** @type {Element} */ element) {
|
|
606
|
+
if (element instanceof HTMLInputElement) {
|
|
607
|
+
if (element.type === 'checkbox' || element.type === 'radio') element.checked = element.defaultChecked;
|
|
608
|
+
else if (element.type !== 'file') element.value = element.defaultValue;
|
|
609
|
+
else element.value = '';
|
|
610
|
+
} else if (element instanceof HTMLTextAreaElement) {
|
|
611
|
+
element.value = element.defaultValue;
|
|
612
|
+
} else if (element instanceof HTMLSelectElement) {
|
|
613
|
+
for (const option of element.options) option.selected = option.defaultSelected;
|
|
614
|
+
}
|
|
615
|
+
}
|
package/src/forms.d.ts
ADDED
package/src/forms.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @tinvar/react/form -- forms and mutations, from one entry.
|
|
2
|
+
//
|
|
3
|
+
// Each module it gathers is a client module of its own ('use client'), so a
|
|
4
|
+
// server component importing <Form> or <FormSession> from here gets a client
|
|
5
|
+
// reference, as it would importing the module itself.
|
|
6
|
+
|
|
7
|
+
export { Form, FormSession, useFlash, useFormContext, RECENTLY_SUCCESSFUL_MS } from './form.js';
|
|
8
|
+
export { useForm, FormProvider } from './use-form.js';
|
|
9
|
+
export { useHttp, query, QUERY_PREFIX } from './use-http.js';
|
|
10
|
+
export { OptimisticState, useOptimisticState } from './optimistic.js';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Fetching one deferred subtree. */
|
|
2
|
+
|
|
3
|
+
export declare const FRAGMENT_PREFIX: '/_fragment/';
|
|
4
|
+
export declare const TOKEN_HEADER: 'x-fragment-token';
|
|
5
|
+
export declare const FRAGMENT_CONTENT_TYPE: 'text/x-component';
|
|
6
|
+
export declare const MIN_INTERVAL_MS: number;
|
|
7
|
+
export declare const MAX_FAILURES: number;
|
|
8
|
+
|
|
9
|
+
export declare class FragmentError extends Error {
|
|
10
|
+
constructor(message: string, status: number, digest?: string | null);
|
|
11
|
+
status: number;
|
|
12
|
+
/** What the server logged a 5xx under, when its answer said. */
|
|
13
|
+
digest: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether a failed load was the server's render throwing -- a 5xx, or an
|
|
18
|
+
* error row the renderer wrote -- with the digest it was reported under.
|
|
19
|
+
* Null for any other failure.
|
|
20
|
+
*/
|
|
21
|
+
export declare function serverFailure(error: unknown): { digest: string | null } | null;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Holds one boundary's token across refetches. The token rotates on every
|
|
25
|
+
* successful response, which is what lets a refresh boundary outlive the hour
|
|
26
|
+
* the first one was issued for.
|
|
27
|
+
*/
|
|
28
|
+
export declare class FragmentHandle {
|
|
29
|
+
constructor(token: string, fetchImpl?: typeof fetch);
|
|
30
|
+
readonly token: string;
|
|
31
|
+
/** Whether this handle has given up: a 404, or too many failures. */
|
|
32
|
+
readonly terminal: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The live token the last successful load came with: the topics of the
|
|
35
|
+
* tags its render read. Null when it read none.
|
|
36
|
+
*/
|
|
37
|
+
readonly liveToken: string | null;
|
|
38
|
+
/** Takes a fresh token for the same boundary, keeping the pacing. */
|
|
39
|
+
adopt(token: string): void;
|
|
40
|
+
/** `headers`: the once-shared holds to claim, X-Have-Shared alone (ADR 0074). */
|
|
41
|
+
load(options?: {
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
headers?: Record<string, string>;
|
|
44
|
+
}): Promise<ReadableStream<Uint8Array>>;
|
|
45
|
+
/**
|
|
46
|
+
* Fetches and decodes. A body that fails to decode counts as a failed
|
|
47
|
+
* load, against the same backoff and limit as a failed request.
|
|
48
|
+
*/
|
|
49
|
+
load<T>(options: {
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
decode: (stream: ReadableStream<Uint8Array>) => T | Promise<T>;
|
|
52
|
+
headers?: Record<string, string>;
|
|
53
|
+
}): Promise<T>;
|
|
54
|
+
/**
|
|
55
|
+
* Fetches a <Deferred group>'s boundaries in one request, resolving with
|
|
56
|
+
* each one's subtree in order. The handles' backoff, limits and rotation
|
|
57
|
+
* hold for all of them at once.
|
|
58
|
+
*/
|
|
59
|
+
static loadGroup(
|
|
60
|
+
handles: FragmentHandle[],
|
|
61
|
+
options: {
|
|
62
|
+
signal?: AbortSignal;
|
|
63
|
+
decode: (stream: ReadableStream<Uint8Array>) => unknown;
|
|
64
|
+
headers?: Record<string, string>;
|
|
65
|
+
},
|
|
66
|
+
): Promise<unknown[]>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A token's signed claims without its issue and expiry times; equal for re-issues of the same boundary. */
|
|
70
|
+
export declare function tokenIdentity(token: string | null): string | null;
|