@better-s3/react 3.1045.2 → 3.1047.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/api.d.ts +30 -0
  2. package/dist/helpers/build-object-key.d.ts +20 -0
  3. package/dist/helpers/format-eta.d.ts +12 -0
  4. package/dist/helpers/format-file-size.d.ts +11 -0
  5. package/dist/helpers/format-speed.d.ts +8 -0
  6. package/dist/helpers/format-upload-progress.d.ts +17 -0
  7. package/dist/helpers/get-file-extension.d.ts +13 -0
  8. package/dist/helpers/index.d.ts +10 -0
  9. package/dist/helpers/parse-content-disposition.d.ts +18 -0
  10. package/dist/helpers/speed-tracker.d.ts +17 -0
  11. package/dist/helpers/truncate-filename.d.ts +9 -0
  12. package/dist/helpers/validate-file.d.ts +22 -0
  13. package/dist/hooks/index.d.ts +7 -0
  14. package/dist/{use-delete.d.ts → hooks/use-delete.d.ts} +4 -3
  15. package/dist/{use-download.d.ts → hooks/use-download.d.ts} +5 -10
  16. package/dist/hooks/use-fetch-download.d.ts +23 -0
  17. package/dist/hooks/use-multi-upload-controls.d.ts +33 -0
  18. package/dist/{use-multi-upload.d.ts → hooks/use-multi-upload.d.ts} +4 -3
  19. package/dist/hooks/use-upload-controls.d.ts +44 -0
  20. package/dist/hooks/use-upload.d.ts +75 -0
  21. package/dist/index.d.ts +13 -8
  22. package/dist/index.js +581 -186
  23. package/dist/index.js.map +1 -1
  24. package/dist/internal-helpers.d.ts +9 -0
  25. package/dist/s3-provider.d.ts +47 -0
  26. package/dist/store/index.d.ts +3 -0
  27. package/dist/store/local-storage-store.d.ts +9 -0
  28. package/dist/store/memory-store.d.ts +14 -0
  29. package/dist/types/download.d.ts +19 -2
  30. package/dist/types/error.d.ts +14 -0
  31. package/dist/types/index.d.ts +3 -0
  32. package/dist/types/s3-api.d.ts +158 -0
  33. package/dist/types/upload-store.d.ts +54 -0
  34. package/dist/types/upload.d.ts +56 -0
  35. package/dist/upload/constants.d.ts +1 -1
  36. package/dist/upload/multipart.d.ts +4 -3
  37. package/dist/upload/retry.d.ts +2 -1
  38. package/dist/upload/upload-file.d.ts +5 -1
  39. package/dist/upload/upload-files.d.ts +1 -1
  40. package/package.json +3 -6
  41. package/dist/helpers.d.ts +0 -7
  42. package/dist/use-fetch-download.d.ts +0 -33
  43. package/dist/use-upload-controls.d.ts +0 -63
  44. package/dist/use-upload.d.ts +0 -19
package/dist/index.js CHANGED
@@ -1,48 +1,110 @@
1
- import { validateFile } from '@better-s3/server';
2
- export { createS3Api as createPresignApi, createS3Api, validateFile } from '@better-s3/server';
3
- import { useState, useRef, useCallback } from 'react';
1
+ import { createContext, useContext, useState, useRef, useCallback } from 'react';
2
+ import { jsx } from 'react/jsx-runtime';
4
3
 
