@tinvar/react 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +168 -4
  3. package/src/action-server.js +41 -0
  4. package/src/action.d.ts +68 -0
  5. package/src/action.js +167 -0
  6. package/src/bootstrap.js +153 -0
  7. package/src/channel.d.ts +46 -0
  8. package/src/channel.js +132 -0
  9. package/src/client-modules.d.ts +8 -0
  10. package/src/client-modules.js +88 -0
  11. package/src/client.d.ts +36 -0
  12. package/src/client.js +42 -0
  13. package/src/compose-route.d.ts +33 -0
  14. package/src/compose-route.js +107 -0
  15. package/src/csrf.d.ts +11 -0
  16. package/src/csrf.js +49 -0
  17. package/src/deferred-boundary.d.ts +61 -0
  18. package/src/deferred-boundary.js +646 -0
  19. package/src/deferred-groups.d.ts +24 -0
  20. package/src/deferred-groups.js +198 -0
  21. package/src/deferred-revalidation.d.ts +37 -0
  22. package/src/deferred-revalidation.js +108 -0
  23. package/src/deferred-triggers.d.ts +26 -0
  24. package/src/deferred-triggers.js +172 -0
  25. package/src/deferred.d.ts +24 -0
  26. package/src/deferred.jsx +100 -0
  27. package/src/document-load.js +95 -0
  28. package/src/endpoint.d.ts +87 -0
  29. package/src/endpoint.js +304 -0
  30. package/src/form-data.js +219 -0
  31. package/src/form.d.ts +177 -0
  32. package/src/form.js +615 -0
  33. package/src/forms.d.ts +4 -0
  34. package/src/forms.js +10 -0
  35. package/src/fragment-client.d.ts +70 -0
  36. package/src/fragment-client.js +510 -0
  37. package/src/head.d.ts +85 -0
  38. package/src/head.js +360 -0
  39. package/src/inbox-view.d.ts +17 -0
  40. package/src/inbox-view.js +105 -0
  41. package/src/inbox.d.ts +108 -0
  42. package/src/inbox.js +293 -0
  43. package/src/index.combined.d.ts +31 -0
  44. package/src/index.d.ts +5 -0
  45. package/src/index.js +29 -0
  46. package/src/index.server.d.ts +4 -0
  47. package/src/index.server.js +8 -0
  48. package/src/infinite-scroll.d.ts +108 -0
  49. package/src/infinite-scroll.js +715 -0
  50. package/src/layout-props.d.ts +30 -0
  51. package/src/layout-props.js +143 -0
  52. package/src/link-click.d.ts +3 -0
  53. package/src/link-click.js +68 -0
  54. package/src/link.d.ts +63 -0
  55. package/src/link.js +274 -0
  56. package/src/live.d.ts +65 -0
  57. package/src/live.js +977 -0
  58. package/src/merge-slot.d.ts +41 -0
  59. package/src/merge-slot.js +253 -0
  60. package/src/merge.d.ts +111 -0
  61. package/src/merge.js +149 -0
  62. package/src/once-slot.d.ts +14 -0
  63. package/src/once-slot.js +75 -0
  64. package/src/once-store.d.ts +26 -0
  65. package/src/once-store.js +211 -0
  66. package/src/once.d.ts +44 -0
  67. package/src/once.js +65 -0
  68. package/src/optimistic.d.ts +14 -0
  69. package/src/optimistic.js +159 -0
  70. package/src/optional.d.ts +10 -0
  71. package/src/optional.js +31 -0
  72. package/src/page-root.d.ts +56 -0
  73. package/src/page-root.js +455 -0
  74. package/src/payload.d.ts +15 -0
  75. package/src/payload.js +101 -0
  76. package/src/poll.d.ts +55 -0
  77. package/src/poll.js +224 -0
  78. package/src/precognition.js +190 -0
  79. package/src/prefetch-cache.d.ts +46 -0
  80. package/src/prefetch-cache.js +256 -0
  81. package/src/progress.d.ts +38 -0
  82. package/src/progress.js +243 -0
  83. package/src/remember-store.d.ts +50 -0
  84. package/src/remember-store.js +486 -0
  85. package/src/rescue.d.ts +10 -0
  86. package/src/rescue.js +30 -0
  87. package/src/resumable.js +822 -0
  88. package/src/resume-store.js +364 -0
  89. package/src/route.d.ts +50 -0
  90. package/src/route.js +227 -0
  91. package/src/router-hooks.js +234 -0
  92. package/src/router.d.ts +586 -0
  93. package/src/router.js +3555 -0
  94. package/src/segment.d.ts +36 -0
  95. package/src/segment.js +116 -0
  96. package/src/server.d.ts +71 -0
  97. package/src/server.js +242 -0
  98. package/src/submit.js +333 -0
  99. package/src/telemetry.d.ts +71 -0
  100. package/src/telemetry.js +851 -0
  101. package/src/testing.d.ts +89 -0
  102. package/src/testing.js +634 -0
  103. package/src/transfer.d.ts +48 -0
  104. package/src/transfer.js +135 -0
  105. package/src/transport.js +390 -0
  106. package/src/upload.d.ts +342 -0
  107. package/src/upload.js +1240 -0
  108. package/src/use-channel.d.ts +21 -0
  109. package/src/use-channel.js +51 -0
  110. package/src/use-form.d.ts +86 -0
  111. package/src/use-form.js +432 -0
  112. package/src/use-http.d.ts +56 -0
  113. package/src/use-http.js +248 -0
  114. package/src/use-inbox.d.ts +41 -0
  115. package/src/use-inbox.js +201 -0
  116. package/src/use-remember.d.ts +10 -0
  117. package/src/use-remember.js +132 -0
  118. package/src/when-mounted.d.ts +15 -0
  119. package/src/when-mounted.js +27 -0
  120. 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
@@ -0,0 +1,4 @@
1
+ export * from './form';
2
+ export * from './use-form';
3
+ export * from './use-http';
4
+ export * from './optimistic';
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;