@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.
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
@@ -0,0 +1,21 @@
1
+ import type { ChannelError, Channels, ChannelShape } from './channel.js';
2
+
3
+ export interface UseChannelOptions {
4
+ /** A refusal -- 401, 403, 404 -- or a request that never got an answer. */
5
+ onError?: (error: ChannelError) => void;
6
+ /** The stream was down for a while: messages may have been missed. */
7
+ onGap?: () => void;
8
+ }
9
+
10
+ /**
11
+ * Listens to one channel while the component is mounted, subscribing again
12
+ * when the channel or its parameters change; null parameters listen to
13
+ * nothing yet. The latest `onMessage` is the one called.
14
+ */
15
+ export declare function useChannel<M extends { [N in keyof M]: ChannelShape }, N extends keyof M & string>(
16
+ channel: Channels<M>,
17
+ name: N,
18
+ params: M[N]['params'] | null | undefined,
19
+ onMessage: (message: M[N]['message']) => void,
20
+ options?: UseChannelOptions,
21
+ ): void;
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ // useChannel: a component listening to one of the application's #[Channel]s
4
+ // while it is mounted (ADR 0069).
5
+ //
6
+ // useChannel(channel, 'orders', { order: id }, (message) => setNotice(message));
7
+ //
8
+ // Subscribed when it mounts and whenever the channel or its parameters
9
+ // change, and unsubscribed when it unmounts. The latest `onMessage` is the
10
+ // one called, so an inline function does not subscribe again on every
11
+ // render. What it hears goes through the live module, which the app's entry
12
+ // starts (createLive): without it, nothing is traded and nothing is heard.
13
+
14
+ import { useEffect, useRef } from 'react';
15
+
16
+ /**
17
+ * @template {{ listen: (name: any, params: any, onMessage: any, options?: any) => () => void }} C
18
+ * @param {C} channel the `channel` codegen exports
19
+ * @param {string} name
20
+ * @param {Record<string, string | number | boolean> | null | undefined} params null to listen to nothing yet
21
+ * @param {(message: any) => void} onMessage
22
+ * @param {{ onError?: (error: import('./channel.js').ChannelError) => void, onGap?: () => void }} [options]
23
+ */
24
+ export function useChannel(channel, name, params, onMessage, options) {
25
+ const latest = useRef({ onMessage, options });
26
+ latest.current = { onMessage, options };
27
+
28
+ // By value: a parameters object written inline is a new object on every
29
+ // render, and the same subscription.
30
+ const key =
31
+ params == null
32
+ ? null
33
+ : JSON.stringify(Object.entries(params).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
34
+
35
+ useEffect(() => {
36
+ if (key === null) return undefined;
37
+ /** @type {Record<string, string | number | boolean>} */
38
+ const current = Object.fromEntries(JSON.parse(key));
39
+
40
+ return channel.listen(
41
+ name,
42
+ current,
43
+ (/** @type {unknown} */ message) => latest.current.onMessage(message),
44
+ {
45
+ onError: (/** @type {import('./channel.js').ChannelError} */ error) =>
46
+ latest.current.options?.onError?.(error),
47
+ onGap: () => latest.current.options?.onGap?.(),
48
+ },
49
+ );
50
+ }, [channel, name, key]);
51
+ }
@@ -0,0 +1,86 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ActionTarget } from './action';
3
+ import type { PrecognitionTarget } from './endpoint';
4
+ import type { PrecognitionSettings, Progress, SubmitCallbacks, VisitOptions } from './form';
5
+
6
+ /** What a useForm() submission came to. */
7
+ export type Submitted =
8
+ | { type: 'success'; result: import('./form').ActionResult }
9
+ | { type: 'validation'; errors: Record<string, string[]> }
10
+ | { type: 'http'; failure: import('./form').HttpFailure }
11
+ | { type: 'network'; error: unknown }
12
+ | { type: 'expired' }
13
+ | { type: 'cancelled' };
14
+
15
+ /** How one submission runs: top-level fields control it; `options`, the visit after it. */
16
+ export interface Submission extends SubmitCallbacks {
17
+ options?: VisitOptions;
18
+ headers?: Record<string, string>;
19
+ resetOnSuccess?: boolean | string[];
20
+ resetOnError?: boolean | string[];
21
+ setDefaultsOnSuccess?: boolean;
22
+ }
23
+
24
+ export interface UseForm<D extends Record<string, unknown>> {
25
+ data: D;
26
+ setData: {
27
+ <K extends keyof D>(field: K, value: D[K]): void;
28
+ (data: Partial<D>): void;
29
+ (update: (data: D) => D): void;
30
+ };
31
+ /** The first message for each field. */
32
+ errors: Partial<Record<keyof D | (string & {}), string>>;
33
+ hasErrors: boolean;
34
+ processing: boolean;
35
+ progress: Progress | null;
36
+ wasSuccessful: boolean;
37
+ /** True for two seconds after a success. */
38
+ recentlySuccessful: boolean;
39
+ /** Whether the data differs from the defaults. */
40
+ isDirty: boolean;
41
+ /** Puts the named fields -- or every field -- back to their defaults. */
42
+ reset: (...fields: (keyof D)[]) => void;
43
+ /** Makes the current data -- or the fields given -- what isDirty and reset() compare with. */
44
+ defaults: {
45
+ (): void;
46
+ <K extends keyof D>(field: K, value: D[K]): void;
47
+ (fields: Partial<D>): void;
48
+ };
49
+ setError: {
50
+ (field: keyof D | (string & {}), message: string): void;
51
+ (errors: Partial<Record<keyof D | (string & {}), string>>): void;
52
+ };
53
+ clearErrors: (...fields: (keyof D | (string & {}))[]) => void;
54
+ /** Reshapes the data before each submission. */
55
+ transform: (fn: (data: D) => Record<string, unknown>) => void;
56
+ /** Fields never kept on the history entry -- a password. Returns the form. */
57
+ dontRemember: (...fields: (keyof D)[]) => UseForm<D>;
58
+ /** Posts the data to an action and applies the answer to the page. */
59
+ submit: (action: ActionTarget, submission?: Submission) => Promise<Submitted | undefined>;
60
+ cancel: () => void;
61
+ /** The page outlived its session; see FormSlotProps.expired. */
62
+ expired: boolean;
63
+ /** Names the action -- or the endpoint, by `endpoint.precognitive()` -- validate() asks. Returns the form. */
64
+ withPrecognition: (
65
+ action: ActionTarget | PrecognitionTarget,
66
+ settings?: PrecognitionSettings,
67
+ ) => UseForm<D>;
68
+ /** Precognition: asks the action's (or endpoint's) form request about these fields, after the validation timeout. */
69
+ validate: (field: (keyof D & string) | (string & {}) | string[]) => void;
70
+ validating: boolean;
71
+ valid: (field: keyof D | (string & {})) => boolean;
72
+ invalid: (field: keyof D | (string & {})) => boolean;
73
+ }
74
+
75
+ /**
76
+ * A form whose fields React holds. Given a key, its data and errors are kept
77
+ * on the history entry, so back and forward bring them back.
78
+ */
79
+ export declare function useForm<D extends Record<string, unknown>>(data: D): UseForm<D>;
80
+ export declare function useForm<D extends Record<string, unknown>>(key: string, data: D): UseForm<D>;
81
+
82
+ /** Makes a useForm() form what useFormContext() returns below it. */
83
+ export declare function FormProvider<D extends Record<string, unknown>>(props: {
84
+ form: UseForm<D>;
85
+ children?: ReactNode;
86
+ }): ReactNode;
@@ -0,0 +1,432 @@
1
+ 'use client';
2
+
3
+ // useForm: a form whose fields React holds.
4
+ //
5
+ // tinvar-spec.md, "Forms and mutations": "`useForm(key?, data)` for
6
+ // controlled forms, with history-remembered state and
7
+ // `dontRemember('password')`. `useFormContext()` for nested inputs."
8
+ //
9
+ // The same submission as <Form> -- submit.js, so the same three error
10
+ // channels and the same answer applied to the page -- with the data in
11
+ // React state instead of the DOM. Given a key, the data and errors are kept
12
+ // for the history entry through the router's remembered state -- the store
13
+ // useRemember() keeps, bound to the session and wiped when it ends -- so
14
+ // back to a half-filled form finds it as it was left; fields named in
15
+ // dontRemember() never are. Without a router nothing is remembered.
16
+
17
+ import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
+ import {
19
+ FormContext,
20
+ firstMessages,
21
+ RECENTLY_SUCCESSFUL_MS,
22
+ useFormSession,
23
+ useOptionalRouter,
24
+ } from './form.js';
25
+ import { useOptimisticStore } from './optimistic.js';
26
+ import { usePrecognition } from './precognition.js';
27
+ import { submitAction } from './submit.js';
28
+ import { carries, useHydrated } from './use-remember.js';
29
+
30
+ const EMPTY = Object.freeze({});
31
+
32
+ /**
33
+ * @template {Record<string, unknown>} D
34
+ * @param {string | D} keyOrData
35
+ * @param {D} [maybeData]
36
+ */
37
+ export function useForm(keyOrData, maybeData) {
38
+ const key = typeof keyOrData === 'string' ? keyOrData : null;
39
+ const initial = /** @type {D} */ (typeof keyOrData === 'string' ? (maybeData ?? {}) : keyOrData);
40
+
41
+ const router = useOptionalRouter();
42
+ const optimistic = useOptimisticStore();
43
+ const session = useFormSession();
44
+
45
+ const rememberKey = key === null ? null : `form:${key}`;
46
+
47
+ // The page the form mounted on, for what it remembers: while a visit is
48
+ // mounting a page, that page, so a form on it never reads -- or writes --
49
+ // the entry being left. The router binds what it keeps to that page's
50
+ // session, and reads it back under no other.
51
+ const scope = useRef(/** @type {import('./router.js').RememberScope | null} */ (null));
52
+ if (scope.current === null && rememberKey !== null && typeof router?.rememberScope === 'function') {
53
+ scope.current = router.rememberScope();
54
+ }
55
+
56
+ const restored = useRef(
57
+ /** @type {{ data?: Partial<D>, errors?: Record<string, string> } | undefined} */ (undefined),
58
+ );
59
+ if (restored.current === undefined) {
60
+ restored.current =
61
+ rememberKey === null || scope.current === null ? {} : keptFor(router, rememberKey, scope.current);
62
+ }
63
+
64
+ // The defaults isDirty and reset() compare with: what the form was given,
65
+ // not what was restored into it.
66
+ const defaults = useRef(/** @type {D} */ (initial));
67
+ const [data, setDataState] = useState(
68
+ /** @returns {D} */ () => ({ ...initial, ...(restored.current?.data ?? {}) }),
69
+ );
70
+ const [errors, setErrors] = useState(
71
+ /** @returns {Record<string, string>} */ () => restored.current?.errors ?? EMPTY,
72
+ );
73
+ const [processing, setProcessing] = useState(false);
74
+ const [progress, setProgress] = useState(/** @type {import('./transport.js').Progress | null} */ (null));
75
+ const [wasSuccessful, setWasSuccessful] = useState(false);
76
+ const [recentlySuccessful, setRecentlySuccessful] = useState(false);
77
+ const [expired, setExpired] = useState(false);
78
+ const [, setDefaultsVersion] = useState(0);
79
+
80
+ const unremembered = useRef(/** @type {Set<string>} */ (new Set()));
81
+ const transformer = useRef(/** @type {((data: D) => Record<string, unknown>) | null} */ (null));
82
+ const inFlight = useRef(/** @type {AbortController | null} */ (null));
83
+ const recently = useRef(/** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined));
84
+ const latest = useRef(data);
85
+ latest.current = data;
86
+ const latestErrors = useRef(errors);
87
+ latestErrors.current = errors;
88
+ const latestInitial = useRef(initial);
89
+ latestInitial.current = initial;
90
+
91
+ // What the server rendered until hydration is done: the data the form was
92
+ // given, and no errors. What was remembered shows straight after; see
93
+ // useHydrated().
94
+ const hydrated = useHydrated();
95
+ const shownData = hydrated ? data : initial;
96
+ const shownErrors = hydrated ? errors : EMPTY;
97
+
98
+ /** What is kept of the data: everything but the fields named in dontRemember(). */
99
+ const rememberable = useCallback((/** @type {D} */ from) => {
100
+ /** @type {Record<string, unknown>} */
101
+ const kept = {};
102
+ for (const [field, value] of Object.entries(from)) {
103
+ if (!unremembered.current.has(field)) kept[field] = value;
104
+ }
105
+ return kept;
106
+ }, []);
107
+
108
+ useEffect(
109
+ () => () => {
110
+ clearTimeout(recently.current);
111
+ inFlight.current?.abort();
112
+ },
113
+ [],
114
+ );
115
+
116
+ // Kept for the entry as it changes. A field named in dontRemember() is
117
+ // left out entirely: a password is never written to storage.
118
+ useEffect(() => {
119
+ if (rememberKey === null || scope.current === null) return;
120
+ router?.remember(rememberKey, { data: rememberable(data), errors }, scope.current);
121
+ }, [router, rememberKey, data, errors, rememberable]);
122
+
123
+ // A form kept across a navigation -- the same page component for another
124
+ // product, or a form in a layout both pages share -- belongs to the new
125
+ // page from then on, as a useRemember() value does. It took its page once,
126
+ // as it mounted, and kept writing to that page's entry from every page
127
+ // after: what was typed on the second product went onto the first's entry,
128
+ // and back to either found the other's. So: back or forward to an entry
129
+ // that remembered the form brings that back; a visit carries what the form
130
+ // holds onto the new entry -- within one session, and never from an
131
+ // encrypted page to one that is not (carries()). Otherwise nothing it holds
132
+ // goes with it: it starts again from the data it was given.
133
+ useEffect(() => {
134
+ if (rememberKey === null || typeof router?.rememberScope !== 'function') return undefined;
135
+ return router.on('navigate', (/** @type {CustomEvent} */ event) => {
136
+ const next = router.rememberScope();
137
+ const previous = scope.current;
138
+ if (next === previous) return;
139
+ scope.current = next;
140
+
141
+ const kept = event.detail?.type === 'history' ? keptFor(router, rememberKey, next) : {};
142
+ const found = kept.data !== undefined || kept.errors !== undefined;
143
+
144
+ if (previous === null || !carries(previous, next) || found) {
145
+ setDataState({ ...latestInitial.current, ...(kept.data ?? {}) });
146
+ setErrors(kept.errors ?? EMPTY);
147
+ return;
148
+ }
149
+
150
+ router.remember(
151
+ rememberKey,
152
+ { data: rememberable(latest.current), errors: latestErrors.current },
153
+ next,
154
+ );
155
+ });
156
+ }, [router, rememberKey, rememberable]);
157
+
158
+ const setData = useCallback(
159
+ /**
160
+ * @param {keyof D | Partial<D> | ((data: D) => D)} field
161
+ * @param {unknown} [value]
162
+ */
163
+ (field, value) => {
164
+ setDataState((now) => {
165
+ if (typeof field === 'function') return field(now);
166
+ if (typeof field === 'object' && field !== null) return { ...now, ...field };
167
+ return { ...now, [field]: value };
168
+ });
169
+ },
170
+ [],
171
+ );
172
+
173
+ const reset = useCallback((/** @type {(keyof D)[]} */ ...fields) => {
174
+ setDataState((now) => {
175
+ if (fields.length === 0) return { ...defaults.current };
176
+ const next = { ...now };
177
+ for (const field of fields) next[field] = defaults.current[field];
178
+ return next;
179
+ });
180
+ }, []);
181
+
182
+ const setDefaults = useCallback(
183
+ /**
184
+ * @param {keyof D | Partial<D>} [field]
185
+ * @param {unknown} [value]
186
+ */
187
+ (field, value) => {
188
+ if (field === undefined) defaults.current = { ...latest.current };
189
+ else if (typeof field === 'object') defaults.current = { ...defaults.current, ...field };
190
+ else defaults.current = { ...defaults.current, [field]: value };
191
+ setDefaultsVersion((v) => v + 1);
192
+ },
193
+ [],
194
+ );
195
+
196
+ const setError = useCallback(
197
+ (/** @type {string | Record<string, string>} */ field, /** @type {string | undefined} */ message) => {
198
+ setErrors((now) =>
199
+ typeof field === 'string' ? { ...now, [field]: message ?? '' } : { ...now, ...field },
200
+ );
201
+ },
202
+ [],
203
+ );
204
+
205
+ const clearErrors = useCallback((/** @type {string[]} */ ...fields) => {
206
+ setErrors((now) => {
207
+ if (fields.length === 0) return EMPTY;
208
+ const next = { ...now };
209
+ for (const field of fields) delete next[field];
210
+ return Object.keys(next).length === 0 ? EMPTY : next;
211
+ });
212
+ }, []);
213
+
214
+ const cancel = useCallback(() => inFlight.current?.abort(), []);
215
+
216
+ // Precognition needs an action to ask, which a form React holds learns
217
+ // from withPrecognition(); <Form> knows its own.
218
+ const precognitive = useRef(
219
+ /** @type {{ action: import('./action.js').ActionTarget | import('./precognition.js').PrecognitionTarget | null, timeout?: number, files?: boolean }} */ ({
220
+ action: null,
221
+ }),
222
+ );
223
+ const precognition = usePrecognition({
224
+ action: () => precognitive.current.action,
225
+ data: () => {
226
+ const payload = /** @type {Record<string, unknown>} */ ({ ...latest.current });
227
+ return transformer.current ? transformer.current(/** @type {D} */ (payload)) : payload;
228
+ },
229
+ errors: shownErrors,
230
+ setErrors,
231
+ timeout: () => precognitive.current.timeout,
232
+ files: () => precognitive.current.files,
233
+ });
234
+ const { cancelValidation } = precognition;
235
+
236
+ const submit = useCallback(
237
+ /**
238
+ * Posts the data to an action, and applies the answer to the page.
239
+ * Top-level fields control the submission; `options`, the visit after it.
240
+ *
241
+ * @param {import('./action.js').ActionTarget} action
242
+ * @param {import('./submit.js').Callbacks & {
243
+ * onBefore?: (data: Record<string, unknown>) => boolean | void,
244
+ * options?: Record<string, unknown>,
245
+ * headers?: Record<string, string>,
246
+ * resetOnSuccess?: boolean | string[],
247
+ * resetOnError?: boolean | string[],
248
+ * setDefaultsOnSuccess?: boolean,
249
+ * }} [submission]
250
+ */
251
+ async (action, submission = {}) => {
252
+ let payload = /** @type {Record<string, unknown>} */ ({ ...latest.current });
253
+ if (transformer.current) payload = transformer.current(/** @type {D} */ (payload));
254
+ if (typeof submission.onBefore === 'function' && submission.onBefore(payload) === false) return;
255
+
256
+ cancelValidation();
257
+ inFlight.current?.abort();
258
+ const controller = new AbortController();
259
+ inFlight.current = controller;
260
+ const current = () => inFlight.current === controller;
261
+
262
+ setProcessing(true);
263
+ setProgress(null);
264
+ setWasSuccessful(false);
265
+ setRecentlySuccessful(false);
266
+ setExpired(false);
267
+ clearTimeout(recently.current);
268
+
269
+ const outcome = await submitAction({
270
+ action,
271
+ data: payload,
272
+ router,
273
+ options: submission.options ?? {},
274
+ headers: submission.headers ?? {},
275
+ signal: controller.signal,
276
+ hardNavigate: session?.hardNavigate,
277
+ optimistic,
278
+ callbacks: {
279
+ ...submission,
280
+ onProgress: (next) => {
281
+ if (current()) setProgress(next);
282
+ submission.onProgress?.(next);
283
+ },
284
+ onFlash: (flash) => {
285
+ session?.setFlash(flash);
286
+ submission.onFlash?.(flash);
287
+ },
288
+ },
289
+ });
290
+
291
+ if (!current()) return outcome;
292
+ inFlight.current = null;
293
+ setProcessing(false);
294
+ setProgress(null);
295
+
296
+ // The page outlived its session; the form says so, and keeps what
297
+ // was typed for the next try.
298
+ if (outcome.type === 'expired') setExpired(true);
299
+
300
+ if (outcome.type === 'success') {
301
+ setErrors(EMPTY);
302
+ setWasSuccessful(true);
303
+ setRecentlySuccessful(true);
304
+ recently.current = setTimeout(() => setRecentlySuccessful(false), RECENTLY_SUCCESSFUL_MS);
305
+ if (submission.setDefaultsOnSuccess) setDefaults();
306
+ resetFields(submission.resetOnSuccess);
307
+ } else if (outcome.type === 'validation') {
308
+ setErrors(firstMessages(outcome.errors));
309
+ resetFields(submission.resetOnError);
310
+ }
311
+ return outcome;
312
+
313
+ /** @param {boolean | string[] | undefined} which */
314
+ function resetFields(which) {
315
+ if (which === true) reset();
316
+ else if (Array.isArray(which) && which.length > 0) reset(...which);
317
+ }
318
+ },
319
+ [router, session, optimistic, reset, setDefaults, cancelValidation],
320
+ );
321
+
322
+ const transform = useCallback((/** @type {(data: D) => Record<string, unknown>} */ fn) => {
323
+ transformer.current = fn;
324
+ }, []);
325
+
326
+ // Returns the form, so it reads as Inertia's does when chained:
327
+ // useForm('sign-in', data).dontRemember('password').
328
+ const self = useRef(/** @type {any} */ (null));
329
+ const dontRemember = useCallback((/** @type {string[]} */ ...fields) => {
330
+ for (const field of fields) unremembered.current.add(field);
331
+ return self.current;
332
+ }, []);
333
+
334
+ /**
335
+ * Names what validate() asks -- an action, or an endpoint as
336
+ * `endpoint.precognitive()` names one (ADR 0125); returns the form, to
337
+ * chain from useForm().
338
+ */
339
+ const withPrecognition = useCallback(
340
+ (
341
+ /** @type {import('./action.js').ActionTarget | import('./precognition.js').PrecognitionTarget} */ action,
342
+ /** @type {{ validationTimeout?: number, validateFiles?: boolean }} */ settings = {},
343
+ ) => {
344
+ precognitive.current = { action, timeout: settings.validationTimeout, files: settings.validateFiles };
345
+ return self.current;
346
+ },
347
+ [],
348
+ );
349
+
350
+ const isDirty = JSON.stringify(shownData) !== JSON.stringify(defaults.current);
351
+ const hasErrors = Object.keys(shownErrors).length > 0;
352
+ const { validate, validating, valid, invalid } = precognition;
353
+
354
+ const form = useMemo(
355
+ () => ({
356
+ data: shownData,
357
+ setData,
358
+ errors: shownErrors,
359
+ hasErrors,
360
+ processing,
361
+ progress,
362
+ wasSuccessful,
363
+ recentlySuccessful,
364
+ isDirty,
365
+ reset,
366
+ defaults: setDefaults,
367
+ setError,
368
+ clearErrors,
369
+ transform,
370
+ dontRemember,
371
+ submit,
372
+ cancel,
373
+ expired,
374
+ withPrecognition,
375
+ validate,
376
+ validating,
377
+ valid,
378
+ invalid,
379
+ }),
380
+ [
381
+ shownData,
382
+ setData,
383
+ shownErrors,
384
+ hasErrors,
385
+ processing,
386
+ progress,
387
+ wasSuccessful,
388
+ recentlySuccessful,
389
+ isDirty,
390
+ reset,
391
+ setDefaults,
392
+ setError,
393
+ clearErrors,
394
+ transform,
395
+ dontRemember,
396
+ submit,
397
+ cancel,
398
+ expired,
399
+ withPrecognition,
400
+ validate,
401
+ validating,
402
+ valid,
403
+ invalid,
404
+ ],
405
+ );
406
+ self.current = form;
407
+ return form;
408
+ }
409
+
410
+ /**
411
+ * What was remembered for a form on a page, in the shape useForm() keeps --
412
+ * nothing, for anything else.
413
+ *
414
+ * @param {any} router
415
+ * @param {string} key
416
+ * @param {import('./router.js').RememberScope} scope
417
+ * @returns {{ data?: Record<string, any>, errors?: Record<string, string> }}
418
+ */
419
+ function keptFor(router, key, scope) {
420
+ const found = router?.remembered(key, scope);
421
+ return found !== null && typeof found === 'object' ? found : {};
422
+ }
423
+
424
+ /**
425
+ * Makes a useForm() form what useFormContext() returns below it, for inputs
426
+ * nested deep inside a form React holds -- as <Form> is for its own.
427
+ *
428
+ * @param {{ form: object, children?: unknown }} props
429
+ */
430
+ export function FormProvider({ form, children }) {
431
+ return createElement(FormContext.Provider, { value: /** @type {any} */ (form) }, children);
432
+ }
@@ -0,0 +1,56 @@
1
+ import type { ActionTarget } from './action';
2
+ import type { HttpFailure } from './form';
3
+
4
+ export declare const QUERY_PREFIX: '/_query/';
5
+
6
+ /** A reference to a PHP #[Query], for useHttp. */
7
+ export interface QueryReference {
8
+ readonly query: string;
9
+ readonly url: string;
10
+ }
11
+
12
+ export declare function query(name: string): QueryReference;
13
+
14
+ export interface HttpCallbacks<T = unknown> {
15
+ onSuccess?: (value: T) => void;
16
+ /** 422: the field errors, if the answer named fields. */
17
+ onError?: (errors: Record<string, string[]>) => void;
18
+ /** Any other status. */
19
+ onHttpException?: (failure: HttpFailure) => void;
20
+ /** No answer at all. */
21
+ onNetworkError?: (error: unknown) => void;
22
+ onCancel?: () => void;
23
+ onFinish?: () => void;
24
+ headers?: Record<string, string>;
25
+ }
26
+
27
+ export type Called<T = unknown> =
28
+ | { type: 'success'; value: T; redirect?: string }
29
+ | { type: 'validation'; errors: Record<string, string[]> }
30
+ | { type: 'http'; failure: HttpFailure }
31
+ | { type: 'network'; error: unknown }
32
+ | { type: 'cancelled' };
33
+
34
+ export interface UseHttp<T = unknown> {
35
+ /** Calls it; a call still in flight is cancelled. */
36
+ call: (args?: Record<string, unknown>, callbacks?: HttpCallbacks<T>) => Promise<Called<T>>;
37
+ /** The last successful call's query result, or action value. */
38
+ result: T | undefined;
39
+ /** The first message for each field. */
40
+ errors: Record<string, string>;
41
+ hasErrors: boolean;
42
+ processing: boolean;
43
+ wasSuccessful: boolean;
44
+ cancel: () => void;
45
+ clearErrors: () => void;
46
+ }
47
+
48
+ /**
49
+ * Calls an action or a query without moving the page: search-as-you-type.
50
+ * Nothing is mounted and no redirect followed; an action's invalidated tags
51
+ * still flush prefetched pages, and its flash is still announced.
52
+ */
53
+ export declare function useHttp<T = unknown>(
54
+ target: ActionTarget | QueryReference,
55
+ callbacks?: HttpCallbacks<T>,
56
+ ): UseHttp<T>;