react-dropzone 19.0.2 → 19.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx CHANGED
@@ -14,15 +14,16 @@ import {
14
14
  isEvtWithFiles,
15
15
  isIeOrEdge,
16
16
  isNotAllowedError,
17
+ isThenable,
17
18
  isPropagationStopped,
18
19
  isSecurityError,
19
20
  onDocumentDragOver,
20
21
  pickerOptionsFromAccept,
21
22
  TOO_MANY_FILES_REJECTION
22
23
  } from "./utils";
23
- import type {Accept, FileError} from "./utils";
24
+ import type {Accept, FileError, ValidatorResult} from "./utils";
24
25
 
25
- export type {Accept, FileError, FileWithPath};
26
+ export type {Accept, FileError, FileWithPath, ValidatorResult};
26
27
  export {ErrorCode};
27
28
 
28
29
  export interface DropzoneProps extends DropzoneOptions {
@@ -54,7 +55,18 @@ export type DropzoneOptions = Pick<React.HTMLProps<HTMLElement>, SharedProps> &
54
55
  onFileDialogCancel?: () => void;
55
56
  onFileDialogOpen?: () => void;
56
57
  onError?: (err: Error) => void;
57
- validator?: <T extends File>(file: T) => FileError | readonly FileError[] | null;
58
+ /**
59
+ * Custom validation, run once per file on drop/selection. Return `null` to accept the file, or a
60
+ * {@link FileError} (or array of them) to reject it. May be `async` (return a `Promise`) to support
61
+ * checks that can't run synchronously - e.g. reading image dimensions, inspecting file contents,
62
+ * or calling an external service. While an async validator is pending, {@link DropzoneState.isProcessing}
63
+ * is `true`, and `onDrop`/`onDropAccepted`/`onDropRejected` fire only once it settles. If the
64
+ * validator throws or rejects, `onError` is called and the drop is discarded.
65
+ *
66
+ * Note: the validator never runs during a drag (a `DataTransferItem` has no name/size), so a
67
+ * validator-configured dropzone is `isDragUnknown` until drop.
68
+ */
69
+ validator?: <T extends File>(file: T) => ValidatorResult | Promise<ValidatorResult>;
58
70
  /**
59
71
  * Override the message of any rejection error (built-in or custom). Called once per error;
60
72
  * receives the error and the file it belongs to and returns the message to use. Return
@@ -79,6 +91,14 @@ export type DropzoneState = DropzoneRef & {
79
91
  isDragUnknown: boolean;
80
92
  isDragGlobal: boolean;
81
93
  isFileDialogActive: boolean;
94
+ /**
95
+ * `true` while a drop/selection is being processed asynchronously - i.e. while `getFilesFromEvent`
96
+ * reads the files and/or an async {@link DropzoneOptions.validator} runs. Spans the whole pipeline,
97
+ * from when files start being read until validation settles. When both are synchronous (the default
98
+ * `getFilesFromEvent` with no/async-free validator) the work resolves within a microtask, so it's
99
+ * only observable for genuinely async work. Use it to show a spinner or disable UI while processing.
100
+ */
101
+ isProcessing: boolean;
82
102
  acceptedFiles: readonly FileWithPath[];
83
103
  fileRejections: readonly FileRejection[];
84
104
  rootRef: React.RefObject<HTMLElement>;
@@ -133,10 +153,24 @@ interface DropzoneInternalState {
133
153
  isDragReject: boolean;
134
154
  isDragUnknown: boolean;
135
155
  isDragGlobal: boolean;
156
+ isProcessing: boolean;
136
157
  acceptedFiles: FileWithPath[];
137
158
  fileRejections: FileRejection[];
138
159
  }
139
160
 
161
+ /**
162
+ * The per-file outcome of the built-in checks plus the (resolved) custom validator, assembled in
163
+ * setFiles before the accepted/rejected split.
164
+ */
165
+ interface PerFileResult {
166
+ file: FileWithPath;
167
+ accepted: boolean;
168
+ acceptError: FileError | null;
169
+ sizeMatch: boolean;
170
+ sizeError: FileError | null;
171
+ customErrors: ValidatorResult;
172
+ }
173
+
140
174
  const initialState: DropzoneInternalState = {
141
175
  isFocused: false,
142
176
  isFileDialogActive: false,
@@ -145,6 +179,7 @@ const initialState: DropzoneInternalState = {
145
179
  isDragReject: false,
146
180
  isDragUnknown: false,
147
181
  isDragGlobal: false,
182
+ isProcessing: false,
148
183
  acceptedFiles: [],
149
184
  fileRejections: []
150
185
  };
@@ -228,6 +263,29 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
228
263
  const [state, dispatch] = useReducer(reducer, initialState);
229
264
  const {isFocused, isFileDialogActive} = state;
230
265
 
266
+ // Tracks the in-flight processing run - reading files (getFilesFromEvent) plus running an async
267
+ // validator. A newer drop/selection aborts the previous run so slow async work can't resolve late
268
+ // and clobber the state with stale results.
269
+ const processingAbortRef = useRef<AbortController | null>(null);
270
+
271
+ // Begin a processing run: supersede any run still in flight and flip {isProcessing} on. Returns
272
+ // the run's AbortSignal, which downstream async steps check to bail if a newer run took over.
273
+ const beginProcessing = useCallback(() => {
274
+ processingAbortRef.current?.abort();
275
+ const controller = new AbortController();
276
+ processingAbortRef.current = controller;
277
+ dispatch({type: "setProcessing", isProcessing: true});
278
+ return controller.signal;
279
+ }, []);
280
+
281
+ // End a processing run by clearing {isProcessing} - but only if this run is still the active one.
282
+ // A superseded run (signal aborted) leaves the flag to the run that replaced it.
283
+ const endProcessing = useCallback((signal: AbortSignal) => {
284
+ if (!signal.aborted) {
285
+ dispatch({type: "setProcessing", isProcessing: false});
286
+ }
287
+ }, []);
288
+
231
289
  const fsAccessApiWorksRef = useRef(
232
290
  typeof window !== "undefined" && window.isSecureContext && useFsAccessApi && canUseFileSystemAccessAPI()
233
291
  );
@@ -474,65 +532,114 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
474
532
  );
475
533
 
476
534
  const setFiles = useCallback(
477
- (files: FileWithPath[], event: any) => {
478
- const acceptedFiles: FileWithPath[] = [];
479
- const fileRejections: FileRejection[] = [];
480
-
535
+ async (files: FileWithPath[], event: any, signal: AbortSignal) => {
481
536
  const localizeError = (error: FileError, file: File): FileError =>
482
537
  getErrorMessage ? {...error, message: getErrorMessage(error, file)} : error;
483
538
 
484
- files.forEach(file => {
485
- const [accepted, acceptError] = fileAccepted(file, inputAcceptAttr);
486
- const [sizeMatch, sizeError] = fileMatchSize(file, minSize, maxSize);
487
- const customErrors = validator ? validator(file) : null;
539
+ // Commit the per-file verdicts: split accepted/rejected, cap the surplus, update state and
540
+ // fire the onDrop callbacks. Runs synchronously so nested-dropzone ordering is preserved on
541
+ // the fast path (an onDrop handler calling stopPropagation must do so before a parent's onDrop
542
+ // check - see the noDragEventsBubbling tests).
543
+ const commit = (results: Array<PerFileResult>) => {
544
+ const acceptedFiles: FileWithPath[] = [];
545
+ const fileRejections: FileRejection[] = [];
546
+
547
+ results.forEach(({file, accepted, acceptError, sizeMatch, sizeError, customErrors}) => {
548
+ if (accepted && sizeMatch && !customErrors) {
549
+ acceptedFiles.push(file);
550
+ } else {
551
+ let errors: Array<FileError | null> = [acceptError, sizeError];
488
552
 
489
- if (accepted && sizeMatch && !customErrors) {
490
- acceptedFiles.push(file);
491
- } else {
492
- let errors: Array<FileError | null> = [acceptError, sizeError];
553
+ if (customErrors) {
554
+ errors = errors.concat(customErrors);
555
+ }
493
556
 
494
- if (customErrors) {
495
- errors = errors.concat(customErrors);
557
+ fileRejections.push({
558
+ file,
559
+ errors: errors.filter((e): e is FileError => e != null).map(error => localizeError(error, file))
560
+ });
496
561
  }
562
+ });
497
563
 
498
- fileRejections.push({
499
- file,
500
- errors: errors.filter((e): e is FileError => e != null).map(error => localizeError(error, file))
564
+ // Cap the accepted files at the configured limit and reject only the surplus (the files past
565
+ // the limit) with a too-many-files error, instead of rejecting the whole batch. The limit is 1
566
+ // when {multiple} is false, and {maxFiles} when {multiple} is true (0 means no limit). Files
567
+ // that already failed the per-file checks above are in {fileRejections} and don't count here.
568
+ // See https://github.com/react-dropzone/react-dropzone/issues/1355
569
+ // and https://github.com/react-dropzone/react-dropzone/issues/1358
570
+ const acceptedFilesLimit = multiple ? (maxFiles >= 1 ? maxFiles : Number.POSITIVE_INFINITY) : 1;
571
+ if (acceptedFiles.length > acceptedFilesLimit) {
572
+ const surplusFiles = acceptedFiles.splice(acceptedFilesLimit);
573
+ surplusFiles.forEach(file => {
574
+ fileRejections.push({file, errors: [localizeError(TOO_MANY_FILES_REJECTION, file)]});
501
575
  });
502
576
  }
503
- });
504
577
 
505
- // Cap the accepted files at the configured limit and reject only the surplus (the files past
506
- // the limit) with a too-many-files error, instead of rejecting the whole batch. The limit is 1
507
- // when {multiple} is false, and {maxFiles} when {multiple} is true (0 means no limit). Files
508
- // that already failed the per-file checks above are in {fileRejections} and don't count here.
509
- // See https://github.com/react-dropzone/react-dropzone/issues/1355
510
- // and https://github.com/react-dropzone/react-dropzone/issues/1358
511
- const acceptedFilesLimit = multiple ? (maxFiles >= 1 ? maxFiles : Number.POSITIVE_INFINITY) : 1;
512
- if (acceptedFiles.length > acceptedFilesLimit) {
513
- const surplusFiles = acceptedFiles.splice(acceptedFilesLimit);
514
- surplusFiles.forEach(file => {
515
- fileRejections.push({file, errors: [localizeError(TOO_MANY_FILES_REJECTION, file)]});
578
+ // Clears isProcessing back to false (see the reducer) in the same update that sets the files.
579
+ dispatch({
580
+ acceptedFiles,
581
+ fileRejections,
582
+ type: "setFiles"
516
583
  });
517
- }
518
584
 
519
- dispatch({
520
- acceptedFiles,
521
- fileRejections,
522
- type: "setFiles"
585
+ if (onDrop) {
586
+ onDrop(acceptedFiles, fileRejections, event);
587
+ }
588
+
589
+ if (fileRejections.length > 0 && onDropRejected) {
590
+ onDropRejected(fileRejections, event);
591
+ }
592
+
593
+ if (acceptedFiles.length > 0 && onDropAccepted) {
594
+ onDropAccepted(acceptedFiles, event);
595
+ }
596
+ };
597
+
598
+ // Run the built-in checks synchronously and invoke the validator (which may return a value or
599
+ // a Promise). customErrors is left as-is here so we can tell sync from async below.
600
+ const pending = files.map(file => {
601
+ const [accepted, acceptError] = fileAccepted(file, inputAcceptAttr);
602
+ const [sizeMatch, sizeError] = fileMatchSize(file, minSize, maxSize);
603
+ const customErrors = validator ? validator(file) : null;
604
+ return {file, accepted, acceptError, sizeMatch, sizeError, customErrors};
523
605
  });
524
606
 
525
- if (onDrop) {
526
- onDrop(acceptedFiles, fileRejections, event);
607
+ // Callers check signal.aborted right before invoking setFiles (synchronously, no await in
608
+ // between), so this run is guaranteed live here - the supersession guards below only matter
609
+ // after we await the validator.
610
+
611
+ // Fast path: no validator, or a synchronous one. Commit synchronously - no extra microtask
612
+ // hop, so nested-dropzone ordering is preserved (an onDrop handler calling stopPropagation
613
+ // must run before a parent's onDrop check - see the noDragEventsBubbling tests). commit's
614
+ // dispatch also clears isProcessing.
615
+ if (!pending.some(({customErrors}) => isThenable(customErrors))) {
616
+ commit(pending as Array<PerFileResult>);
617
+ return;
527
618
  }
528
619
 
529
- if (fileRejections.length > 0 && onDropRejected) {
530
- onDropRejected(fileRejections, event);
620
+ // Async path: at least one validator returned a Promise. isProcessing is already on (set when
621
+ // the run began, before getFilesFromEvent); keep guarding against supersession while we await.
622
+ let results: Array<PerFileResult>;
623
+ try {
624
+ results = await Promise.all(
625
+ pending.map(async ({customErrors, ...rest}) => ({...rest, customErrors: await customErrors}))
626
+ );
627
+ } catch (e) {
628
+ // A validator threw/rejected. If a newer run already superseded this one, let it own the
629
+ // state; otherwise clear the processing flag and report the error via onError.
630
+ if (!signal.aborted) {
631
+ endProcessing(signal);
632
+ onErrCb(e as Error);
633
+ }
634
+ return;
531
635
  }
532
636
 
533
- if (acceptedFiles.length > 0 && onDropAccepted) {
534
- onDropAccepted(acceptedFiles, event);
637
+ // A newer drop landed while we were validating - discard these stale results.
638
+ if (signal.aborted) {
639
+ return;
535
640
  }
641
+
642
+ commit(results);
536
643
  },
537
644
  [
538
645
  dispatch,
@@ -545,7 +652,9 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
545
652
  onDropAccepted,
546
653
  onDropRejected,
547
654
  validator,
548
- getErrorMessage
655
+ getErrorMessage,
656
+ onErrCb,
657
+ endProcessing
549
658
  ]
550
659
  );
551
660
 
@@ -558,19 +667,35 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
558
667
 
559
668
  dragTargetsRef.current = [];
560
669
 
670
+ // Clear drag state before we begin processing so beginProcessing's isProcessing isn't reset.
671
+ dispatch({type: "reset"});
672
+
561
673
  if (isEvtWithFiles(event)) {
674
+ // Processing spans reading the files (getFilesFromEvent) and running the validator.
675
+ const signal = beginProcessing();
562
676
  Promise.resolve(getFilesFromEvent(event))
563
677
  .then(files => {
678
+ // A newer drop superseded this one while reading files - it owns isProcessing now.
679
+ if (signal.aborted) {
680
+ return;
681
+ }
564
682
  if (isPropagationStopped(event) && !noDragEventsBubbling) {
683
+ endProcessing(signal);
565
684
  return;
566
685
  }
567
- setFiles(files as FileWithPath[], event);
686
+ // setFiles handles validator errors internally (routing them to onError); the outer
687
+ // catch here only fires for a getFilesFromEvent failure.
688
+ return setFiles(files as FileWithPath[], event, signal);
568
689
  })
569
- .catch(e => onErrCb(e));
690
+ .catch(e => {
691
+ if (!signal.aborted) {
692
+ endProcessing(signal);
693
+ onErrCb(e);
694
+ }
695
+ });
570
696
  }
571
- dispatch({type: "reset"});
572
697
  },
573
- [getFilesFromEvent, setFiles, onErrCb, noDragEventsBubbling]
698
+ [getFilesFromEvent, setFiles, onErrCb, noDragEventsBubbling, beginProcessing, endProcessing]
574
699
  );
575
700
 
576
701
  // Fn for opening the file dialog programmatically
@@ -585,14 +710,31 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
585
710
  multiple,
586
711
  types: pickerTypes
587
712
  };
713
+ // Set once the user has picked file(s); processing then spans reading them (getFilesFromEvent)
714
+ // and running the validator. Picker rejections (cancel, security) happen before this, so it
715
+ // stays undefined there and no processing state is touched.
716
+ let signal: AbortSignal | undefined;
588
717
  (window as any)
589
718
  .showOpenFilePicker(opts)
590
- .then((handles: any) => getFilesFromEvent(handles))
719
+ .then((handles: any) => {
720
+ signal = beginProcessing();
721
+ return getFilesFromEvent(handles);
722
+ })
591
723
  .then((files: Array<File | DataTransferItem>) => {
592
- setFiles(files as FileWithPath[], null);
724
+ // Close the dialog as soon as we have the selection; validation runs afterwards and is
725
+ // reflected by isProcessing rather than by keeping the dialog "active".
593
726
  dispatch({type: "closeDialog"});
727
+ // A drop elsewhere superseded this selection while reading files - it owns isProcessing.
728
+ if (signal!.aborted) {
729
+ return;
730
+ }
731
+ return setFiles(files as FileWithPath[], null, signal!);
594
732
  })
595
733
  .catch((e: any) => {
734
+ // Clear any processing this run started (e.g. a getFilesFromEvent failure).
735
+ if (signal) {
736
+ endProcessing(signal);
737
+ }
596
738
  // AbortError means the user canceled
597
739
  if (isAbort(e)) {
598
740
  onFileDialogCancelCb(e);
@@ -626,7 +768,18 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
626
768
  inputRef.current.value = "";
627
769
  inputRef.current.click();
628
770
  }
629
- }, [dispatch, onFileDialogOpenCb, onFileDialogCancelCb, useFsAccessApi, setFiles, onErrCb, pickerTypes, multiple]);
771
+ }, [
772
+ dispatch,
773
+ onFileDialogOpenCb,
774
+ onFileDialogCancelCb,
775
+ useFsAccessApi,
776
+ setFiles,
777
+ onErrCb,
778
+ pickerTypes,
779
+ multiple,
780
+ beginProcessing,
781
+ endProcessing
782
+ ]);
630
783
 
631
784
  // Cb to open the file dialog when SPACE/ENTER occurs on the dropzone
632
785
  const onKeyDownCb = useCallback(
@@ -814,11 +967,17 @@ function reducer(state: DropzoneInternalState, action: any): DropzoneInternalSta
814
967
  isDragReject: action.isDragReject,
815
968
  isDragUnknown: action.isDragUnknown
816
969
  };
970
+ case "setProcessing":
971
+ return {
972
+ ...state,
973
+ isProcessing: action.isProcessing
974
+ };
817
975
  case "setFiles":
818
976
  return {
819
977
  ...state,
820
978
  acceptedFiles: action.acceptedFiles,
821
979
  fileRejections: action.fileRejections,
980
+ isProcessing: false,
822
981
  isDragReject: false,
823
982
  isDragUnknown: false
824
983
  };
@@ -21,6 +21,13 @@ export interface FileError {
21
21
  code: ErrorCode | string;
22
22
  }
23
23
 
24
+ /**
25
+ * What a custom `validator` returns: a single error, a list of errors, or `null` when the file
26
+ * passes. A validator may return the result directly (synchronous) or wrapped in a `Promise`
27
+ * (asynchronous, e.g. reading image dimensions or calling an external service).
28
+ */
29
+ export type ValidatorResult = FileError | readonly FileError[] | null;
30
+
24
31
  // Error codes
25
32
  export const FILE_INVALID_TYPE = "file-invalid-type";
26
33
  export const FILE_TOO_LARGE = "file-too-large";
@@ -133,6 +140,14 @@ function isDefined<T>(value: T): value is NonNullable<T> {
133
140
  return value !== undefined && value !== null;
134
141
  }
135
142
 
143
+ /**
144
+ * Check if a value is thenable (Promise-like), used to tell a synchronous validator result from an
145
+ * asynchronous one.
146
+ */
147
+ export function isThenable(value: unknown): value is PromiseLike<unknown> {
148
+ return value != null && typeof (value as {then?: unknown}).then === "function";
149
+ }
150
+
136
151
  export function allFilesAccepted({
137
152
  files,
138
153
  accept,
@@ -205,7 +220,9 @@ export function getDragVerdict({
205
220
  maxSize?: number;
206
221
  multiple?: boolean;
207
222
  maxFiles?: number;
208
- validator?: (file: File) => FileError | readonly FileError[] | null;
223
+ // The validator is never invoked here (see the note above), so its async variant is accepted
224
+ // purely so the same `validator` prop is assignable during a drag.
225
+ validator?: (file: File) => ValidatorResult | Promise<ValidatorResult>;
209
226
  }): DragVerdict {
210
227
  // The file count is knowable during a drag, so an over-the-limit selection is a confident reject.
211
228
  if ((!multiple && files.length > 1) || (multiple && maxFiles >= 1 && files.length > maxFiles)) {