5
- // src/helpers.ts
6
- function parseContentDispositionFilename(header, fallback) {
7
- if (!header) return fallback;
8
- const starMatch = header.match(/filename\*=UTF-8''([^;,\s]+)/i);
9
- if (starMatch) {
10
- try {
11
- return decodeURIComponent(starMatch[1]);
12
- } catch {
13
- }
4
+ // src/types/error.ts
5
+ var S3UploadError = class extends Error {
6
+ constructor(message, code, statusCode, phase) {
7
+ super(message);
8
+ this.name = "S3UploadError";
9
+ this.code = code;
10
+ this.statusCode = statusCode;
11
+ this.phase = phase;
14
12
  }
15
- const match = header.match(/filename="([^"]+)"/i);
16
- if (match) return match[1];
17
- return fallback;
13
+ };
14
+
15
+ // src/api.ts
16
+ function createS3Client(input) {
17
+ return input;
18
18
  }
19
- function formatFileSize(bytes) {
20
- if (bytes === 0) return "0 B";
21
- const units = ["B", "KB", "MB", "GB", "TB"];
22
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
23
- const size = bytes / Math.pow(1024, i);
24
- return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
19
+ var createS3Api = createS3Client;
20
+ var S3Context = createContext(null);
21
+ function S3Provider({
22
+ client,
23
+ children
24
+ }) {
25
+ return /* @__PURE__ */ jsx(S3Context.Provider, { value: client, children });
26
+ }
27
+ function useS3Client() {
28
+ const ctx = useContext(S3Context);
29
+ if (!ctx) {
30
+ throw new Error(
31
+ "[better-s3] No S3Api client found. Either wrap your app with <S3Provider client={...}> or pass `api` directly to the hook."
32
+ );
33
+ }
34
+ return ctx;
35
+ }
36
+
37
+ // src/store/local-storage-store.ts
38
+ var STORAGE_PREFIX = "better-s3:upload:";
39
+ function createLocalStorageStore() {
40
+ return {
41
+ get(key, fileSize) {
42
+ try {
43
+ const raw = localStorage.getItem(STORAGE_PREFIX + key);
44
+ if (!raw) return null;
45
+ const stored = JSON.parse(raw);
46
+ return stored.fileSize === fileSize ? stored : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ },
51
+ set(upload) {
52
+ try {
53
+ localStorage.setItem(
54
+ STORAGE_PREFIX + upload.key,
55
+ JSON.stringify(upload)
56
+ );
57
+ } catch {
58
+ }
59
+ },
60
+ delete(key) {
61
+ try {
62
+ localStorage.removeItem(STORAGE_PREFIX + key);
63
+ } catch {
64
+ }
65
+ }
66
+ };
67
+ }
68
+
69
+ // src/store/memory-store.ts
70
+ function createMemoryStore() {
71
+ const map = /* @__PURE__ */ new Map();
72
+ return {
73
+ get(key, fileSize) {
74
+ const stored = map.get(key);
75
+ if (!stored) return null;
76
+ return stored.fileSize === fileSize ? stored : null;
77
+ },
78
+ set(upload) {
79
+ map.set(upload.key, upload);
80
+ },
81
+ delete(key) {
82
+ map.delete(key);
83
+ }
84
+ };
25
85
  }
26
86
 
27
87
  // src/upload/constants.ts
28
- var DEFAULT_MULTIPART_THRESHOLD = 30 * 1024 * 1024;
29
- var DEFAULT_PART_SIZE = 10 * 1024 * 1024;
88
+ var DEFAULT_MULTIPART_THRESHOLD = 50 * 1024 * 1024;
89
+ var DEFAULT_PART_SIZE = 5 * 1024 * 1024;
30
90
  var MAX_RETRIES = 3;
31
91
  var RETRY_BASE_DELAY = 1e3;
32
- var DEFAULT_CONCURRENT_PARTS = 3;
92
+ var DEFAULT_CONCURRENT_PARTS = 2;
33
93
  var DEFAULT_CONCURRENT_FILES = 2;
34
94
 
35
95
  // src/upload/retry.ts
36
- async function withRetry(fn, retries, signal) {
96
+ async function withRetry(fn, retryConfig, signal) {
97
+ const maxRetries = retryConfig?.maxRetries ?? MAX_RETRIES;
98
+ const baseDelay = retryConfig?.baseDelay ?? RETRY_BASE_DELAY;
37
99
  let lastError;
38
- for (let attempt = 0; attempt <= retries; attempt++) {
100
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
39
101
  try {
40
102
  return await fn();
41
103
  } catch (err) {
42
104
  if (err.name === "AbortError") throw err;
43
105
  lastError = err;
44
- if (attempt < retries) {
45
- const delay = RETRY_BASE_DELAY * 2 ** attempt;
106
+ if (attempt < maxRetries) {
107
+ const delay = baseDelay * 2 ** attempt;
46
108
  await new Promise((r) => setTimeout(r, delay));
47
109
  if (signal?.aborted)
48
110
  throw new DOMException("Upload aborted", "AbortError");
@@ -177,22 +239,69 @@ function uploadPart(blob, presignedUrl, partLoaded, totalSize, reportProgress, s
177
239
  }
178
240
 
179
241
  // src/upload/multipart.ts
180
- async function uploadMultipart(api, file, objectKey, partSize, concurrentParts, onProgress, signal, requestOptions) {
242
+ function resolvePartSize(partIndex, totalParts, partSize, fileSize) {
243
+ return partIndex === totalParts - 1 ? fileSize - partIndex * partSize : partSize;
244
+ }
245
+ async function uploadMultipart(api, file, objectKey, partSize, concurrentParts, onProgress, signal, requestOptions, retryConfig, uploadStore, onPartUpload, onMultipartInit) {
246
+ const bucket = requestOptions?.bucket;
181
247
  const contentType = requestOptions?.contentType ?? file.type;
182
- const { uploadId, key } = await api.multipart.init({
183
- key: objectKey,
184
- contentType,
185
- fileSize: file.size,
186
- fileName: requestOptions?.fileName !== null ? requestOptions?.fileName ?? file.name : void 0,
187
- metadata: requestOptions?.metadata,
188
- bucket: requestOptions?.bucket,
189
- acl: requestOptions?.acl
190
- });
248
+ const store = uploadStore != null && uploadStore !== false ? uploadStore : null;
249
+ let uploadId;
250
+ let key;
251
+ const completedPartNumbers = /* @__PURE__ */ new Set();
252
+ const existing = store ? await store.get(objectKey, file.size) : null;
253
+ if (existing) {
254
+ try {
255
+ const { parts: parts2 } = await api.multipart.listParts({
256
+ key: existing.key,
257
+ uploadId: existing.uploadId,
258
+ bucket: bucket ?? existing.bucket
259
+ });
260
+ uploadId = existing.uploadId;
261
+ key = existing.key;
262
+ for (const p of parts2) completedPartNumbers.add(p.partNumber);
263
+ onMultipartInit?.(uploadId, key);
264
+ } catch {
265
+ await store?.delete(objectKey);
266
+ const result = await api.multipart.init({
267
+ key: objectKey,
268
+ contentType,
269
+ fileSize: file.size,
270
+ fileName: requestOptions?.fileName !== null ? requestOptions?.fileName ?? file.name : void 0,
271
+ metadata: requestOptions?.metadata,
272
+ bucket,
273
+ acl: requestOptions?.acl
274
+ });
275
+ uploadId = result.uploadId;
276
+ key = result.key;
277
+ await store?.set({ uploadId, key, fileSize: file.size, bucket });
278
+ onMultipartInit?.(uploadId, key);
279
+ }
280
+ } else {
281
+ const result = await api.multipart.init({
282
+ key: objectKey,
283
+ contentType,
284
+ fileSize: file.size,
285
+ fileName: requestOptions?.fileName !== null ? requestOptions?.fileName ?? file.name : void 0,
286
+ metadata: requestOptions?.metadata,
287
+ bucket,
288
+ acl: requestOptions?.acl
289
+ });
290
+ uploadId = result.uploadId;
291
+ key = result.key;
292
+ await store?.set({ uploadId, key, fileSize: file.size, bucket });
293
+ onMultipartInit?.(uploadId, key);
294
+ }
191
295
  const totalParts = Math.ceil(file.size / partSize);
192
- const parts = [];
193
296
  const partProgress = Array.from(
194
297
  { length: totalParts },
195
- () => ({ bytes: 0 })
298
+ (_, i) => ({
299
+ bytes: completedPartNumbers.has(i + 1) ? resolvePartSize(i, totalParts, partSize, file.size) : 0
300
+ })
301
+ );
302
+ const parts = Array.from(
303
+ completedPartNumbers,
304
+ (n) => ({ partNumber: n })
196
305
  );
197
306
  const reportProgress = () => {
198
307
  const loaded = partProgress.reduce((sum, p) => sum + p.bytes, 0);
@@ -202,6 +311,9 @@ async function uploadMultipart(api, file, objectKey, partSize, concurrentParts,
202
311
  percent: Math.round(loaded / file.size * 100)
203
312
  });
204
313
  };
314
+ if (completedPartNumbers.size > 0) {
315
+ reportProgress();
316
+ }
205
317
  try {
206
318
  for (let batchStart = 0; batchStart < totalParts; batchStart += concurrentParts) {
207
319
  if (signal?.aborted) {
@@ -210,10 +322,11 @@ async function uploadMultipart(api, file, objectKey, partSize, concurrentParts,
210
322
  const batchEnd = Math.min(batchStart + concurrentParts, totalParts);
211
323
  const batch = [];
212
324
  for (let i = batchStart; i < batchEnd; i++) {
325
+ const partNumber = i + 1;
326
+ if (completedPartNumbers.has(partNumber)) continue;
213
327
  const start = i * partSize;
214
328
  const end = Math.min(start + partSize, file.size);
215
329
  const blob = file.slice(start, end);
216
- const partNumber = i + 1;
217
330
  batch.push(
218
331
  withRetry(
219
332
  async () => {
@@ -221,11 +334,8 @@ async function uploadMultipart(api, file, objectKey, partSize, concurrentParts,
221
334
  key,
222
335
  uploadId,
223
336
  partNumber,
224
- // Pass the exact byte count so the server binds Content-Length
225
- // into the HMAC signature. S3 will then reject any PUT whose
226
- // body size differs from blob.size with SignatureDoesNotMatch.
227
337
  partSize: blob.size,
228
- bucket: requestOptions?.bucket
338
+ bucket
229
339
  });
230
340
  partProgress[i].bytes = 0;
231
341
  await uploadPart(
@@ -236,9 +346,10 @@ async function uploadMultipart(api, file, objectKey, partSize, concurrentParts,
236
346
  reportProgress,
237
347
  signal
238
348
  );
349
+ onPartUpload?.(partNumber, totalParts);
239
350
  return { partNumber };
240
351
  },
241
- MAX_RETRIES,
352
+ retryConfig,
242
353
  signal
243
354
  )
244
355
  );
@@ -251,13 +362,16 @@ async function uploadMultipart(api, file, objectKey, partSize, concurrentParts,
251
362
  key,
252
363
  uploadId,
253
364
  parts,
254
- bucket: requestOptions?.bucket
365
+ bucket
255
366
  });
367
+ await store?.delete(objectKey);
256
368
  onProgress?.({ loaded: file.size, total: file.size, percent: 100 });
257
369
  return result.eTag;
258
370
  } catch (err) {
259
- api.multipart.abort({ key, uploadId, bucket: requestOptions?.bucket }).catch(() => {
260
- });
371
+ if (store === null) {
372
+ api.multipart.abort({ key, uploadId, bucket }).catch(() => {
373
+ });
374
+ }
261
375
  throw err;
262
376
  }
263
377
  }
@@ -267,6 +381,7 @@ async function uploadFile(api, file, objectKey, config = {}, callbacks = {}, sig
267
381
  const threshold = config.multipartThreshold ?? DEFAULT_MULTIPART_THRESHOLD;
268
382
  const useMultipart = config.multipart === true && file.size >= threshold;
269
383
  const concurrentParts = config.concurrentParts ?? DEFAULT_CONCURRENT_PARTS;
384
+ const partSize = requestOptions?.partSize ?? config.partSize ?? DEFAULT_PART_SIZE;
270
385
  const contentType = requestOptions?.contentType ?? file.type;
271
386
  let eTag;
272
387
  if (useMultipart) {
@@ -275,11 +390,15 @@ async function uploadFile(api, file, objectKey, config = {}, callbacks = {}, sig
275
390
  api,
276
391
  file,
277
392
  objectKey,
278
- DEFAULT_PART_SIZE,
393
+ partSize,
279
394
  concurrentParts,
280
395
  callbacks.onProgress,
281
396
  signal,
282
- requestOptions
397
+ requestOptions,
398
+ config.retry,
399
+ config.uploadStore,
400
+ callbacks.onPartUpload,
401
+ callbacks.onMultipartInit
283
402
  );
284
403
  } else {
285
404
  await withRetry(
@@ -313,7 +432,7 @@ async function uploadFile(api, file, objectKey, config = {}, callbacks = {}, sig
313
432
  );
314
433
  }
315
434
  },
316
- MAX_RETRIES,
435
+ config.retry,
317
436
  signal
318
437
  );
319
438
  callbacks.onPhaseChange?.("finalizing");
@@ -398,6 +517,143 @@ async function uploadFiles(api, items, config = {}, callbacks = {}, signal, getR
398
517
  await Promise.all(workers);
399
518
  return results;
400
519
  }
520
+
521
+ // src/helpers/format-file-size.ts
522
+ function formatFileSize(bytes) {
523
+ if (bytes === 0) return "0 B";
524
+ const units = ["B", "KB", "MB", "GB", "TB"];
525
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
526
+ const size = bytes / Math.pow(1024, i);
527
+ return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
528
+ }
529
+
530
+ // src/helpers/validate-file.ts
531
+ function validateFile(file, options) {
532
+ if (options.accept?.length) {
533
+ const allowed = options.accept.some((type) => {
534
+ if (type.startsWith(".")) {
535
+ return file.name.toLowerCase().endsWith(type.toLowerCase());
536
+ }
537
+ if (type.endsWith("/*")) {
538
+ return file.type.startsWith(type.replace("/*", "/"));
539
+ }
540
+ return file.type === type;
541
+ });
542
+ if (!allowed) {
543
+ const ext = file.name.includes(".") ? file.name.split(".").pop() : null;
544
+ return `File type "${ext ? `.${ext}` : file.type || "unknown"}" is not allowed`;
545
+ }
546
+ }
547
+ if (file.size === 0) {
548
+ return "File is empty";
549
+ }
550
+ if (options.maxFileSize && file.size > options.maxFileSize) {
551
+ const maxMB = (options.maxFileSize / (1024 * 1024)).toFixed(1);
552
+ return `File size exceeds ${maxMB} MB limit`;
553
+ }
554
+ return null;
555
+ }
556
+
557
+ // src/helpers/parse-content-disposition.ts
558
+ function parseContentDispositionFilename(header, fallback) {
559
+ if (!header) return fallback;
560
+ const starMatch = header.match(/filename\*=UTF-8''([^;,\s]+)/i);
561
+ if (starMatch) {
562
+ try {
563
+ return decodeURIComponent(starMatch[1]);
564
+ } catch {
565
+ }
566
+ }
567
+ const match = header.match(/filename="([^"]+)"/i);
568
+ if (match) return match[1];
569
+ return fallback;
570
+ }
571
+
572
+ // src/helpers/format-upload-progress.ts
573
+ function formatUploadProgress(loaded, total, percent) {
574
+ const loadedStr = formatFileSize(loaded);
575
+ if (!total) return loadedStr;
576
+ return `${loadedStr} / ${formatFileSize(total)} (${Math.round(percent)}%)`;
577
+ }
578
+
579
+ // src/helpers/format-speed.ts
580
+ function formatSpeed(bytesPerSecond) {
581
+ return `${formatFileSize(bytesPerSecond)}/s`;
582
+ }
583
+
584
+ // src/helpers/format-eta.ts
585
+ function formatEta(remainingBytes, bytesPerSecond) {
586
+ if (bytesPerSecond <= 0 || remainingBytes <= 0) return null;
587
+ const totalSeconds = remainingBytes / bytesPerSecond;
588
+ if (totalSeconds < 60) return `${Math.ceil(totalSeconds)}s`;
589
+ const totalMinutes = totalSeconds / 60;
590
+ if (totalMinutes < 60) return `${Math.ceil(totalMinutes)}m`;
591
+ const hours = Math.floor(totalMinutes / 60);
592
+ const mins = Math.ceil(totalMinutes % 60);
593
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
594
+ }
595
+
596
+ // src/helpers/build-object-key.ts
597
+ function buildObjectKey(...parts) {
598
+ return parts.map((p) => p.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/");
599
+ }
600
+
601
+ // src/helpers/get-file-extension.ts
602
+ function getFileExtension(filename) {
603
+ const dotIndex = filename.lastIndexOf(".");
604
+ if (dotIndex < 1) return "";
605
+ return filename.slice(dotIndex + 1).toLowerCase();
606
+ }
607
+
608
+ // src/helpers/truncate-filename.ts
609
+ function truncateFilename(name, maxChars = 26) {
610
+ if (name.length <= maxChars) return name;
611
+ const dotIndex = name.lastIndexOf(".");
612
+ if (dotIndex <= 0) {
613
+ return name.slice(0, maxChars - 1) + "\u2026";
614
+ }
615
+ const ext = name.slice(dotIndex);
616
+ const available = maxChars - ext.length - 1;
617
+ if (available <= 0) {
618
+ return name.slice(0, maxChars - 1) + "\u2026";
619
+ }
620
+ return name.slice(0, available) + "\u2026 " + ext;
621
+ }
622
+
623
+ // src/helpers/speed-tracker.ts
624
+ function createSpeedTracker(windowMs = 3e3) {
625
+ const samples = [];
626
+ return {
627
+ /**
628
+ * Record the latest cumulative `loaded` byte count.
629
+ * Returns the current speed in bytes/second (0 until at least 2 samples exist).
630
+ */
631
+ update(loaded) {
632
+ const now = Date.now();
633
+ samples.push({ t: now, loaded });
634
+ const cutoff = now - windowMs;
635
+ while (samples.length > 1 && samples[0].t < cutoff) {
636
+ samples.shift();
637
+ }
638
+ if (samples.length < 2) return 0;
639
+ const oldest = samples[0];
640
+ const newest = samples[samples.length - 1];
641
+ const deltaMs = newest.t - oldest.t;
642
+ const deltaBytes = newest.loaded - oldest.loaded;
643
+ return deltaMs > 0 ? Math.round(deltaBytes / deltaMs * 1e3) : 0;
644
+ },
645
+ reset() {
646
+ samples.length = 0;
647
+ }
648
+ };
649
+ }
650
+ function useLiveRef(value) {
651
+ const ref = useRef(value);
652
+ ref.current = value;
653
+ return ref;
654
+ }
655
+
656
+ // src/hooks/use-upload.ts
401
657
  var INITIAL_PROGRESS = { loaded: 0, total: 0, percent: 0 };
402
658
  var INITIAL_STATE = {
403
659
  phase: "idle",
@@ -409,9 +665,15 @@ var INITIAL_STATE = {
409
665
  };
410
666
  function useUpload(options) {
411
667
  const [state, setState] = useState(INITIAL_STATE);
412
- const optionsRef = useRef(options);
413
- optionsRef.current = options;
668
+ const contextApi = useContext(S3Context);
669
+ const optsRef = useLiveRef(options);
670
+ const apiRef = useLiveRef(contextApi);
414
671
  const abortRef = useRef(null);
672
+ const activeUploadRef = useRef(null);
673
+ const detachingRef = useRef(false);
674
+ const speedTrackerRef = useRef(createSpeedTracker());
675
+ const lastSpeedRef = useRef(void 0);
676
+ const lastSpeedUpdateRef = useRef(0);
415
677
  const upload = useCallback(
416
678
  async (file, objectKey, requestOptions) => {
417
679
  setState({
@@ -420,7 +682,12 @@ function useUpload(options) {
420
682
  fileName: file.name,
421
683
  fileSize: file.size
422
684
  });
423
- const opts = optionsRef.current;
685
+ const opts = optsRef.current;
686
+ const api = opts.api ?? apiRef.current;
687
+ if (!api)
688
+ throw new Error(
689
+ "[better-s3] No S3Api client found. Pass `api` to useUpload or wrap with <S3Provider>."
690
+ );
424
691
  const validationError = validateFile(file, {
425
692
  accept: opts.accept,
426
693
  maxFileSize: opts.maxFileSize
@@ -442,26 +709,55 @@ function useUpload(options) {
442
709
  return;
443
710
  }
444
711
  }
712
+ speedTrackerRef.current.reset();
713
+ lastSpeedRef.current = void 0;
714
+ lastSpeedUpdateRef.current = 0;
445
715
  setState((s) => ({ ...s, phase: "presigning" }));
446
716
  opts.onUploadStart?.(file, objectKey);
447
717
  const controller = new AbortController();
448
718
  abortRef.current = controller;
719
+ activeUploadRef.current = {
720
+ file,
721
+ objectKey,
722
+ serverKey: objectKey,
723
+ bucket: requestOptions?.bucket,
724
+ requestOptions
725
+ };
449
726
  try {
450
727
  const result = await uploadFile(
451
- opts.api,
728
+ api,
452
729
  file,
453
730
  objectKey,
454
731
  {
455
732
  multipart: opts.multipart,
456
733
  multipartThreshold: opts.multipartThreshold,
457
- concurrentParts: opts.concurrentParts
734
+ concurrentParts: opts.concurrentParts,
735
+ partSize: opts.partSize,
736
+ retry: opts.retry,
737
+ uploadStore: opts.uploadStore
458
738
  },
459
739
  {
460
740
  onProgress: (progress) => {
461
- setState((s) => ({ ...s, progress }));
462
- opts.onProgress?.(file, progress);
741
+ const rawSpeed = speedTrackerRef.current.update(progress.loaded);
742
+ const now = Date.now();
743
+ if (rawSpeed > 0 && now - lastSpeedUpdateRef.current >= 500) {
744
+ lastSpeedRef.current = rawSpeed;
745
+ lastSpeedUpdateRef.current = now;
746
+ }
747
+ const speed = lastSpeedRef.current;
748
+ const p = speed ? { ...progress, speed } : progress;
749
+ setState((s) => ({ ...s, progress: p }));
750
+ opts.onProgress?.(file, p);
463
751
  },
464
- onPhaseChange: (phase) => setState((s) => ({ ...s, phase }))
752
+ onPhaseChange: (phase) => setState((s) => ({ ...s, phase })),
753
+ onPartUpload: (partNumber, totalParts) => opts.onPartUpload?.(file, partNumber, totalParts),
754
+ onMultipartInit: (uploadId, serverKey) => {
755
+ if (activeUploadRef.current) {
756
+ activeUploadRef.current.uploadId = uploadId;
757
+ activeUploadRef.current.serverKey = serverKey;
758
+ }
759
+ opts.onMultipartInit?.(file, uploadId);
760
+ }
465
761
  },
466
762
  controller.signal,
467
763
  requestOptions
@@ -475,6 +771,10 @@ function useUpload(options) {
475
771
  await opts.onSuccess?.(file, result);
476
772
  } catch (err) {
477
773
  if (err.name === "AbortError") {
774
+ if (detachingRef.current) {
775
+ detachingRef.current = false;
776
+ return;
777
+ }
478
778
  opts.onCancel?.(file);
479
779
  setState(INITIAL_STATE);
480
780
  return;
@@ -484,11 +784,34 @@ function useUpload(options) {
484
784
  opts.onError?.(file, err, "uploading");
485
785
  } finally {
486
786
  abortRef.current = null;
787
+ activeUploadRef.current = null;
487
788
  }
488
789
  },
489
790
  []
490
791
  );
491
792
  const cancel = useCallback(() => {
793
+ const opts = optsRef.current;
794
+ const api = opts.api ?? apiRef.current;
795
+ const active = activeUploadRef.current;
796
+ abortRef.current?.abort();
797
+ if (active && api) {
798
+ const { objectKey, serverKey, uploadId, bucket } = active;
799
+ const store = opts.uploadStore;
800
+ if (store != null && store !== false) {
801
+ void Promise.resolve(store.delete(objectKey)).catch(() => {
802
+ });
803
+ }
804
+ if (uploadId) {
805
+ api.multipart.abort({ key: serverKey, uploadId, bucket }).catch(() => {
806
+ });
807
+ }
808
+ }
809
+ setState(INITIAL_STATE);
810
+ }, []);
811
+ const detach = useCallback(() => {
812
+ const active = activeUploadRef.current;
813
+ if (!active) return;
814
+ detachingRef.current = true;
492
815
  abortRef.current?.abort();
493
816
  setState(INITIAL_STATE);
494
817
  }, []);
@@ -496,7 +819,7 @@ function useUpload(options) {
496
819
  abortRef.current?.abort();
497
820
  setState(INITIAL_STATE);
498
821
  }, []);
499
- return { ...state, upload, cancel, reset };
822
+ return { ...state, upload, cancel, detach, reset };
500
823
  }
501
824
  var INITIAL_PROGRESS2 = { loaded: 0, total: 0, percent: 0 };
502
825
  var INITIAL_STATE2 = {
@@ -505,19 +828,31 @@ var INITIAL_STATE2 = {
505
828
  totalProgress: INITIAL_PROGRESS2,
506
829
  error: null
507
830
  };
508
- var nextId = 0;
509
831
  function generateId() {
510
- return `file-${++nextId}`;
832
+ return crypto.randomUUID();
511
833
  }
512
834
  function useMultiUpload(options) {
513
835
  const [state, setState] = useState(INITIAL_STATE2);
514
- const optionsRef = useRef(options);
515
- optionsRef.current = options;
836
+ const contextApi = useContext(S3Context);
837
+ const optsRef = useLiveRef(options);
838
+ const apiRef = useLiveRef(contextApi);
516
839
  const abortRef = useRef(null);
840
+ const resettingRef = useRef(false);
517
841
  const fileMapRef = useRef(/* @__PURE__ */ new Map());
842
+ const fileSpeedTrackersRef = useRef(/* @__PURE__ */ new Map());
843
+ const totalSpeedTrackerRef = useRef(createSpeedTracker());
844
+ const fileLastSpeedRef = useRef(/* @__PURE__ */ new Map());
845
+ const fileLastSpeedUpdateRef = useRef(/* @__PURE__ */ new Map());
846
+ const lastTotalSpeedRef = useRef(void 0);
847
+ const lastTotalSpeedUpdateRef = useRef(0);
518
848
  const upload = useCallback(
519
849
  async (files, resolveKey) => {
520
- const opts = optionsRef.current;
850
+ const opts = optsRef.current;
851
+ const api = opts.api ?? apiRef.current;
852
+ if (!api)
853
+ throw new Error(
854
+ "[better-s3] No S3Api client found. Pass `api` to useMultiUpload or wrap with <S3Provider>."
855
+ );
521
856
  const items = [];
522
857
  const fileStates = [];
523
858
  const fileMap = /* @__PURE__ */ new Map();
@@ -578,28 +913,49 @@ function useMultiUpload(options) {
578
913
  error: null
579
914
  });
580
915
  opts.onUploadStart?.(files);
916
+ fileSpeedTrackersRef.current.clear();
917
+ for (const item of items) {
918
+ fileSpeedTrackersRef.current.set(item.id, createSpeedTracker());
919
+ }
920
+ totalSpeedTrackerRef.current.reset();
921
+ fileLastSpeedRef.current.clear();
922
+ fileLastSpeedUpdateRef.current.clear();
923
+ lastTotalSpeedRef.current = void 0;
924
+ lastTotalSpeedUpdateRef.current = 0;
581
925
  const controller = new AbortController();
582
926
  abortRef.current = controller;
583
927
  try {
584
928
  const results = await uploadFiles(
585
- opts.api,
929
+ api,
586
930
  items,
587
931
  {
588
932
  multipart: opts.multipart,
589
933
  multipartThreshold: opts.multipartThreshold,
590
934
  concurrentParts: opts.concurrentParts,
591
- concurrentFiles: opts.concurrentFiles
935
+ concurrentFiles: opts.concurrentFiles,
936
+ partSize: opts.partSize,
937
+ retry: opts.retry,
938
+ uploadStore: opts.uploadStore
592
939
  },
593
940
  {
594
941
  onFileProgress: (id, progress) => {
942
+ const tracker = fileSpeedTrackersRef.current.get(id);
943
+ const rawSpeed = tracker ? tracker.update(progress.loaded) : 0;
944
+ const now = Date.now();
945
+ if (rawSpeed > 0 && now - (fileLastSpeedUpdateRef.current.get(id) ?? 0) >= 500) {
946
+ fileLastSpeedRef.current.set(id, rawSpeed);
947
+ fileLastSpeedUpdateRef.current.set(id, now);
948
+ }
949
+ const speed = fileLastSpeedRef.current.get(id);
950
+ const p = speed ? { ...progress, speed } : progress;
595
951
  setState((s) => ({
596
952
  ...s,
597
953
  files: s.files.map(
598
- (f) => f.id === id ? { ...f, status: "uploading", progress } : f
954
+ (f) => f.id === id ? { ...f, status: "uploading", progress: p } : f
599
955
  )
600
956
  }));
601
957
  const file = fileMap.get(id);
602
- if (file) opts.onFileProgress?.(file, progress);
958
+ if (file) opts.onFileProgress?.(file, p);
603
959
  },
604
960
  onFileSuccess: (id, result) => {
605
961
  setState((s) => ({
@@ -630,8 +986,18 @@ function useMultiUpload(options) {
630
986
  if (file) opts.onFileError?.(file, error);
631
987
  },
632
988
  onTotalProgress: (progress) => {
633
- setState((s) => ({ ...s, totalProgress: progress }));
634
- opts.onProgress?.(progress);
989
+ const rawSpeed = totalSpeedTrackerRef.current.update(
990
+ progress.loaded
991
+ );
992
+ const now = Date.now();
993
+ if (rawSpeed > 0 && now - lastTotalSpeedUpdateRef.current >= 1e3) {
994
+ lastTotalSpeedRef.current = rawSpeed;
995
+ lastTotalSpeedUpdateRef.current = now;
996
+ }
997
+ const speed = lastTotalSpeedRef.current;
998
+ const p = speed ? { ...progress, speed } : progress;
999
+ setState((s) => ({ ...s, totalProgress: p }));
1000
+ opts.onProgress?.(p);
635
1001
  }
636
1002
  },
637
1003
  controller.signal,
@@ -658,7 +1024,8 @@ function useMultiUpload(options) {
658
1024
  }
659
1025
  } catch (err) {
660
1026
  if (err.name === "AbortError") {
661
- opts.onCancel?.();
1027
+ if (!resettingRef.current) opts.onCancel?.();
1028
+ resettingRef.current = false;
662
1029
  setState(INITIAL_STATE2);
663
1030
  return;
664
1031
  }
@@ -676,90 +1043,43 @@ function useMultiUpload(options) {
676
1043
  setState(INITIAL_STATE2);
677
1044
  }, []);
678
1045
  const reset = useCallback(() => {
1046
+ resettingRef.current = true;
679
1047
  abortRef.current?.abort();
680
1048
  setState(INITIAL_STATE2);
681
1049
  }, []);
682
1050
  return { ...state, upload, cancel, reset };
683
1051
  }
684
- var INITIAL_PROGRESS3 = { loaded: 0, total: 0, percent: 0 };
685
1052
  function useUploadControls(options) {
686
- const isMulti = (options.maxFiles ?? 1) > 1;
687
- const singleOpts = {
688
- api: options.api,
689
- accept: options.accept,
690
- maxFileSize: options.maxFileSize,
691
- multipart: options.multipart,
692
- multipartThreshold: options.multipartThreshold,
693
- concurrentParts: options.concurrentParts,
694
- beforeUpload: options.beforeUpload,
695
- onUploadStart: options.onUploadStart,
696
- onProgress: options.onProgress,
697
- onSuccess: options.onSuccess,
698
- onError: options.onError,
699
- onCancel: options.onCancel
700
- };
701
- const multiOpts = {
702
- api: options.api,
703
- accept: options.accept,
704
- maxFileSize: options.maxFileSize,
705
- maxFiles: options.maxFiles,
706
- multipart: options.multipart,
707
- multipartThreshold: options.multipartThreshold,
708
- concurrentParts: options.concurrentParts,
709
- concurrentFiles: options.concurrentFiles,
710
- uploadOptions: options.uploadOptions,
711
- getUploadOptions: options.getUploadOptions,
712
- beforeUpload: options.beforeUpload,
713
- onUploadStart: options.onUploadStart,
714
- onProgress: options.onProgress,
715
- onSuccess: options.onSuccess,
716
- onError: options.onError,
717
- onCancel: options.onCancel,
718
- onFileProgress: options.onFileProgress,
719
- onFileSuccess: options.onFileSuccess,
720
- onFileError: options.onFileError
721
- };
722
- const single = useUpload(singleOpts);
723
- const multi = useMultiUpload(multiOpts);
1053
+ const single = useUpload(options);
724
1054
  const inputRef = useRef(null);
725
1055
  const [fileInfo, setFileInfo] = useState(null);
726
1056
  const resolveKey = (file) => typeof options.objectKey === "function" ? options.objectKey(file) : options.objectKey;
727
1057
  const handleFiles = async (files) => {
728
- if (isMulti) {
729
- if (!files?.length) return;
730
- await multi.upload(Array.from(files), resolveKey);
731
- } else {
732
- const file = files?.[0];
733
- if (!file) return;
734
- setFileInfo({ name: file.name, size: file.size });
735
- await single.upload(file, resolveKey(file), {
736
- ...options.uploadOptions,
737
- ...options.getUploadOptions?.(file)
738
- });
739
- }
1058
+ const file = files?.[0];
1059
+ if (!file) return;
1060
+ setFileInfo({ name: file.name, size: file.size });
1061
+ await single.upload(file, resolveKey(file), {
1062
+ ...options.uploadOptions,
1063
+ ...options.getUploadOptions?.(file)
1064
+ });
740
1065
  };
741
- const openFilePicker = () => inputRef.current?.click();
742
- const isUploading = isMulti ? multi.phase === "uploading" : single.phase === "uploading";
743
1066
  return {
744
- mode: isMulti ? "multi" : "single",
745
- phase: isMulti ? multi.phase : single.phase,
746
- fileInfo: isMulti ? null : fileInfo,
747
- progress: isMulti ? INITIAL_PROGRESS3 : single.progress,
748
- files: isMulti ? multi.files : [],
749
- totalProgress: isMulti ? multi.totalProgress : INITIAL_PROGRESS3,
750
- error: isMulti ? multi.error : single.error,
751
- isUploading,
1067
+ phase: single.phase,
1068
+ fileInfo,
1069
+ progress: single.progress,
1070
+ error: single.error,
1071
+ isUploading: single.phase === "uploading",
752
1072
  handleFiles,
753
- openFilePicker,
754
- cancel: isMulti ? multi.cancel : single.cancel,
755
- reset: isMulti ? multi.reset : () => {
1073
+ openFilePicker: () => inputRef.current?.click(),
1074
+ cancel: single.cancel,
1075
+ detach: single.detach,
1076
+ reset: () => {
756
1077
  single.reset();
757
1078
  setFileInfo(null);
758
1079
  },
759
1080
  inputProps: {
760
1081
  ref: inputRef,
761
1082
  type: "file",
762
- ...isMulti && { multiple: true },
763
1083
  accept: options.accept?.join(","),
764
1084
  hidden: true,
765
1085
  onChange: (e) => {
@@ -775,7 +1095,49 @@ function useUploadControls(options) {
775
1095
  onDrop: (e) => {
776
1096
  e.preventDefault();
777
1097
  e.stopPropagation();
778
- if (!isUploading) handleFiles(e.dataTransfer.files);
1098
+ if (single.phase !== "uploading") handleFiles(e.dataTransfer.files);
1099
+ }
1100
+ }
1101
+ };
1102
+ }
1103
+ function useMultiUploadControls(options) {
1104
+ const multi = useMultiUpload(options);
1105
+ const inputRef = useRef(null);
1106
+ const resolveKey = (file) => typeof options.objectKey === "function" ? options.objectKey(file) : options.objectKey;
1107
+ const handleFiles = async (files) => {
1108
+ if (!files?.length) return;
1109
+ await multi.upload(Array.from(files), resolveKey);
1110
+ };
1111
+ return {
1112
+ phase: multi.phase,
1113
+ files: multi.files,
1114
+ totalProgress: multi.totalProgress,
1115
+ error: multi.error,
1116
+ isUploading: multi.phase === "uploading",
1117
+ handleFiles,
1118
+ openFilePicker: () => inputRef.current?.click(),
1119
+ cancel: multi.cancel,
1120
+ reset: multi.reset,
1121
+ inputProps: {
1122
+ ref: inputRef,
1123
+ type: "file",
1124
+ multiple: true,
1125
+ accept: options.accept?.join(","),
1126
+ hidden: true,
1127
+ onChange: (e) => {
1128
+ handleFiles(e.target.files);
1129
+ e.target.value = "";
1130
+ }
1131
+ },
1132
+ dropHandlers: {
1133
+ onDragOver: (e) => {
1134
+ e.preventDefault();
1135
+ e.stopPropagation();
1136
+ },
1137
+ onDrop: (e) => {
1138
+ e.preventDefault();
1139
+ e.stopPropagation();
1140
+ if (multi.phase !== "uploading") handleFiles(e.dataTransfer.files);
779
1141
  }
780
1142
  }
781
1143
  };
@@ -788,13 +1150,19 @@ var INITIAL_STATE3 = {
788
1150
  };
789
1151
  function useDownload(options) {
790
1152
  const [state, setState] = useState(INITIAL_STATE3);
791
- const optionsRef = useRef(options);
792
- optionsRef.current = options;
1153
+ const contextApi = useContext(S3Context);
1154
+ const optsRef = useLiveRef(options);
1155
+ const apiRef = useLiveRef(contextApi);
793
1156
  const presign = useCallback(async (key, downloadName) => {
794
- const opts = optionsRef.current;
1157
+ const opts = optsRef.current;
1158
+ const api = opts.api ?? apiRef.current;
1159
+ if (!api)
1160
+ throw new Error(
1161
+ "[better-s3] No S3Api client found. Pass `api` to useDownload or wrap with <S3Provider>."
1162
+ );
795
1163
  setState({ phase: "presigning", error: null, url: null, expiresIn: null });
796
1164
  try {
797
- const result = await opts.api.download(key, {
1165
+ const result = await api.download(key, {
798
1166
  fileName: downloadName,
799
1167
  bucket: opts.bucket
800
1168
  });
@@ -814,7 +1182,7 @@ function useDownload(options) {
814
1182
  }, []);
815
1183
  const download = useCallback(
816
1184
  async (key, downloadName) => {
817
- const opts = optionsRef.current;
1185
+ const opts = optsRef.current;
818
1186
  if (opts.beforeDownload) {
819
1187
  const allowed = await opts.beforeDownload(key);
820
1188
  if (!allowed) {
@@ -835,31 +1203,35 @@ function useDownload(options) {
835
1203
  },
836
1204
  [presign]
837
1205
  );
838
- const reset = useCallback(() => {
839
- setState(INITIAL_STATE3);
840
- }, []);
1206
+ const reset = useCallback(() => setState(INITIAL_STATE3), []);
841
1207
  return { ...state, download, presign, reset };
842
1208
  }
843
- var INITIAL_PROGRESS4 = {
844
- loaded: 0,
845
- total: 0,
846
- percent: 0
847
- };
1209
+ var INITIAL_PROGRESS3 = { loaded: 0, total: 0, percent: 0 };
848
1210
  var INITIAL_STATE4 = {
849
1211
  phase: "idle",
850
- progress: INITIAL_PROGRESS4,
1212
+ progress: INITIAL_PROGRESS3,
851
1213
  error: null,
852
1214
  fileName: null,
853
1215
  fileSize: null
854
1216
  };
855
1217
  function useFetchDownload(options) {
856
1218
  const [state, setState] = useState(INITIAL_STATE4);
857
- const optionsRef = useRef(options);
858
- optionsRef.current = options;
1219
+ const contextApi = useContext(S3Context);
1220
+ const optsRef = useLiveRef(options);
1221
+ const apiRef = useLiveRef(contextApi);
859
1222
  const abortRef = useRef(null);
1223
+ const resettingRef = useRef(false);
1224
+ const speedTrackerRef = useRef(createSpeedTracker());
1225
+ const lastSpeedRef = useRef(void 0);
1226
+ const lastSpeedUpdateRef = useRef(0);
860
1227
  const download = useCallback(async (key, downloadName) => {
861
1228
  const fallback = key.split("/").pop() ?? key;
862
- const opts = optionsRef.current;
1229
+ const opts = optsRef.current;
1230
+ const api = opts.api ?? apiRef.current;
1231
+ if (!api)
1232
+ throw new Error(
1233
+ "[better-s3] No S3Api client found. Pass `api` to useFetchDownload or wrap with <S3Provider>."
1234
+ );
863
1235
  if (opts.beforeDownload) {
864
1236
  const allowed = await opts.beforeDownload(key);
865
1237
  if (!allowed) {
@@ -874,18 +1246,21 @@ function useFetchDownload(options) {
874
1246
  }
875
1247
  setState({
876
1248
  phase: "presigning",
877
- progress: INITIAL_PROGRESS4,
1249
+ progress: INITIAL_PROGRESS3,
878
1250
  error: null,
879
1251
  fileName: downloadName ?? null,
880
1252
  fileSize: null
881
1253
  });
882
1254
  try {
883
- const { url } = await opts.api.download(key, {
1255
+ const { url } = await api.download(key, {
884
1256
  fileName: downloadName,
885
1257
  bucket: opts.bucket
886
1258
  });
887
1259
  setState((s) => ({ ...s, phase: "downloading" }));
888
1260
  opts.onDownloadStart?.(key);
1261
+ speedTrackerRef.current.reset();
1262
+ lastSpeedRef.current = void 0;
1263
+ lastSpeedUpdateRef.current = 0;
889
1264
  const controller = new AbortController();
890
1265
  abortRef.current = controller;
891
1266
  const res = await fetch(url, { signal: controller.signal });
@@ -914,10 +1289,18 @@ function useFetchDownload(options) {
914
1289
  chunks.push(value);
915
1290
  loaded += value.byteLength;
916
1291
  const percent = contentLength > 0 ? Math.round(loaded / contentLength * 100) : 0;
1292
+ const rawSpeed = speedTrackerRef.current.update(loaded);
1293
+ const now = Date.now();
1294
+ if (rawSpeed > 0 && now - lastSpeedUpdateRef.current >= 500) {
1295
+ lastSpeedRef.current = rawSpeed;
1296
+ lastSpeedUpdateRef.current = now;
1297
+ }
1298
+ const speed = lastSpeedRef.current;
917
1299
  const progress = {
918
1300
  loaded,
919
1301
  total: contentLength,
920
- percent
1302
+ percent,
1303
+ ...speed && { speed }
921
1304
  };
922
1305
  setState((s) => ({ ...s, progress }));
923
1306
  opts.onProgress?.(key, progress);
@@ -938,7 +1321,8 @@ function useFetchDownload(options) {
938
1321
  await opts.onSuccess?.(key, name ?? fallback);
939
1322
  } catch (err) {
940
1323
  if (err.name === "AbortError") {
941
- opts.onCancel?.(key);
1324
+ if (!resettingRef.current) opts.onCancel?.(key);
1325
+ resettingRef.current = false;
942
1326
  setState(INITIAL_STATE4);
943
1327
  return;
944
1328
  }
@@ -954,6 +1338,7 @@ function useFetchDownload(options) {
954
1338
  setState(INITIAL_STATE4);
955
1339
  }, []);
956
1340
  const reset = useCallback(() => {
1341
+ resettingRef.current = true;
957
1342
  abortRef.current?.abort();
958
1343
  setState(INITIAL_STATE4);
959
1344
  }, []);
@@ -961,56 +1346,66 @@ function useFetchDownload(options) {
961
1346
  }
962
1347
  var INITIAL_STATE5 = {
963
1348
  phase: "idle",
964
- error: null
1349
+ error: null,
1350
+ pendingKey: null
965
1351
  };
966
1352
  function useDelete(options) {
967
1353
  const [state, setState] = useState(INITIAL_STATE5);
968
- const [pendingKey, setPendingKey] = useState(null);
969
- const optionsRef = useRef(options);
970
- optionsRef.current = options;
1354
+ const contextApi = useContext(S3Context);
1355
+ const optsRef = useLiveRef(options);
1356
+ const apiRef = useLiveRef(contextApi);
1357
+ const pendingKeyRef = useRef(null);
971
1358
  const requestDelete = useCallback((key) => {
972
- setPendingKey(key);
973
- setState({ phase: "confirming", error: null });
1359
+ pendingKeyRef.current = key;
1360
+ setState({ phase: "confirming", error: null, pendingKey: key });
974
1361
  }, []);
975
1362
  const confirmDelete = useCallback(async () => {
976
- if (!pendingKey) return;
977
- const opts = optionsRef.current;
1363
+ const key = pendingKeyRef.current;
1364
+ if (!key) return;
1365
+ const opts = optsRef.current;
1366
+ const api = opts.api ?? apiRef.current;
1367
+ if (!api)
1368
+ throw new Error(
1369
+ "[better-s3] No S3Api client found. Pass `api` to useDelete or wrap with <S3Provider>."
1370
+ );
978
1371
  if (opts.beforeDelete) {
979
- const allowed = await opts.beforeDelete(pendingKey);
1372
+ const allowed = await opts.beforeDelete(key);
980
1373
  if (!allowed) {
981
1374
  setState({
982
1375
  phase: "error",
983
- error: "Delete blocked by beforeDelete hook"
1376
+ error: "Delete blocked by beforeDelete hook",
1377
+ pendingKey: null
984
1378
  });
985
- opts.onError?.(pendingKey, new Error("blocked"), "confirming");
986
- setPendingKey(null);
1379
+ opts.onError?.(key, new Error("blocked"), "confirming");
1380
+ pendingKeyRef.current = null;
987
1381
  return;
988
1382
  }
989
1383
  }
990
- setState({ phase: "deleting", error: null });
991
- opts.onDeleteStart?.(pendingKey);
1384
+ setState((s) => ({ ...s, phase: "deleting", error: null }));
1385
+ opts.onDeleteStart?.(key);
992
1386
  try {
993
- await opts.api.delete(pendingKey, { bucket: opts.bucket });
994
- setState({ phase: "success", error: null });
995
- await opts.onSuccess?.(pendingKey);
996
- setPendingKey(null);
1387
+ await api.delete(key, { bucket: opts.bucket });
1388
+ pendingKeyRef.current = null;
1389
+ setState({ phase: "success", error: null, pendingKey: null });
1390
+ await opts.onSuccess?.(key);
997
1391
  } catch (err) {
998
1392
  const message = err instanceof Error ? err.message : "Delete failed";
999
- setState({ phase: "error", error: message });
1000
- opts.onError?.(pendingKey, err, "deleting");
1393
+ setState((s) => ({ ...s, phase: "error", error: message }));
1394
+ opts.onError?.(key, err, "deleting");
1001
1395
  }
1002
- }, [pendingKey]);
1396
+ }, []);
1003
1397
  const cancelDelete = useCallback(() => {
1004
- setPendingKey(null);
1398
+ pendingKeyRef.current = null;
1005
1399
  setState(INITIAL_STATE5);
1006
1400
  }, []);
1007
1401
  const reset = useCallback(() => {
1008
- setPendingKey(null);
1402
+ pendingKeyRef.current = null;
1009
1403
  setState(INITIAL_STATE5);
1010
1404
  }, []);
1011
1405
  return {
1012
- ...state,
1013
- pendingKey,
1406
+ phase: state.phase,
1407
+ error: state.error,
1408
+ pendingKey: state.pendingKey,
1014
1409
  requestDelete,
1015
1410
  confirmDelete,
1016
1411
  cancelDelete,
@@ -1018,6 +1413,6 @@ function useDelete(options) {
1018
1413
  };
1019
1414
  }
1020
1415
 
1021
- export { formatFileSize, uploadFile, uploadFiles, useDelete, useDownload, useFetchDownload, useMultiUpload, useUpload, useUploadControls };
1416
+ export { S3Context, S3Provider, S3UploadError, buildObjectKey, createLocalStorageStore, createMemoryStore, createS3Api as createPresignApi, createS3Api, createS3Client, createSpeedTracker, formatEta, formatFileSize, formatSpeed, formatUploadProgress, getFileExtension, parseContentDispositionFilename, truncateFilename, uploadFile, uploadFiles, useDelete, useDownload, useFetchDownload, useMultiUpload, useMultiUploadControls, useS3Client, useUpload, useUploadControls, validateFile };
1022
1417
  //# sourceMappingURL=index.js.map
1023
1418
  //# sourceMappingURL=index.js.map