@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
package/src/upload.js ADDED
@@ -0,0 +1,1240 @@
1
+ 'use client';
2
+
3
+ // Uploads: a file sent to /_upload under the ticket a page issued, and the
4
+ // reference that comes back put into the form, for the action to be called
5
+ // with.
6
+ //
7
+ // tinvar-spec.md: "Uploaded files are referenced only by a server-issued
8
+ // upload ticket bound to user, tenant, target model, key prefix and expiry.
9
+ // Storage keys are server-generated; client filenames are metadata only."
10
+ // So the file never goes with the form. The page's loader hands the form a
11
+ // ticket for one action parameter; choosing a file sends it to /_upload at
12
+ // once, with the ticket and the CSRF header, reporting progress; and what
13
+ // /_upload answers -- an opaque reference -- is what the form posts under
14
+ // the parameter's name. The action is held to everything the ticket bound
15
+ // the upload to before its body sees it.
16
+ //
17
+ // <UploadField> renders the hidden input that carries the reference and
18
+ // draws nothing else: the app draws the control, from the state it is
19
+ // handed, in its own design system. useUpload() is the same state for a
20
+ // form whose data React holds (useForm), where the app puts the reference
21
+ // into its data itself. <UploadsField> and useUploads() are the same for a
22
+ // parameter that takes several files (an UploadList): each file goes to
23
+ // /_upload under the one ticket, and the form posts every reference.
24
+ //
25
+ // A page left open past its ticket's hour has the ticket issued again
26
+ // (/_upload/refresh) the first time an upload under it is refused, and the
27
+ // upload sent once more -- for the same session and user, and never past a
28
+ // day from when the page first had it.
29
+ //
30
+ // `resumable` sends the file in pieces the server holds as they arrive
31
+ // (resumable.js, ADR 0143): a dropped network is waited out and the upload
32
+ // goes on by itself; and after a refresh, a closed tab or a crash the field
33
+ // offers to go on from where the server is -- the person chooses the file
34
+ // again, or, where the browser lets a file be reopened (the File System
35
+ // Access API), only grants permission to read it.
36
+
37
+ import { createElement, Fragment, useCallback, useEffect, useRef, useState } from 'react';
38
+ import { useFormSession, useOptionalRouter } from './form.js';
39
+ import { assignLocation, endSession } from './router-hooks.js';
40
+ import { send } from './transport.js';
41
+ import { defaultResumeStore } from './resume-store.js';
42
+ import {
43
+ abortUpload,
44
+ askOffset,
45
+ fingerprintFile,
46
+ putToStore,
47
+ uploadResumable,
48
+ uploadScope,
49
+ } from './resumable.js';
50
+
51
+ export { abortUpload } from './resumable.js';
52
+ export {
53
+ UPLOAD_ABORT_PATH,
54
+ UPLOAD_CHUNK_PATH,
55
+ UPLOAD_OFFSET_PATH,
56
+ UPLOAD_PARTS_PATH,
57
+ UPLOAD_SESSION_COMPLETE_PATH,
58
+ UPLOAD_SESSION_PATH,
59
+ } from './resumable.js';
60
+ export { indexedDbResumeStore, memoryResumeStore } from './resume-store.js';
61
+
62
+ /** Where files go: the framework's upload endpoint. */
63
+ export const UPLOAD_PATH = '/_upload';
64
+
65
+ /** Where a ticket a page has outlived is issued again. */
66
+ export const UPLOAD_REFRESH_PATH = '/_upload/refresh';
67
+
68
+ /** Where a file to go straight to the object store is announced, for a URL to send it to. */
69
+ export const UPLOAD_DIRECT_PATH = '/_upload/direct';
70
+
71
+ /** Where a file sent straight to the store is said to be there, for PHP to check and keep. */
72
+ export const UPLOAD_COMPLETE_PATH = '/_upload/complete';
73
+
74
+ /** What /_upload calls the reference it answers with: the id it drew, as hex. */
75
+ const REFERENCE = /^[a-f0-9]{32}$/;
76
+
77
+ /**
78
+ * @typedef {import('./transport.js').Progress} Progress
79
+ *
80
+ * @typedef {{ upload: string, name: string, size: number, type: string }} Uploaded
81
+ *
82
+ * @typedef {'too_large' | 'type' | 'invalid' | 'refused' | 'signed_out' | 'throttled' | 'quota' | 'infected' | 'unavailable' | 'expired' | 'network' | 'failed'} UploadErrorKind
83
+ *
84
+ * @typedef {{
85
+ * kind: UploadErrorKind,
86
+ * status: number,
87
+ * code: string | null,
88
+ * reference: string | null,
89
+ * retryAfter: number | null,
90
+ * }} UploadError
91
+ *
92
+ * @typedef {(
93
+ * | { type: 'uploaded', file: Uploaded }
94
+ * | { type: 'error', error: UploadError }
95
+ * | { type: 'cancelled' }
96
+ * )} UploadOutcome
97
+ *
98
+ * @typedef {{
99
+ * ticket: string,
100
+ * file: Blob,
101
+ * url?: string,
102
+ * maxBytes?: number,
103
+ * signal?: AbortSignal,
104
+ * onProgress?: (progress: Progress) => void,
105
+ * refresh?: boolean,
106
+ * refreshUrl?: string,
107
+ * onTicket?: (ticket: string) => void,
108
+ * direct?: boolean,
109
+ * resumable?: boolean,
110
+ * onState?: (state: 'uploading' | 'paused' | 'resuming') => void,
111
+ * onSession?: (session: import('./resumable.js').ResumableSession) => void,
112
+ * handle?: unknown,
113
+ * resumeStore?: import('./resume-store.js').ResumeStore,
114
+ * backoff?: number[],
115
+ * }} UploadOptions
116
+ */
117
+
118
+ /**
119
+ * Sends one file to /_upload under a ticket.
120
+ *
121
+ * Never throws for an outcome. Each refusal has a kind the app can say
122
+ * something specific about -- too large, not a type the field takes, a
123
+ * ticket that has run out -- and no kind ever carries the server's message,
124
+ * which is for its log.
125
+ *
126
+ * `maxBytes`, when given, refuses a file larger than it without sending
127
+ * it: the server holds the real limit, and this only spares the customer a
128
+ * wait for an answer that can only be no.
129
+ *
130
+ * `direct` sends the file straight to the application's object store
131
+ * rather than through PHP: /_upload/direct answers with a URL presigned for
132
+ * its size and the headers to send, the file is PUT there with progress,
133
+ * and /_upload/complete has PHP fetch it back and keep it as /_upload
134
+ * would -- or refuse it. The outcome is the same either way. The bucket
135
+ * must allow this origin's PUT (CORS).
136
+ *
137
+ * `resumable` sends it as a resumable upload (ADR 0143): in chunks through
138
+ * PHP, or -- with `direct` -- in parts straight to the store, each piece
139
+ * only from where the server says it holds. A network that drops is waited
140
+ * out, `onState` told `paused` and then `resuming`; and the same file chosen
141
+ * again, after a refresh or a crash, goes on from where the server is. A
142
+ * server that takes no resumable upload of the kind asked has the file sent
143
+ * whole, as without it. `onSession` is told the session once it is known:
144
+ * a cancel through `signal` keeps it on the server, and a caller that gives
145
+ * the upload up for good gives the session up too (abortUpload()).
146
+ *
147
+ * @param {UploadOptions} options
148
+ * @returns {Promise<UploadOutcome>}
149
+ */
150
+ export async function uploadFile({
151
+ ticket,
152
+ file,
153
+ url = UPLOAD_PATH,
154
+ maxBytes,
155
+ signal,
156
+ onProgress,
157
+ refresh = false,
158
+ refreshUrl = UPLOAD_REFRESH_PATH,
159
+ onTicket,
160
+ direct = false,
161
+ resumable = false,
162
+ onState,
163
+ onSession,
164
+ handle,
165
+ resumeStore,
166
+ backoff,
167
+ }) {
168
+ if (typeof maxBytes === 'number' && file.size > maxBytes) {
169
+ return { type: 'error', error: failure('too_large', 413) };
170
+ }
171
+
172
+ const sendWhole = (/** @type {string} */ under) =>
173
+ direct
174
+ ? sendDirect(under, file, signal, onProgress)
175
+ : send({ url, data: { ticket: under, file }, signal, onUploadProgress: onProgress });
176
+ const sendUnder = async (/** @type {string} */ under) => {
177
+ if (!resumable) return sendWhole(under);
178
+ const sent = await uploadResumable({
179
+ ticket: under,
180
+ file,
181
+ direct,
182
+ signal,
183
+ onProgress,
184
+ onState,
185
+ onSession,
186
+ handle,
187
+ store: resumeStore,
188
+ ...(backoff === undefined ? {} : { backoff }),
189
+ });
190
+ return sent.type === 'unsupported' ? sendWhole(under) : sent;
191
+ };
192
+
193
+ let outcome = await sendUnder(ticket);
194
+
195
+ // Refused: most likely a ticket the page has outlived. Asked for again
196
+ // once, and the file sent once more under it; a refresh refused is the
197
+ // first refusal, and the page has to be loaded again.
198
+ if (refresh && outcome.type === 'http' && outcome.status === 403) {
199
+ const renewed = await refreshTicket({ ticket, url: refreshUrl, signal });
200
+ if (renewed.type === 'refreshed') {
201
+ onTicket?.(renewed.ticket);
202
+ outcome = await sendUnder(renewed.ticket);
203
+ }
204
+ }
205
+
206
+ return outcomeOf(outcome);
207
+ }
208
+
209
+ /**
210
+ * What an upload's last answer means for the app: the file, or a refusal of
211
+ * a kind it can say something about.
212
+ *
213
+ * @param {import('./transport.js').Outcome} outcome
214
+ * @returns {UploadOutcome}
215
+ */
216
+ function outcomeOf(outcome) {
217
+ switch (outcome.type) {
218
+ case 'success': {
219
+ const body = outcome.body;
220
+ const uploaded =
221
+ body !== null &&
222
+ typeof body === 'object' &&
223
+ typeof body.upload === 'string' &&
224
+ REFERENCE.test(body.upload) &&
225
+ typeof body.name === 'string' &&
226
+ typeof body.size === 'number' &&
227
+ typeof body.type === 'string';
228
+ return uploaded
229
+ ? {
230
+ type: 'uploaded',
231
+ file: { upload: body.upload, name: body.name, size: body.size, type: body.type },
232
+ }
233
+ : { type: 'error', error: failure('failed', outcome.status) };
234
+ }
235
+ case 'validation':
236
+ // A scanner's find is a 422 too, told apart by its code.
237
+ return outcome.body?.error?.code === 'upload_infected'
238
+ ? { type: 'error', error: failure('infected', 422, 'upload_infected') }
239
+ : { type: 'error', error: failure('invalid', 422) };
240
+ case 'http':
241
+ return {
242
+ type: 'error',
243
+ error: failure(
244
+ kindOf(outcome.status),
245
+ outcome.status,
246
+ outcome.code,
247
+ outcome.reference,
248
+ outcome.retryAfter,
249
+ ),
250
+ };
251
+ case 'expired':
252
+ return { type: 'error', error: failure('expired', 419) };
253
+ case 'network':
254
+ return { type: 'error', error: failure('network', 0) };
255
+ case 'cancelled':
256
+ return { type: 'cancelled' };
257
+ default:
258
+ // A redirect, which /_upload never answers with.
259
+ return { type: 'error', error: failure('failed', 0) };
260
+ }
261
+ }
262
+
263
+ /**
264
+ * A file sent straight to the object store, in three steps, answered as
265
+ * /_upload answers: the intent and the presigned URL from /_upload/direct;
266
+ * the PUT, with the headers it was signed with and nothing of this origin's
267
+ * -- no cookie, no CSRF header; and /_upload/complete, which answers with
268
+ * the reference, or the refusal /_upload would have given.
269
+ *
270
+ * @param {string} ticket
271
+ * @param {Blob} file
272
+ * @param {AbortSignal | undefined} signal
273
+ * @param {((progress: Progress) => void) | undefined} onProgress
274
+ * @returns {Promise<import('./transport.js').Outcome>}
275
+ */
276
+ async function sendDirect(ticket, file, signal, onProgress) {
277
+ const name = typeof (/** @type {any} */ (file).name) === 'string' ? /** @type {any} */ (file).name : '';
278
+ const begun = await send({ url: UPLOAD_DIRECT_PATH, data: { ticket, size: file.size, name }, signal });
279
+ if (begun.type !== 'success') return begun;
280
+
281
+ const body = begun.body;
282
+ if (
283
+ body === null ||
284
+ typeof body !== 'object' ||
285
+ typeof body.intent !== 'string' ||
286
+ !REFERENCE.test(body.intent) ||
287
+ typeof body.url !== 'string' ||
288
+ body.headers === null ||
289
+ typeof body.headers !== 'object'
290
+ ) {
291
+ return {
292
+ type: 'http',
293
+ status: 0,
294
+ body: null,
295
+ headers: new Headers(),
296
+ code: null,
297
+ reference: null,
298
+ retryAfter: null,
299
+ };
300
+ }
301
+
302
+ const put = await putToStore(body.url, body.headers, file, signal, onProgress);
303
+ if (put.type !== 'sent') return put;
304
+
305
+ return send({ url: UPLOAD_COMPLETE_PATH, data: { ticket, intent: body.intent }, signal });
306
+ }
307
+
308
+ /**
309
+ * Asks for a ticket the page holds to be issued again: for the same
310
+ * session, user, action and record, for another hour, and never past a day
311
+ * from its first issue. Never throws for an outcome.
312
+ *
313
+ * @param {{ ticket: string, url?: string, signal?: AbortSignal }} options
314
+ * @returns {Promise<{ type: 'refreshed', ticket: string, expiresAt: number } | { type: 'error', error: UploadError } | { type: 'cancelled' }>}
315
+ */
316
+ export async function refreshTicket({ ticket, url = UPLOAD_REFRESH_PATH, signal }) {
317
+ const outcome = await send({ url, data: { ticket }, signal });
318
+ if (outcome.type === 'cancelled') return { type: 'cancelled' };
319
+ if (outcome.type === 'success') {
320
+ const body = outcome.body;
321
+ if (
322
+ body !== null &&
323
+ typeof body === 'object' &&
324
+ typeof body.ticket === 'string' &&
325
+ typeof body.expiresAt === 'number'
326
+ ) {
327
+ return { type: 'refreshed', ticket: body.ticket, expiresAt: body.expiresAt };
328
+ }
329
+ return { type: 'error', error: failure('failed', outcome.status) };
330
+ }
331
+ if (outcome.type === 'http') {
332
+ return {
333
+ type: 'error',
334
+ error: failure(
335
+ kindOf(outcome.status),
336
+ outcome.status,
337
+ outcome.code,
338
+ outcome.reference,
339
+ outcome.retryAfter,
340
+ ),
341
+ };
342
+ }
343
+ return { type: 'error', error: failure(outcome.type === 'network' ? 'network' : 'failed', 0) };
344
+ }
345
+
346
+ /** @param {number} status @returns {UploadErrorKind} */
347
+ function kindOf(status) {
348
+ switch (status) {
349
+ case 401:
350
+ return 'signed_out';
351
+ case 403:
352
+ return 'refused';
353
+ case 413:
354
+ return 'too_large';
355
+ case 415:
356
+ return 'type';
357
+ case 429:
358
+ return 'throttled';
359
+ case 507:
360
+ return 'quota';
361
+ case 503:
362
+ return 'unavailable';
363
+ default:
364
+ return 'failed';
365
+ }
366
+ }
367
+
368
+ /**
369
+ * @param {UploadErrorKind} kind
370
+ * @param {number} status
371
+ * @returns {UploadError}
372
+ */
373
+ function failure(kind, status, code = null, reference = null, retryAfter = null) {
374
+ return { kind, status, code, reference, retryAfter };
375
+ }
376
+
377
+ /**
378
+ * @typedef {'idle' | 'uploading' | 'paused' | 'resuming' | 'uploaded' | 'error'} UploadStatus
379
+ *
380
+ * @typedef {{
381
+ * ticket: string,
382
+ * url?: string,
383
+ * maxBytes?: number,
384
+ * onUploaded?: (file: Uploaded) => void,
385
+ * onError?: (error: UploadError) => void,
386
+ * direct?: boolean,
387
+ * resumable?: boolean,
388
+ * resumeStore?: import('./resume-store.js').ResumeStore,
389
+ * backoff?: number[],
390
+ * }} UseUploadOptions
391
+ *
392
+ * @typedef {{
393
+ * name: string,
394
+ * size: number,
395
+ * offset: number,
396
+ * percentage: number,
397
+ * reopenable: boolean,
398
+ * }} PendingUpload
399
+ */
400
+
401
+ /**
402
+ * One upload's state, for a field that takes one file.
403
+ *
404
+ * A second file chosen while the first is still going cancels the first:
405
+ * the reference that counts is the one for the file chosen last. Leaving
406
+ * the page cancels it too.
407
+ *
408
+ * `onUploaded` runs once the reference is in state -- after the render that
409
+ * puts it there -- so a form submitted from it sends the new reference,
410
+ * not the one before.
411
+ *
412
+ * A 401 means the session is over: what the page holds belongs to it, and
413
+ * the page is loaded again, as every form's 401 does.
414
+ *
415
+ * With `resumable`, `status` is also `paused` while a dropped network is
416
+ * waited out, and `resuming` while the server is asked where to go on; and
417
+ * once the field is drawn, `pending` is an upload of this field the server
418
+ * still holds part of -- begun before a refresh, a closed tab or a crash --
419
+ * for the app to offer to go on with (`resume()`) or give up
420
+ * (`discardPending()`). A browser cannot read a file again without the
421
+ * person: `resume()` reopens it where the File System Access API kept a
422
+ * handle to it, asking only for permission, and otherwise answers null, for
423
+ * the person to choose it again -- the same file goes on from where the
424
+ * server is; another starts over.
425
+ *
426
+ * @param {UseUploadOptions} options
427
+ */
428
+ export function useUpload({
429
+ ticket,
430
+ url,
431
+ maxBytes,
432
+ onUploaded,
433
+ onError,
434
+ direct,
435
+ resumable = false,
436
+ resumeStore,
437
+ backoff,
438
+ }) {
439
+ const router = useOptionalRouter();
440
+ const session = useFormSession();
441
+ const [status, setStatus] = useState(/** @type {UploadStatus} */ ('idle'));
442
+ const [progress, setProgress] = useState(/** @type {Progress | null} */ (null));
443
+ const [file, setFile] = useState(/** @type {Uploaded | null} */ (null));
444
+ const [error, setError] = useState(/** @type {UploadError | null} */ (null));
445
+ const [pending, setPending] = useState(/** @type {PendingUpload | null} */ (null));
446
+ const pendingRecord = useRef(/** @type {import('./resume-store.js').ResumeRecord | null} */ (null));
447
+ const inFlight = useRef(/** @type {Job | null} */ (null));
448
+ // The last upload that ended keeping its session on the server -- given
449
+ // up on the network, say -- for a reset or another file to give up.
450
+ const kept = useRef(/** @type {Job | null} */ (null));
451
+ // How many files have been chosen here: one chosen while the server was
452
+ // asked about a waiting upload is what the field holds, not that one.
453
+ const choices = useRef(0);
454
+
455
+ // The latest callbacks and options, for an upload started under older ones.
456
+ const latest = useRef({
457
+ ticket,
458
+ url,
459
+ maxBytes,
460
+ onUploaded,
461
+ onError,
462
+ direct,
463
+ resumable,
464
+ resumeStore,
465
+ backoff,
466
+ });
467
+ latest.current = { ticket, url, maxBytes, onUploaded, onError, direct, resumable, resumeStore, backoff };
468
+ const renewed = useRenewedTicket(ticket);
469
+
470
+ // Leaving the page cancels the upload but keeps its session: the next
471
+ // page offers to go on with it.
472
+ useEffect(() => () => inFlight.current?.controller.abort(), []);
473
+
474
+ // An upload of this field the server still holds part of, offered once
475
+ // the field is drawn: the newest one for what this ticket is for, asked
476
+ // of the server first -- one it has let go of, or that is another
477
+ // session's or user's, is forgotten without a word.
478
+ const scope = resumable ? uploadScope(ticket) : null;
479
+ useEffect(() => {
480
+ if (scope === null) return undefined;
481
+ let live = true;
482
+ const store = latest.current.resumeStore ?? defaultResumeStore();
483
+ const choicesBefore = choices.current;
484
+ void (async () => {
485
+ let records = [];
486
+ try {
487
+ records = await store.inScope(scope);
488
+ } catch {
489
+ records = [];
490
+ }
491
+ for (const record of records) {
492
+ const asked = await askOffset(record.session);
493
+ if (!live || choices.current !== choicesBefore) return;
494
+ if (asked.type === 'gone') {
495
+ await store.delete(record.id).catch(() => {});
496
+ continue;
497
+ }
498
+ const offset =
499
+ asked.type === 'open' ? asked.offset : asked.type === 'completed' ? record.size : record.offset;
500
+ pendingRecord.current = record;
501
+ setPending({
502
+ name: record.name,
503
+ size: record.size,
504
+ offset,
505
+ percentage: record.size > 0 ? Math.floor((offset / record.size) * 100) : 0,
506
+ reopenable: typeof (/** @type {any} */ (record.handle)?.getFile) === 'function',
507
+ });
508
+ return;
509
+ }
510
+ })();
511
+ return () => {
512
+ live = false;
513
+ };
514
+ }, [scope]);
515
+
516
+ // Told once per upload, after it has rendered.
517
+ const told = useRef(/** @type {Uploaded | null} */ (null));
518
+ useEffect(() => {
519
+ if (file !== null && told.current !== file) {
520
+ told.current = file;
521
+ latest.current.onUploaded?.(file);
522
+ }
523
+ }, [file]);
524
+
525
+ const upload = useCallback(
526
+ async (/** @type {Blob} */ chosen, /** @type {{ handle?: unknown }} */ { handle } = {}) => {
527
+ choices.current++;
528
+ const options = latest.current;
529
+ const fingerprint = options.resumable ? fingerprintFile(chosen) : null;
530
+ // This field takes one file: the one it replaces is given up, its
531
+ // session too -- unless it is the same file, which goes on in it.
532
+ const replaced = inFlight.current ?? kept.current;
533
+ kept.current = null;
534
+ if (replaced !== null) {
535
+ giveUp(
536
+ replaced,
537
+ (async () =>
538
+ replaced.fingerprint === null || (await replaced.fingerprint) !== (await fingerprint))(),
539
+ options.resumeStore,
540
+ );
541
+ }
542
+ const controller = new AbortController();
543
+ /** @type {Job} */
544
+ const job = { controller, fingerprint, session: null, discard: false, done: false };
545
+ inFlight.current = job;
546
+ const current = () => inFlight.current === job;
547
+
548
+ setStatus('uploading');
549
+ setProgress(null);
550
+ setFile(null);
551
+ setError(null);
552
+
553
+ // Another file than the one waiting to be gone on with: this field
554
+ // takes one, so the one waiting is given up, and its room with it.
555
+ const waiting = pendingRecord.current;
556
+ if (waiting !== null) {
557
+ pendingRecord.current = null;
558
+ setPending(null);
559
+ if (options.resumable && (await fingerprint) !== waiting.fingerprint) {
560
+ void discardRecord(waiting, options.resumeStore);
561
+ }
562
+ }
563
+
564
+ const outcome = await uploadFile({
565
+ ticket: renewed.current.ticket,
566
+ file: chosen,
567
+ url: options.url,
568
+ maxBytes: options.maxBytes,
569
+ direct: options.direct,
570
+ resumable: options.resumable,
571
+ resumeStore: options.resumeStore,
572
+ handle,
573
+ ...(options.backoff === undefined ? {} : { backoff: options.backoff }),
574
+ signal: controller.signal,
575
+ onProgress: (p) => {
576
+ if (current()) setProgress(p);
577
+ },
578
+ onState: (state) => {
579
+ if (current()) setStatus(state);
580
+ },
581
+ onSession: (held) => {
582
+ job.session = held;
583
+ },
584
+ refresh: true,
585
+ onTicket: (next) => {
586
+ renewed.current.ticket = next;
587
+ },
588
+ });
589
+
590
+ // Cancelled for good -- not by the field going away -- so the server
591
+ // need hold nothing more of it.
592
+ job.done = true;
593
+ if (outcome.type === 'cancelled' && job.session !== null && (await job.discard)) {
594
+ void discardRecord(job.session, options.resumeStore);
595
+ job.session = null;
596
+ }
597
+
598
+ // Superseded, or the field went away: the later upload owns the state.
599
+ if (!current()) return outcome;
600
+ inFlight.current = null;
601
+ // Ended with its session still on the server: the next file, or a reset, gives it up.
602
+ if (outcome.type === 'error' && job.session !== null) kept.current = job;
603
+ setProgress(null);
604
+
605
+ if (outcome.type === 'uploaded') {
606
+ setFile(outcome.file);
607
+ setStatus('uploaded');
608
+ } else if (outcome.type === 'error') {
609
+ setError(outcome.error);
610
+ setStatus('error');
611
+ latest.current.onError?.(outcome.error);
612
+ if (outcome.error.kind === 'signed_out') endSession(router, session?.hardNavigate ?? assignLocation);
613
+ } else {
614
+ setStatus('idle');
615
+ }
616
+
617
+ return outcome;
618
+ },
619
+ [router, session, renewed],
620
+ );
621
+
622
+ const cancel = useCallback(() => {
623
+ if (inFlight.current !== null) giveUp(inFlight.current, true, latest.current.resumeStore);
624
+ }, []);
625
+
626
+ const reset = useCallback(() => {
627
+ const job = inFlight.current ?? kept.current;
628
+ if (job !== null) giveUp(job, true, latest.current.resumeStore);
629
+ inFlight.current = null;
630
+ kept.current = null;
631
+ setStatus('idle');
632
+ setProgress(null);
633
+ setFile(null);
634
+ setError(null);
635
+ }, []);
636
+
637
+ const resume = useCallback(async () => {
638
+ const record = pendingRecord.current;
639
+ if (record === null) return null;
640
+
641
+ // Whole on the server already -- the last piece went in as the page
642
+ // went away -- so there is nothing to send, and no file to ask for.
643
+ if (pending !== null && pending.offset >= pending.size) {
644
+ const asked = await askOffset(record.session);
645
+ if (asked.type === 'completed') {
646
+ pendingRecord.current = null;
647
+ setPending(null);
648
+ await (latest.current.resumeStore ?? defaultResumeStore()).delete(record.id).catch(() => {});
649
+ const outcome = outcomeOf(asked.outcome);
650
+ if (outcome.type === 'uploaded') {
651
+ setFile(outcome.file);
652
+ setStatus('uploaded');
653
+ }
654
+ return outcome;
655
+ }
656
+ }
657
+
658
+ // A file the browser kept a handle to: read again once the person
659
+ // grants it, which needs this to run from their click.
660
+ const handle = /** @type {any} */ (record.handle);
661
+ if (handle && typeof handle.getFile === 'function') {
662
+ try {
663
+ let permission =
664
+ typeof handle.queryPermission === 'function'
665
+ ? await handle.queryPermission({ mode: 'read' })
666
+ : 'granted';
667
+ if (permission !== 'granted' && typeof handle.requestPermission === 'function') {
668
+ permission = await handle.requestPermission({ mode: 'read' });
669
+ }
670
+ if (permission === 'granted') return upload(await handle.getFile(), { handle });
671
+ } catch {
672
+ // Moved, deleted or refused: the person chooses it again.
673
+ }
674
+ }
675
+ return null;
676
+ }, [pending, upload]);
677
+
678
+ const discardPending = useCallback(async () => {
679
+ const record = pendingRecord.current;
680
+ pendingRecord.current = null;
681
+ setPending(null);
682
+ if (record !== null) await discardRecord(record, latest.current.resumeStore);
683
+ }, []);
684
+
685
+ return {
686
+ status,
687
+ uploading: status === 'uploading' || status === 'paused' || status === 'resuming',
688
+ paused: status === 'paused',
689
+ resuming: status === 'resuming',
690
+ progress,
691
+ file,
692
+ error,
693
+ /** What the form sends under the parameter's name: the reference, or null before one. */
694
+ reference: file?.upload ?? null,
695
+ pending,
696
+ upload,
697
+ resume,
698
+ discardPending,
699
+ cancel,
700
+ reset,
701
+ };
702
+ }
703
+
704
+ /**
705
+ * One upload of a single-file field: what cancels it, the fingerprint of
706
+ * its file (resumable only), the session it goes on in once known, and
707
+ * whether that session is to be given up once it has stopped -- false when
708
+ * the field merely went away.
709
+ *
710
+ * @typedef {{
711
+ * controller: AbortController,
712
+ * fingerprint: Promise<string | null> | null,
713
+ * session: import('./resumable.js').ResumableSession | null,
714
+ * discard: boolean | Promise<boolean>,
715
+ * done: boolean,
716
+ * }} Job
717
+ */
718
+
719
+ /**
720
+ * Gives an upload up: cancelled if it is still going -- its session given up
721
+ * once it has stopped, when $discard says so -- or, ended keeping its
722
+ * session, that session given up now.
723
+ *
724
+ * @param {Job} job
725
+ * @param {boolean | Promise<boolean>} discard
726
+ * @param {import('./resume-store.js').ResumeStore | undefined} store
727
+ */
728
+ function giveUp(job, discard, store) {
729
+ job.discard = discard;
730
+ if (!job.done) {
731
+ job.controller.abort();
732
+ return;
733
+ }
734
+ const session = job.session;
735
+ job.session = null;
736
+ if (session !== null) {
737
+ void (async () => {
738
+ if (await discard) await discardRecord(session, store);
739
+ })();
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Gives up an upload a record remembers: on the server, its bytes removed
745
+ * and its room given back, and the record forgotten. Never throws.
746
+ *
747
+ * @param {{ session: string, id: string | null }} record
748
+ * @param {import('./resume-store.js').ResumeStore | undefined} store
749
+ */
750
+ async function discardRecord(record, store) {
751
+ try {
752
+ await abortUpload(record.session);
753
+ } catch {
754
+ // Pruned once it runs out, whatever happens here.
755
+ }
756
+ if (record.id === null) return;
757
+ try {
758
+ await (store ?? defaultResumeStore()).delete(record.id);
759
+ } catch {
760
+ // Run out, it is not offered again.
761
+ }
762
+ }
763
+
764
+ /**
765
+ * A form field that uploads the file chosen in it, and posts the reference.
766
+ *
767
+ * <Form action={updateAvatar}>
768
+ * <UploadField name="avatar" ticket={data.avatarTicket}>
769
+ * {(field) => (
770
+ * <>
771
+ * <input {...field.input} hidden />
772
+ * <button type="button" onClick={field.choose}>Choose a picture</button>
773
+ * {field.uploading && <progress value={field.progress?.percentage ?? 0} max={100} />}
774
+ * </>
775
+ * )}
776
+ * </UploadField>
777
+ * </Form>
778
+ *
779
+ * It renders a hidden input under `name`, holding the reference once the
780
+ * upload is in, and hands its children the upload's state with `input` --
781
+ * the props for a file input, which has no name, so the file itself is
782
+ * never part of the form -- and `choose()`, which opens it.
783
+ *
784
+ * Without script -- before it has loaded, or with none -- the file input
785
+ * carries `name` and the ticket rides beside it in `_upload_tickets[name]`,
786
+ * so a form with `encType="multipart/form-data"` posts the file to the
787
+ * action itself, which takes it as /_upload would (ADR 0127). Once script
788
+ * runs the input loses its name, and the file goes to /_upload first.
789
+ *
790
+ * With `resumable`, choosing a file opens the browser's own file picker
791
+ * where it has one that keeps a handle to the file (the File System Access
792
+ * API), so an upload interrupted by a refresh or a crash can go on at the
793
+ * cost of a permission prompt; `resume()` goes on with `pending`, opening
794
+ * the file input for the person to choose the file again where there is no
795
+ * handle.
796
+ *
797
+ * @param {UseUploadOptions & {
798
+ * name: string,
799
+ * accept?: string,
800
+ * children?: (field: any) => unknown,
801
+ * }} props
802
+ */
803
+ export function UploadField({
804
+ name,
805
+ ticket,
806
+ url,
807
+ maxBytes,
808
+ accept,
809
+ onUploaded,
810
+ onError,
811
+ direct,
812
+ resumable,
813
+ resumeStore,
814
+ backoff,
815
+ children,
816
+ }) {
817
+ const state = useUpload({
818
+ ticket,
819
+ url,
820
+ maxBytes,
821
+ onUploaded,
822
+ onError,
823
+ direct,
824
+ resumable,
825
+ resumeStore,
826
+ backoff,
827
+ });
828
+ const [hydrated, setHydrated] = useState(false);
829
+ useEffect(() => setHydrated(true), []);
830
+ const inputRef = useRef(/** @type {HTMLInputElement | null} */ (null));
831
+ const { upload, reset: resetUpload, resume: resumeUpload } = state;
832
+
833
+ const onChange = useCallback(
834
+ (/** @type {{ currentTarget: HTMLInputElement }} */ event) => {
835
+ const chosen = event.currentTarget.files?.[0];
836
+ if (chosen) void upload(chosen);
837
+ },
838
+ [upload],
839
+ );
840
+
841
+ const choose = useCallback(() => {
842
+ const picker = resumable ? openFilePicker(accept) : null;
843
+ if (picker === null) {
844
+ inputRef.current?.click();
845
+ return;
846
+ }
847
+ void picker.then((picked) => {
848
+ if (picked === 'unavailable') inputRef.current?.click();
849
+ else if (picked !== null) void upload(picked.file, { handle: picked.handle });
850
+ });
851
+ }, [resumable, accept, upload]);
852
+
853
+ const resume = useCallback(async () => {
854
+ const outcome = await resumeUpload();
855
+ if (outcome === null) choose();
856
+ return outcome;
857
+ }, [resumeUpload, choose]);
858
+
859
+ const reset = useCallback(() => {
860
+ resetUpload();
861
+ if (inputRef.current) inputRef.current.value = '';
862
+ }, [resetUpload]);
863
+
864
+ const field = {
865
+ ...state,
866
+ reset,
867
+ choose,
868
+ resume,
869
+ // Whether script runs: until it does, the app shows the file input
870
+ // itself and a button that submits the form, which carries the file.
871
+ scripted: hydrated,
872
+ // Named only until script runs: posted by the browser itself, the file
873
+ // goes with the form; posted by script, never.
874
+ input: { ref: inputRef, type: 'file', accept, onChange, name: hydrated ? undefined : name },
875
+ };
876
+
877
+ return createElement(
878
+ Fragment,
879
+ null,
880
+ createElement('input', { type: 'hidden', name, value: state.reference ?? '' }),
881
+ // Only until script runs, as the input's name: script sends the reference alone.
882
+ hydrated
883
+ ? null
884
+ : createElement('input', { type: 'hidden', name: `_upload_tickets[${name}]`, value: ticket }),
885
+ typeof children === 'function' ? children(field) : null,
886
+ );
887
+ }
888
+
889
+ /**
890
+ * The browser's own file picker, where it has one that hands back a handle
891
+ * to the file (the File System Access API): the file and its handle, null
892
+ * when the person closed it, or `unavailable` for the file input instead.
893
+ * Null at once where there is no such picker.
894
+ *
895
+ * @param {string | undefined} accept
896
+ * @returns {Promise<{ file: File, handle: unknown } | null | 'unavailable'> | null}
897
+ */
898
+ function openFilePicker(accept) {
899
+ const show = /** @type {any} */ (globalThis).showOpenFilePicker;
900
+ if (typeof show !== 'function') return null;
901
+ const types = (accept ?? '')
902
+ .split(',')
903
+ .map((type) => type.trim())
904
+ .filter((type) => /^[a-z]+\/[a-z0-9.+-]+$/i.test(type));
905
+ return (async () => {
906
+ try {
907
+ const [handle] = await show({
908
+ multiple: false,
909
+ ...(types.length === 0
910
+ ? {}
911
+ : {
912
+ types: [
913
+ {
914
+ description: 'Files',
915
+ accept: Object.fromEntries(types.map((t) => [t, EXTENSIONS[t] ?? []])),
916
+ },
917
+ ],
918
+ }),
919
+ });
920
+ return { file: await handle.getFile(), handle };
921
+ } catch (error) {
922
+ return /** @type {any} */ (error)?.name === 'AbortError' ? null : 'unavailable';
923
+ }
924
+ })();
925
+ }
926
+
927
+ /** @type {Record<string, string[]>} */
928
+ const EXTENSIONS = {
929
+ 'image/png': ['.png'],
930
+ 'image/jpeg': ['.jpg', '.jpeg'],
931
+ 'image/webp': ['.webp'],
932
+ 'image/avif': ['.avif'],
933
+ 'image/gif': ['.gif'],
934
+ 'application/pdf': ['.pdf'],
935
+ };
936
+
937
+ /**
938
+ * The ticket an upload goes under: the page's, until a refresh issues it
939
+ * again -- and the page's once more if the page hands a new one.
940
+ *
941
+ * @param {string} ticket
942
+ */
943
+ function useRenewedTicket(ticket) {
944
+ const held = useRef({ given: ticket, ticket });
945
+ if (held.current.given !== ticket) held.current = { given: ticket, ticket };
946
+ return held;
947
+ }
948
+
949
+ /**
950
+ * @typedef {{
951
+ * key: number,
952
+ * status: UploadStatus,
953
+ * progress: Progress | null,
954
+ * file: Uploaded | null,
955
+ * error: UploadError | null,
956
+ * }} UploadEntry
957
+ */
958
+
959
+ /**
960
+ * The state of a parameter that takes several files (an UploadList): each
961
+ * file chosen is sent to /_upload under the one ticket, one after another,
962
+ * and `references` is every one that went in, in the order chosen.
963
+ *
964
+ * `maxFiles` refuses files past it without sending them, as `maxBytes`
965
+ * does a large one: the server holds the real limit.
966
+ *
967
+ * `remove()` cancels a file still going, and a file still queued is never
968
+ * sent; `reset()`, or the field going away, cancels the one going and sends
969
+ * none of those queued behind it. With `resumable`, `remove()` and
970
+ * `reset()` give up on the server the session of a file they cancel, or of
971
+ * one that ended keeping it; the field going away keeps it, so the same file
972
+ * chosen again goes on from where the server is.
973
+ *
974
+ * @param {UseUploadOptions & { maxFiles?: number }} options
975
+ */
976
+ export function useUploads({
977
+ ticket,
978
+ url,
979
+ maxBytes,
980
+ maxFiles,
981
+ onUploaded,
982
+ onError,
983
+ direct,
984
+ resumable,
985
+ resumeStore,
986
+ backoff,
987
+ }) {
988
+ const router = useOptionalRouter();
989
+ const session = useFormSession();
990
+ const [entries, setEntries] = useState(/** @type {UploadEntry[]} */ ([]));
991
+ const renewed = useRenewedTicket(ticket);
992
+ const controllers = useRef(/** @type {Map<number, AbortController>} */ (new Map()));
993
+ const nextKey = useRef(0);
994
+ const latest = useRef({
995
+ url,
996
+ maxBytes,
997
+ maxFiles,
998
+ onUploaded,
999
+ onError,
1000
+ direct,
1001
+ resumable,
1002
+ resumeStore,
1003
+ backoff,
1004
+ });
1005
+ latest.current = { url, maxBytes, maxFiles, onUploaded, onError, direct, resumable, resumeStore, backoff };
1006
+ const count = useRef(0);
1007
+ count.current = entries.length;
1008
+ // The keys still wanted: a file removed, or every file on a reset, is
1009
+ // dropped from here, and a batch sends none it no longer finds.
1010
+ const wanted = useRef(/** @type {Set<number>} */ (new Set()));
1011
+ // One per batch still going: a reset or the field going away stops each
1012
+ // from sending the files it has queued.
1013
+ const batches = useRef(/** @type {Set<AbortController>} */ (new Set()));
1014
+ // The session each resumable file goes on in, while it goes, and after it
1015
+ // ends in an error that keeps it on the server; and the files given up
1016
+ // for good while they went, whose sessions go once they have stopped.
1017
+ const sessions = useRef(/** @type {Map<number, import('./resumable.js').ResumableSession>} */ (new Map()));
1018
+ const discarding = useRef(/** @type {Set<number>} */ (new Set()));
1019
+
1020
+ useEffect(
1021
+ () => () => {
1022
+ batches.current.forEach((b) => b.abort());
1023
+ controllers.current.forEach((c) => c.abort());
1024
+ },
1025
+ [],
1026
+ );
1027
+
1028
+ const patch = useCallback((/** @type {number} */ key, /** @type {Partial<UploadEntry>} */ change) => {
1029
+ setEntries((all) => all.map((e) => (e.key === key ? { ...e, ...change } : e)));
1030
+ }, []);
1031
+
1032
+ const upload = useCallback(
1033
+ async (/** @type {Iterable<Blob>} */ chosen) => {
1034
+ const files = Array.from(chosen);
1035
+ const room =
1036
+ typeof latest.current.maxFiles === 'number'
1037
+ ? Math.max(0, latest.current.maxFiles - count.current)
1038
+ : files.length;
1039
+ const taken = files.slice(0, room);
1040
+ const added = taken.map(() => ({
1041
+ key: nextKey.current++,
1042
+ status: /** @type {UploadStatus} */ ('uploading'),
1043
+ progress: null,
1044
+ file: null,
1045
+ error: null,
1046
+ }));
1047
+ count.current += added.length;
1048
+ for (const { key } of added) wanted.current.add(key);
1049
+ setEntries((all) => [...all, ...added]);
1050
+ const batch = new AbortController();
1051
+ batches.current.add(batch);
1052
+
1053
+ /** @type {UploadOutcome[]} */
1054
+ const outcomes = [];
1055
+ for (let i = 0; i < taken.length; i++) {
1056
+ const { key } = added[i];
1057
+ // Reset, or the field gone: the rest are never sent. Removed: this one is not.
1058
+ if (batch.signal.aborted || !wanted.current.has(key)) {
1059
+ outcomes.push({ type: 'cancelled' });
1060
+ continue;
1061
+ }
1062
+ const controller = new AbortController();
1063
+ controllers.current.set(key, controller);
1064
+ const outcome = await uploadFile({
1065
+ ticket: renewed.current.ticket,
1066
+ file: taken[i],
1067
+ url: latest.current.url,
1068
+ maxBytes: latest.current.maxBytes,
1069
+ direct: latest.current.direct,
1070
+ resumable: latest.current.resumable,
1071
+ resumeStore: latest.current.resumeStore,
1072
+ ...(latest.current.backoff === undefined ? {} : { backoff: latest.current.backoff }),
1073
+ signal: controller.signal,
1074
+ onProgress: (p) => patch(key, { progress: p }),
1075
+ onState: (state) => patch(key, { status: state }),
1076
+ onSession: (held) => {
1077
+ sessions.current.set(key, held);
1078
+ },
1079
+ refresh: true,
1080
+ onTicket: (next) => {
1081
+ renewed.current.ticket = next;
1082
+ },
1083
+ });
1084
+ controllers.current.delete(key);
1085
+ outcomes.push(outcome);
1086
+
1087
+ // Removed or reset while it went -- not the field going away, which
1088
+ // keeps it for the next page -- so the server need hold nothing more.
1089
+ const held = sessions.current.get(key);
1090
+ if (outcome.type === 'cancelled' && held !== undefined && discarding.current.has(key)) {
1091
+ void discardRecord(held, latest.current.resumeStore);
1092
+ }
1093
+ discarding.current.delete(key);
1094
+ if (outcome.type !== 'error' || !wanted.current.has(key)) sessions.current.delete(key);
1095
+
1096
+ // Removed or reset while it went: its entry is gone, and nothing is told of it.
1097
+ if (!wanted.current.has(key)) continue;
1098
+ if (outcome.type !== 'uploaded' && outcome.type !== 'error') wanted.current.delete(key);
1099
+
1100
+ if (outcome.type === 'uploaded') {
1101
+ patch(key, { status: 'uploaded', progress: null, file: outcome.file });
1102
+ latest.current.onUploaded?.(outcome.file);
1103
+ } else if (outcome.type === 'error') {
1104
+ patch(key, { status: 'error', progress: null, error: outcome.error });
1105
+ latest.current.onError?.(outcome.error);
1106
+ if (outcome.error.kind === 'signed_out') {
1107
+ endSession(router, session?.hardNavigate ?? assignLocation);
1108
+ break;
1109
+ }
1110
+ } else {
1111
+ setEntries((all) => all.filter((e) => e.key !== key));
1112
+ count.current -= 1;
1113
+ }
1114
+ }
1115
+
1116
+ batches.current.delete(batch);
1117
+ return outcomes;
1118
+ },
1119
+ [router, session, renewed, patch],
1120
+ );
1121
+
1122
+ // A file given up for good: cancelled if it is still going, its session
1123
+ // given up once it has stopped; or, ended keeping one, that one given up now.
1124
+ const giveUpFile = useCallback((/** @type {number} */ key) => {
1125
+ wanted.current.delete(key);
1126
+ const controller = controllers.current.get(key);
1127
+ if (controller !== undefined) {
1128
+ discarding.current.add(key);
1129
+ controller.abort();
1130
+ controllers.current.delete(key);
1131
+ return;
1132
+ }
1133
+ const held = sessions.current.get(key);
1134
+ sessions.current.delete(key);
1135
+ if (held !== undefined) void discardRecord(held, latest.current.resumeStore);
1136
+ }, []);
1137
+
1138
+ const remove = useCallback(
1139
+ (/** @type {number} */ key) => {
1140
+ giveUpFile(key);
1141
+ setEntries((all) => all.filter((e) => e.key !== key));
1142
+ },
1143
+ [giveUpFile],
1144
+ );
1145
+
1146
+ const reset = useCallback(() => {
1147
+ batches.current.forEach((b) => b.abort());
1148
+ batches.current.clear();
1149
+ for (const key of new Set([
1150
+ ...wanted.current,
1151
+ ...controllers.current.keys(),
1152
+ ...sessions.current.keys(),
1153
+ ])) {
1154
+ giveUpFile(key);
1155
+ }
1156
+ wanted.current.clear();
1157
+ setEntries([]);
1158
+ }, [giveUpFile]);
1159
+
1160
+ return {
1161
+ entries,
1162
+ uploading: entries.some(
1163
+ (e) => e.status === 'uploading' || e.status === 'paused' || e.status === 'resuming',
1164
+ ),
1165
+ /** What the form sends under the parameter's name: a reference for each file that went in. */
1166
+ references: entries.flatMap((e) => (e.file === null ? [] : [e.file.upload])),
1167
+ upload,
1168
+ remove,
1169
+ reset,
1170
+ };
1171
+ }
1172
+
1173
+ /**
1174
+ * A form field for a parameter that takes several files: it uploads each
1175
+ * file chosen in it and posts every reference under `name[]`, drawing
1176
+ * nothing but the hidden inputs.
1177
+ *
1178
+ * @param {UseUploadOptions & {
1179
+ * name: string,
1180
+ * accept?: string,
1181
+ * maxFiles?: number,
1182
+ * children?: (field: any) => unknown,
1183
+ * }} props
1184
+ */
1185
+ export function UploadsField({
1186
+ name,
1187
+ ticket,
1188
+ url,
1189
+ maxBytes,
1190
+ maxFiles,
1191
+ accept,
1192
+ onUploaded,
1193
+ onError,
1194
+ direct,
1195
+ resumable,
1196
+ resumeStore,
1197
+ backoff,
1198
+ children,
1199
+ }) {
1200
+ const state = useUploads({
1201
+ ticket,
1202
+ url,
1203
+ maxBytes,
1204
+ maxFiles,
1205
+ onUploaded,
1206
+ onError,
1207
+ direct,
1208
+ resumable,
1209
+ resumeStore,
1210
+ backoff,
1211
+ });
1212
+ const inputRef = useRef(/** @type {HTMLInputElement | null} */ (null));
1213
+ const { upload } = state;
1214
+
1215
+ const onChange = useCallback(
1216
+ (/** @type {{ currentTarget: HTMLInputElement }} */ event) => {
1217
+ const chosen = event.currentTarget.files;
1218
+ if (chosen && chosen.length > 0) void upload(Array.from(chosen));
1219
+ event.currentTarget.value = '';
1220
+ },
1221
+ [upload],
1222
+ );
1223
+
1224
+ const choose = useCallback(() => inputRef.current?.click(), []);
1225
+
1226
+ const field = {
1227
+ ...state,
1228
+ choose,
1229
+ input: { ref: inputRef, type: 'file', multiple: true, accept, onChange },
1230
+ };
1231
+
1232
+ return createElement(
1233
+ Fragment,
1234
+ null,
1235
+ ...state.references.map((reference) =>
1236
+ createElement('input', { key: reference, type: 'hidden', name: `${name}[]`, value: reference }),
1237
+ ),
1238
+ typeof children === 'function' ? children(field) : null,
1239
+ );
1240
+ }