@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.
- 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/poll.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// usePoll: the page on screen, refreshed on a timer.
|
|
4
|
+
//
|
|
5
|
+
// tinvar-spec.md, "Client router and components": "`usePoll(ms, options,
|
|
6
|
+
// { mode, background })` with `mode: 'overlap' | 'cancel' | 'rest'` and
|
|
7
|
+
// `background: 'throttle' | 'pause' | 'continue'`, re-running only named
|
|
8
|
+
// loader fields."
|
|
9
|
+
//
|
|
10
|
+
// Each tick is a background reload -- with `only` or `except`, a partial
|
|
11
|
+
// one -- so everything the router promises a background request holds: it
|
|
12
|
+
// never navigates, never cancels a visit the user made, waits while one is
|
|
13
|
+
// in flight, and a version mismatch fires the cancelable `location` event
|
|
14
|
+
// instead of reloading. A 401 still loads the page, as for every request:
|
|
15
|
+
// the session is gone.
|
|
16
|
+
//
|
|
17
|
+
// The timing lives in createPoller(), which knows nothing of React, so the
|
|
18
|
+
// modes can be read and tested without rendering anything.
|
|
19
|
+
|
|
20
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
21
|
+
import { refreshDeferred } from './deferred-revalidation.js';
|
|
22
|
+
import { useOptionalRouter } from './link.js';
|
|
23
|
+
|
|
24
|
+
/** How much slower a `throttle` poll runs while its tab is in the background. */
|
|
25
|
+
export const THROTTLE_FACTOR = 10;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {'overlap' | 'cancel' | 'rest'} PollMode
|
|
29
|
+
* @typedef {'throttle' | 'pause' | 'continue'} PollBackground
|
|
30
|
+
*
|
|
31
|
+
* @typedef {{
|
|
32
|
+
* mode?: PollMode,
|
|
33
|
+
* background?: PollBackground,
|
|
34
|
+
* autoStart?: boolean,
|
|
35
|
+
* deferred?: boolean,
|
|
36
|
+
* }} PollSettings
|
|
37
|
+
*
|
|
38
|
+
* `deferred` also asks the page's loaded <Deferred> boundaries to fetch
|
|
39
|
+
* again after each poll, quietly (refreshDeferred()): a reload keeps a
|
|
40
|
+
* boundary's content, so what a poll is for -- an order's shipping
|
|
41
|
+
* timeline, say -- would otherwise never move.
|
|
42
|
+
*
|
|
43
|
+
* `mode` is what a tick does about the one before it, still in flight:
|
|
44
|
+
* `rest` (the default) waits for it, and the interval runs from when it
|
|
45
|
+
* settled, so a slow server is never asked again before it has answered;
|
|
46
|
+
* `cancel` cancels it; `overlap` lets both run, and an older answer that
|
|
47
|
+
* arrives after a newer one is dropped by the router.
|
|
48
|
+
*
|
|
49
|
+
* `background` is what happens while the tab is hidden, by the Page
|
|
50
|
+
* Visibility API: `throttle` (the default) polls ten times less often,
|
|
51
|
+
* `pause` stops until the tab is shown again, `continue` carries on.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {{
|
|
56
|
+
* router: Pick<import('./router.js').Router, 'reload'>,
|
|
57
|
+
* interval: number,
|
|
58
|
+
* options?: () => import('./router.js').VisitOptions,
|
|
59
|
+
* mode?: PollMode,
|
|
60
|
+
* background?: PollBackground,
|
|
61
|
+
* document?: Document,
|
|
62
|
+
* now?: () => number,
|
|
63
|
+
* onPolled?: () => void,
|
|
64
|
+
* }} settings
|
|
65
|
+
* @returns {{ start: () => void, stop: () => void, readonly running: boolean }}
|
|
66
|
+
*/
|
|
67
|
+
export function createPoller({
|
|
68
|
+
router,
|
|
69
|
+
interval,
|
|
70
|
+
options = () => ({}),
|
|
71
|
+
mode = 'rest',
|
|
72
|
+
background = 'throttle',
|
|
73
|
+
document: doc = globalThis.document,
|
|
74
|
+
now = Date.now,
|
|
75
|
+
onPolled = () => {},
|
|
76
|
+
}) {
|
|
77
|
+
if (!Number.isFinite(interval) || interval <= 0) {
|
|
78
|
+
throw new TypeError(`[tinvar:client] A poll needs a positive interval in ms, not ${interval}.`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let running = false;
|
|
82
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
83
|
+
let timer = null;
|
|
84
|
+
/** When the last poll was sent. */
|
|
85
|
+
let last = now();
|
|
86
|
+
/** @type {Set<AbortController>} */
|
|
87
|
+
const inFlight = new Set();
|
|
88
|
+
|
|
89
|
+
const hidden = () => doc?.visibilityState === 'hidden';
|
|
90
|
+
const every = () => (hidden() && background === 'throttle' ? interval * THROTTLE_FACTOR : interval);
|
|
91
|
+
|
|
92
|
+
function clear() {
|
|
93
|
+
if (timer !== null) clearTimeout(timer);
|
|
94
|
+
timer = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {number} ms */
|
|
98
|
+
function schedule(ms) {
|
|
99
|
+
clear();
|
|
100
|
+
if (!running) return;
|
|
101
|
+
// Paused: nothing is due until the tab is shown again.
|
|
102
|
+
if (hidden() && background === 'pause') return;
|
|
103
|
+
timer = setTimeout(tick, Math.max(0, ms));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function tick() {
|
|
107
|
+
timer = null;
|
|
108
|
+
if (!running) return;
|
|
109
|
+
|
|
110
|
+
if (mode === 'cancel') {
|
|
111
|
+
for (const c of inFlight) c.abort();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
inFlight.add(controller);
|
|
116
|
+
last = now();
|
|
117
|
+
|
|
118
|
+
// A background reload: the router waits out a visit in flight, mounts
|
|
119
|
+
// only onto the page it was asked for, and never moves the page.
|
|
120
|
+
Promise.resolve()
|
|
121
|
+
.then(() => router.reload({ ...options(), background: true, signal: controller.signal }))
|
|
122
|
+
// After a poll that landed, not one that failed or was cancelled. The
|
|
123
|
+
// router reports which: a failed poll resolves as well as one that
|
|
124
|
+
// mounted -- it is reported on the router's own events -- and so does
|
|
125
|
+
// one never sent because a visit was under way.
|
|
126
|
+
.then((mounted) => {
|
|
127
|
+
if (mounted === true && !controller.signal.aborted) onPolled();
|
|
128
|
+
})
|
|
129
|
+
.catch(() => {})
|
|
130
|
+
.finally(() => {
|
|
131
|
+
inFlight.delete(controller);
|
|
132
|
+
if (mode === 'rest' && running) schedule(every());
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (mode !== 'rest') schedule(every());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function onVisibility() {
|
|
139
|
+
if (!running) return;
|
|
140
|
+
// A poll resting on one in flight is rescheduled when that settles.
|
|
141
|
+
if (mode === 'rest' && inFlight.size > 0) return;
|
|
142
|
+
|
|
143
|
+
// Due from when the last one was sent, at the rate that applies now: a
|
|
144
|
+
// tab shown again after longer than an interval polls at once.
|
|
145
|
+
schedule(last + every() - now());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
get running() {
|
|
150
|
+
return running;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
start() {
|
|
154
|
+
if (running) return;
|
|
155
|
+
running = true;
|
|
156
|
+
last = now();
|
|
157
|
+
doc?.addEventListener('visibilitychange', onVisibility);
|
|
158
|
+
schedule(every());
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
stop() {
|
|
162
|
+
if (!running) return;
|
|
163
|
+
running = false;
|
|
164
|
+
clear();
|
|
165
|
+
doc?.removeEventListener('visibilitychange', onVisibility);
|
|
166
|
+
for (const c of inFlight) c.abort();
|
|
167
|
+
inFlight.clear();
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Polls the page on screen every `interval` ms while the component is
|
|
174
|
+
* mounted, through background reloads -- partial ones, given `only` or
|
|
175
|
+
* `except` -- and stops when it unmounts. Returns `start` and `stop` for a
|
|
176
|
+
* poll that should run only some of the time (`autoStart: false`).
|
|
177
|
+
*
|
|
178
|
+
* The reload options are read at each tick, so they can change between
|
|
179
|
+
* renders without restarting the poll.
|
|
180
|
+
*
|
|
181
|
+
* @param {number} interval
|
|
182
|
+
* @param {Omit<import('./router.js').VisitOptions, 'background' | 'signal'>} [options]
|
|
183
|
+
* @param {PollSettings} [settings]
|
|
184
|
+
* @returns {{ start: () => void, stop: () => void }}
|
|
185
|
+
*/
|
|
186
|
+
export function usePoll(
|
|
187
|
+
interval,
|
|
188
|
+
options = {},
|
|
189
|
+
{ mode = 'rest', background = 'throttle', autoStart = true, deferred = false } = {},
|
|
190
|
+
) {
|
|
191
|
+
const router = useOptionalRouter();
|
|
192
|
+
const latest = useRef(options);
|
|
193
|
+
latest.current = options;
|
|
194
|
+
const poller = useRef(/** @type {ReturnType<typeof createPoller> | null} */ (null));
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
// A server render, or a test without a router: nothing to poll with.
|
|
198
|
+
if (router === null) return undefined;
|
|
199
|
+
|
|
200
|
+
const created = createPoller({
|
|
201
|
+
router,
|
|
202
|
+
interval,
|
|
203
|
+
options: () => latest.current,
|
|
204
|
+
mode,
|
|
205
|
+
background,
|
|
206
|
+
onPolled: deferred ? refreshDeferred : undefined,
|
|
207
|
+
});
|
|
208
|
+
poller.current = created;
|
|
209
|
+
if (autoStart) created.start();
|
|
210
|
+
|
|
211
|
+
return () => {
|
|
212
|
+
created.stop();
|
|
213
|
+
if (poller.current === created) poller.current = null;
|
|
214
|
+
};
|
|
215
|
+
}, [router, interval, mode, background, autoStart, deferred]);
|
|
216
|
+
|
|
217
|
+
return useMemo(
|
|
218
|
+
() => ({
|
|
219
|
+
start: () => poller.current?.start(),
|
|
220
|
+
stop: () => poller.current?.stop(),
|
|
221
|
+
}),
|
|
222
|
+
[],
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Precognition: asking an action whether a field is valid, without running it.
|
|
2
|
+
//
|
|
3
|
+
// tinvar-spec.md, "Forms and mutations": "an action can be called in
|
|
4
|
+
// validate-only mode (`validate('email')`, debounced, files excluded by
|
|
5
|
+
// default), running its declared authorization and form request without the
|
|
6
|
+
// method body." The endpoint does the server half: `Precognition: true` and
|
|
7
|
+
// `Precognition-Validate-Only: email` run the order up to validation and
|
|
8
|
+
// stop, answering 204 when the fields are fine and 422 with their errors
|
|
9
|
+
// when not.
|
|
10
|
+
//
|
|
11
|
+
// What is asked is an action, or an endpoint (ADR 0125): `endpoint.
|
|
12
|
+
// precognitive(name, { params })` is a target whose own `precognition`
|
|
13
|
+
// asks the endpoint through `endpoint.validate()` -- the same headers, its
|
|
14
|
+
// CSRF token, its checks -- and answers each field's errors, or null for
|
|
15
|
+
// an answer that is no verdict on them.
|
|
16
|
+
//
|
|
17
|
+
// This is the client half, shared by <Form> and useForm: fields asked about
|
|
18
|
+
// within the timeout go together in one request, the request still out is
|
|
19
|
+
// replaced by the next (its fields go with it), and the answer touches only
|
|
20
|
+
// the fields that were asked about -- an error elsewhere on the form, from a
|
|
21
|
+
// submit, stays where it is.
|
|
22
|
+
|
|
23
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
24
|
+
import { actionUrl } from './action.js';
|
|
25
|
+
import { withoutFiles } from './form-data.js';
|
|
26
|
+
import { send } from './transport.js';
|
|
27
|
+
|
|
28
|
+
/** Inertia's default: long enough not to ask on every keystroke. */
|
|
29
|
+
export const VALIDATION_TIMEOUT_MS = 1500;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {{ precognition: (fields: string[], input: Record<string, unknown>, signal: AbortSignal) => Promise<Record<string, string[]> | null> }} PrecognitionTarget
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether $target asks for itself -- an endpoint's -- rather than being an
|
|
37
|
+
* action to post to.
|
|
38
|
+
*
|
|
39
|
+
* @param {unknown} target
|
|
40
|
+
* @returns {target is PrecognitionTarget}
|
|
41
|
+
*/
|
|
42
|
+
export function isPrecognitionTarget(target) {
|
|
43
|
+
return (
|
|
44
|
+
target !== null &&
|
|
45
|
+
typeof target === 'object' &&
|
|
46
|
+
typeof (/** @type {{ precognition?: unknown }} */ (target).precognition) === 'function'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{
|
|
52
|
+
* action: () => import('./action.js').ActionTarget | PrecognitionTarget | null,
|
|
53
|
+
* data: () => Record<string, unknown>,
|
|
54
|
+
* setErrors: (update: (now: Record<string, string>) => Record<string, string>) => void,
|
|
55
|
+
* errors: Record<string, string>,
|
|
56
|
+
* timeout?: number | (() => number | undefined),
|
|
57
|
+
* files?: boolean | (() => boolean | undefined),
|
|
58
|
+
* }} options `timeout` and `files` may be read when a field is validated
|
|
59
|
+
* rather than when the hook is called -- useForm learns them after
|
|
60
|
+
*/
|
|
61
|
+
export function usePrecognition({ action, data, setErrors, errors, timeout, files }) {
|
|
62
|
+
const [validating, setValidating] = useState(false);
|
|
63
|
+
const [validated, setValidated] = useState(/** @type {ReadonlySet<string>} */ (new Set()));
|
|
64
|
+
|
|
65
|
+
const pending = useRef(/** @type {Set<string>} */ (new Set()));
|
|
66
|
+
const inFlight = useRef(/** @type {{ controller: AbortController, fields: string[] } | null} */ (null));
|
|
67
|
+
const timer = useRef(/** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined));
|
|
68
|
+
|
|
69
|
+
const latest = useRef({ action, data, setErrors, timeout, files });
|
|
70
|
+
latest.current = { action, data, setErrors, timeout, files };
|
|
71
|
+
|
|
72
|
+
const cancel = useCallback(() => {
|
|
73
|
+
clearTimeout(timer.current);
|
|
74
|
+
pending.current.clear();
|
|
75
|
+
inFlight.current?.controller.abort();
|
|
76
|
+
inFlight.current = null;
|
|
77
|
+
setValidating(false);
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
useEffect(() => cancel, [cancel]);
|
|
81
|
+
|
|
82
|
+
const run = useCallback(async () => {
|
|
83
|
+
const { action: target, data: read, setErrors: set } = latest.current;
|
|
84
|
+
const withFiles =
|
|
85
|
+
(typeof latest.current.files === 'function' ? latest.current.files() : latest.current.files) ?? false;
|
|
86
|
+
const to = target();
|
|
87
|
+
if (to === null) return;
|
|
88
|
+
|
|
89
|
+
// The request still out asked about some fields; this one asks about
|
|
90
|
+
// them too, since its answer is the one that will count.
|
|
91
|
+
const fields = [...new Set([...(inFlight.current?.fields ?? []), ...pending.current])];
|
|
92
|
+
pending.current.clear();
|
|
93
|
+
inFlight.current?.controller.abort();
|
|
94
|
+
if (fields.length === 0) return;
|
|
95
|
+
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
inFlight.current = { controller, fields };
|
|
98
|
+
setValidating(true);
|
|
99
|
+
|
|
100
|
+
const values = read();
|
|
101
|
+
const payload = /** @type {Record<string, unknown>} */ (withFiles ? values : withoutFiles(values));
|
|
102
|
+
|
|
103
|
+
/** @type {Record<string, string[]> | null} */
|
|
104
|
+
let found;
|
|
105
|
+
if (isPrecognitionTarget(to)) {
|
|
106
|
+
// An endpoint's: JSON, so never a file, whatever validateFiles says.
|
|
107
|
+
found = await to.precognition(
|
|
108
|
+
fields,
|
|
109
|
+
/** @type {Record<string, unknown>} */ (withoutFiles(values)),
|
|
110
|
+
controller.signal,
|
|
111
|
+
);
|
|
112
|
+
} else {
|
|
113
|
+
const outcome = await send({
|
|
114
|
+
url: actionUrl(to),
|
|
115
|
+
data: payload,
|
|
116
|
+
headers: { Precognition: 'true', 'Precognition-Validate-Only': fields.join(',') },
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
});
|
|
119
|
+
// A 204 says every field asked about is fine; a 422 names the ones
|
|
120
|
+
// that are not. Anything else -- a 500, no answer -- says nothing
|
|
121
|
+
// about the input, and the fields are left as they were. So does a
|
|
122
|
+
// 422 with no errors in it: the endpoint refusing the request itself,
|
|
123
|
+
// a field the action does not declare, is an HTTP problem, not a
|
|
124
|
+
// verdict on the fields. Read as one with nothing wrong, it marked
|
|
125
|
+
// every field asked about valid -- an address the server had just
|
|
126
|
+
// refused among them.
|
|
127
|
+
found =
|
|
128
|
+
outcome.type === 'success'
|
|
129
|
+
? {}
|
|
130
|
+
: outcome.type === 'validation' && namesErrors(outcome.body)
|
|
131
|
+
? outcome.errors
|
|
132
|
+
: null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (inFlight.current?.controller !== controller) return;
|
|
136
|
+
inFlight.current = null;
|
|
137
|
+
setValidating(false);
|
|
138
|
+
if (found === null) return;
|
|
139
|
+
|
|
140
|
+
set((now) => {
|
|
141
|
+
const next = { ...now };
|
|
142
|
+
for (const field of fields) {
|
|
143
|
+
const message = found[field]?.[0];
|
|
144
|
+
if (message === undefined) delete next[field];
|
|
145
|
+
else next[field] = message;
|
|
146
|
+
}
|
|
147
|
+
return next;
|
|
148
|
+
});
|
|
149
|
+
setValidated((now) => {
|
|
150
|
+
const next = new Set(now);
|
|
151
|
+
for (const field of fields) {
|
|
152
|
+
if (found[field] === undefined) next.add(field);
|
|
153
|
+
else next.delete(field);
|
|
154
|
+
}
|
|
155
|
+
return next;
|
|
156
|
+
});
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
const validate = useCallback(
|
|
160
|
+
(/** @type {string | string[]} */ field) => {
|
|
161
|
+
for (const name of Array.isArray(field) ? field : [field]) pending.current.add(name);
|
|
162
|
+
clearTimeout(timer.current);
|
|
163
|
+
const wait =
|
|
164
|
+
typeof latest.current.timeout === 'function' ? latest.current.timeout() : latest.current.timeout;
|
|
165
|
+
timer.current = setTimeout(() => void run(), wait ?? VALIDATION_TIMEOUT_MS);
|
|
166
|
+
},
|
|
167
|
+
[run],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const valid = useCallback(
|
|
171
|
+
(/** @type {string} */ field) => validated.has(field) && errors[field] === undefined,
|
|
172
|
+
[validated, errors],
|
|
173
|
+
);
|
|
174
|
+
const invalid = useCallback((/** @type {string} */ field) => errors[field] !== undefined, [errors]);
|
|
175
|
+
|
|
176
|
+
return useMemo(
|
|
177
|
+
() => ({ validate, validating, valid, invalid, cancelValidation: cancel }),
|
|
178
|
+
[validate, validating, valid, invalid, cancel],
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether a 422's body is the endpoint's verdict on the fields: it carries
|
|
184
|
+
* `errors`, the fields it found wrong.
|
|
185
|
+
*
|
|
186
|
+
* @param {any} body
|
|
187
|
+
*/
|
|
188
|
+
function namesErrors(body) {
|
|
189
|
+
return body?.errors !== null && typeof body?.errors === 'object';
|
|
190
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** The prefetch cache: pages fetched ahead of a click, kept for a while. */
|
|
2
|
+
|
|
3
|
+
/** How long a prefetched page is used for when `cacheFor` is not given, in ms. */
|
|
4
|
+
export declare const DEFAULT_CACHE_FOR: number;
|
|
5
|
+
/** How many prefetched pages are kept at once; the oldest goes first. */
|
|
6
|
+
export declare const MAX_ENTRIES: number;
|
|
7
|
+
|
|
8
|
+
/** A number of milliseconds, or `'500ms'`, `'30s'`, `'1m'`, `'2h'`, `'1d'`. Throws on anything else. */
|
|
9
|
+
export declare function parseDuration(value: number | string): number;
|
|
10
|
+
|
|
11
|
+
/** When a page stops being fresh, and when it stops being usable at all, in ms after it arrived. */
|
|
12
|
+
export declare function cacheWindow(
|
|
13
|
+
cacheFor?: number | string | readonly [number | string, number | string],
|
|
14
|
+
): { fresh: number; stale: number };
|
|
15
|
+
|
|
16
|
+
export interface Entry {
|
|
17
|
+
url: string;
|
|
18
|
+
tags: string[];
|
|
19
|
+
window: { fresh: number; stale: number };
|
|
20
|
+
response: Promise<Response | null>;
|
|
21
|
+
controller: AbortController;
|
|
22
|
+
binding: string | null;
|
|
23
|
+
/** The CSRF cookie the page held when this was asked for; it rotates with the session. */
|
|
24
|
+
csrf: string | null;
|
|
25
|
+
arrivedAt: number | null;
|
|
26
|
+
/** The #[Once] keys it was asked for with, whose fields it may have been sent without. */
|
|
27
|
+
claimed?: readonly string[];
|
|
28
|
+
/** The history key the page came with, imported; the kept response has no header for it. */
|
|
29
|
+
historyKey?: import('./remember-store.js').HistoryKey | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export declare class PrefetchCache {
|
|
33
|
+
constructor(options?: { max?: number; now?: () => number });
|
|
34
|
+
readonly size: number;
|
|
35
|
+
lookup(url: string): { entry: Entry; stale: boolean } | null;
|
|
36
|
+
add(entry: Entry): void;
|
|
37
|
+
arrived(entry: Entry, binding: string | null): boolean;
|
|
38
|
+
forget(entry: Entry): void;
|
|
39
|
+
delete(url: string): void;
|
|
40
|
+
clear(): void;
|
|
41
|
+
abortInFlight(): void;
|
|
42
|
+
/** Forgets every entry carrying any of these tags; returns how many. */
|
|
43
|
+
deleteTagged(tags: Iterable<string>): number;
|
|
44
|
+
/** Forgets every entry asked for claiming any of these once keys. */
|
|
45
|
+
deleteClaiming(keys: readonly string[]): void;
|
|
46
|
+
}
|