react-dropzone 19.0.2 → 19.1.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.
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,34 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
228
263
  const [state, dispatch] = useReducer(reducer, initialState);
229
264
  const {isFocused, isFileDialogActive} = state;
230
265
 
266
+ // Mirror {isFileDialogActive} into a ref so the memoized drag handlers can read the current value
267
+ // without being recreated (and churning getRootProps/getInputProps) every time the dialog toggles.
268
+ const isFileDialogActiveRef = useRef(isFileDialogActive);
269
+ isFileDialogActiveRef.current = isFileDialogActive;
270
+
271
+ // Tracks the in-flight processing run - reading files (getFilesFromEvent) plus running an async
272
+ // validator. A newer drop/selection aborts the previous run so slow async work can't resolve late
273
+ // and clobber the state with stale results.
274
+ const processingAbortRef = useRef<AbortController | null>(null);
275
+
276
+ // Begin a processing run: supersede any run still in flight and flip {isProcessing} on. Returns
277
+ // the run's AbortSignal, which downstream async steps check to bail if a newer run took over.
278
+ const beginProcessing = useCallback(() => {
279
+ processingAbortRef.current?.abort();
280
+ const controller = new AbortController();
281
+ processingAbortRef.current = controller;
282
+ dispatch({type: "setProcessing", isProcessing: true});
283
+ return controller.signal;
284
+ }, []);
285
+
286
+ // End a processing run by clearing {isProcessing} - but only if this run is still the active one.
287
+ // A superseded run (signal aborted) leaves the flag to the run that replaced it.
288
+ const endProcessing = useCallback((signal: AbortSignal) => {
289
+ if (!signal.aborted) {
290
+ dispatch({type: "setProcessing", isProcessing: false});
291
+ }
292
+ }, []);
293
+
231
294
  const fsAccessApiWorksRef = useRef(
232
295
  typeof window !== "undefined" && window.isSecureContext && useFsAccessApi && canUseFileSystemAccessAPI()
233
296
  );
@@ -360,6 +423,13 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
360
423
  event.persist?.();
361
424
  stopPropagation(event);
362
425
 
426
+ // Ignore drags onto the dropzone while the file picker dialog is open: the page underneath a
427
+ // live picker shouldn't react to (or accept) dropped files. See #1455. preventDefault() above
428
+ // still runs so the browser doesn't try to open/navigate to a dropped file.
429
+ if (isFileDialogActiveRef.current) {
430
+ return;
431
+ }
432
+
363
433
  dragTargetsRef.current = [...dragTargetsRef.current, event.target];
364
434
 
365
435
  if (isEvtWithFiles(event)) {
@@ -421,6 +491,11 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
421
491
  event.persist?.();
422
492
  stopPropagation(event);
423
493
 
494
+ // Ignore drags over the dropzone while the file picker dialog is open. See #1455.
495
+ if (isFileDialogActiveRef.current) {
496
+ return false;
497
+ }
498
+
424
499
  const hasFiles = isEvtWithFiles(event);
425
500
  if (hasFiles && event.dataTransfer) {
426
501
  try {
@@ -474,65 +549,114 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
474
549
  );
475
550
 
476
551
  const setFiles = useCallback(
477
- (files: FileWithPath[], event: any) => {
478
- const acceptedFiles: FileWithPath[] = [];
479
- const fileRejections: FileRejection[] = [];
480
-
552
+ async (files: FileWithPath[], event: any, signal: AbortSignal) => {
481
553
  const localizeError = (error: FileError, file: File): FileError =>
482
554
  getErrorMessage ? {...error, message: getErrorMessage(error, file)} : error;
483
555
 
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;
556
+ // Commit the per-file verdicts: split accepted/rejected, cap the surplus, update state and
557
+ // fire the onDrop callbacks. Runs synchronously so nested-dropzone ordering is preserved on
558
+ // the fast path (an onDrop handler calling stopPropagation must do so before a parent's onDrop
559
+ // check - see the noDragEventsBubbling tests).
560
+ const commit = (results: Array<PerFileResult>) => {
561
+ const acceptedFiles: FileWithPath[] = [];
562
+ const fileRejections: FileRejection[] = [];
563
+
564
+ results.forEach(({file, accepted, acceptError, sizeMatch, sizeError, customErrors}) => {
565
+ if (accepted && sizeMatch && !customErrors) {
566
+ acceptedFiles.push(file);
567
+ } else {
568
+ let errors: Array<FileError | null> = [acceptError, sizeError];
488
569
 
489
- if (accepted && sizeMatch && !customErrors) {
490
- acceptedFiles.push(file);
491
- } else {
492
- let errors: Array<FileError | null> = [acceptError, sizeError];
570
+ if (customErrors) {
571
+ errors = errors.concat(customErrors);
572
+ }
493
573
 
494
- if (customErrors) {
495
- errors = errors.concat(customErrors);
574
+ fileRejections.push({
575
+ file,
576
+ errors: errors.filter((e): e is FileError => e != null).map(error => localizeError(error, file))
577
+ });
496
578
  }
579
+ });
497
580
 
498
- fileRejections.push({
499
- file,
500
- errors: errors.filter((e): e is FileError => e != null).map(error => localizeError(error, file))
581
+ // Cap the accepted files at the configured limit and reject only the surplus (the files past
582
+ // the limit) with a too-many-files error, instead of rejecting the whole batch. The limit is 1
583
+ // when {multiple} is false, and {maxFiles} when {multiple} is true (0 means no limit). Files
584
+ // that already failed the per-file checks above are in {fileRejections} and don't count here.
585
+ // See https://github.com/react-dropzone/react-dropzone/issues/1355
586
+ // and https://github.com/react-dropzone/react-dropzone/issues/1358
587
+ const acceptedFilesLimit = multiple ? (maxFiles >= 1 ? maxFiles : Number.POSITIVE_INFINITY) : 1;
588
+ if (acceptedFiles.length > acceptedFilesLimit) {
589
+ const surplusFiles = acceptedFiles.splice(acceptedFilesLimit);
590
+ surplusFiles.forEach(file => {
591
+ fileRejections.push({file, errors: [localizeError(TOO_MANY_FILES_REJECTION, file)]});
501
592
  });
502
593
  }
503
- });
504
594
 
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)]});
595
+ // Clears isProcessing back to false (see the reducer) in the same update that sets the files.
596
+ dispatch({
597
+ acceptedFiles,
598
+ fileRejections,
599
+ type: "setFiles"
516
600
  });
517
- }
518
601
 
519
- dispatch({
520
- acceptedFiles,
521
- fileRejections,
522
- type: "setFiles"
602
+ if (onDrop) {
603
+ onDrop(acceptedFiles, fileRejections, event);
604
+ }
605
+
606
+ if (fileRejections.length > 0 && onDropRejected) {
607
+ onDropRejected(fileRejections, event);
608
+ }
609
+
610
+ if (acceptedFiles.length > 0 && onDropAccepted) {
611
+ onDropAccepted(acceptedFiles, event);
612
+ }
613
+ };
614
+
615
+ // Run the built-in checks synchronously and invoke the validator (which may return a value or
616
+ // a Promise). customErrors is left as-is here so we can tell sync from async below.
617
+ const pending = files.map(file => {
618
+ const [accepted, acceptError] = fileAccepted(file, inputAcceptAttr);
619
+ const [sizeMatch, sizeError] = fileMatchSize(file, minSize, maxSize);
620
+ const customErrors = validator ? validator(file) : null;
621
+ return {file, accepted, acceptError, sizeMatch, sizeError, customErrors};
523
622
  });
524
623
 
525
- if (onDrop) {
526
- onDrop(acceptedFiles, fileRejections, event);
624
+ // Callers check signal.aborted right before invoking setFiles (synchronously, no await in
625
+ // between), so this run is guaranteed live here - the supersession guards below only matter
626
+ // after we await the validator.
627
+
628
+ // Fast path: no validator, or a synchronous one. Commit synchronously - no extra microtask
629
+ // hop, so nested-dropzone ordering is preserved (an onDrop handler calling stopPropagation
630
+ // must run before a parent's onDrop check - see the noDragEventsBubbling tests). commit's
631
+ // dispatch also clears isProcessing.
632
+ if (!pending.some(({customErrors}) => isThenable(customErrors))) {
633
+ commit(pending as Array<PerFileResult>);
634
+ return;
527
635
  }
528
636
 
529
- if (fileRejections.length > 0 && onDropRejected) {
530
- onDropRejected(fileRejections, event);
637
+ // Async path: at least one validator returned a Promise. isProcessing is already on (set when
638
+ // the run began, before getFilesFromEvent); keep guarding against supersession while we await.
639
+ let results: Array<PerFileResult>;
640
+ try {
641
+ results = await Promise.all(
642
+ pending.map(async ({customErrors, ...rest}) => ({...rest, customErrors: await customErrors}))
643
+ );
644
+ } catch (e) {
645
+ // A validator threw/rejected. If a newer run already superseded this one, let it own the
646
+ // state; otherwise clear the processing flag and report the error via onError.
647
+ if (!signal.aborted) {
648
+ endProcessing(signal);
649
+ onErrCb(e as Error);
650
+ }
651
+ return;
531
652
  }
532
653
 
533
- if (acceptedFiles.length > 0 && onDropAccepted) {
534
- onDropAccepted(acceptedFiles, event);
654
+ // A newer drop landed while we were validating - discard these stale results.
655
+ if (signal.aborted) {
656
+ return;
535
657
  }
658
+
659
+ commit(results);
536
660
  },
537
661
  [
538
662
  dispatch,
@@ -545,7 +669,9 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
545
669
  onDropAccepted,
546
670
  onDropRejected,
547
671
  validator,
548
- getErrorMessage
672
+ getErrorMessage,
673
+ onErrCb,
674
+ endProcessing
549
675
  ]
550
676
  );
551
677
 
@@ -558,19 +684,44 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
558
684
 
559
685
  dragTargetsRef.current = [];
560
686
 
687
+ // Ignore a drop landing on the dropzone while the file picker dialog is open (see #1455).
688
+ // Guard on {event.dataTransfer} so only real drag-drops are suppressed: the input's change
689
+ // event (a file picked from the dialog) also runs through here while the dialog is still
690
+ // flagged active, and that path must keep working. Returning before the reset below leaves
691
+ // the dialog flag intact.
692
+ if (isFileDialogActiveRef.current && event.dataTransfer) {
693
+ return;
694
+ }
695
+
696
+ // Clear drag state before we begin processing so beginProcessing's isProcessing isn't reset.
697
+ dispatch({type: "reset"});
698
+
561
699
  if (isEvtWithFiles(event)) {
700
+ // Processing spans reading the files (getFilesFromEvent) and running the validator.
701
+ const signal = beginProcessing();
562
702
  Promise.resolve(getFilesFromEvent(event))
563
703
  .then(files => {
704
+ // A newer drop superseded this one while reading files - it owns isProcessing now.
705
+ if (signal.aborted) {
706
+ return;
707
+ }
564
708
  if (isPropagationStopped(event) && !noDragEventsBubbling) {
709
+ endProcessing(signal);
565
710
  return;
566
711
  }
567
- setFiles(files as FileWithPath[], event);
712
+ // setFiles handles validator errors internally (routing them to onError); the outer
713
+ // catch here only fires for a getFilesFromEvent failure.
714
+ return setFiles(files as FileWithPath[], event, signal);
568
715
  })
569
- .catch(e => onErrCb(e));
716
+ .catch(e => {
717
+ if (!signal.aborted) {
718
+ endProcessing(signal);
719
+ onErrCb(e);
720
+ }
721
+ });
570
722
  }
571
- dispatch({type: "reset"});
572
723
  },
573
- [getFilesFromEvent, setFiles, onErrCb, noDragEventsBubbling]
724
+ [getFilesFromEvent, setFiles, onErrCb, noDragEventsBubbling, beginProcessing, endProcessing]
574
725
  );
575
726
 
576
727
  // Fn for opening the file dialog programmatically
@@ -585,14 +736,31 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
585
736
  multiple,
586
737
  types: pickerTypes
587
738
  };
739
+ // Set once the user has picked file(s); processing then spans reading them (getFilesFromEvent)
740
+ // and running the validator. Picker rejections (cancel, security) happen before this, so it
741
+ // stays undefined there and no processing state is touched.
742
+ let signal: AbortSignal | undefined;
588
743
  (window as any)
589
744
  .showOpenFilePicker(opts)
590
- .then((handles: any) => getFilesFromEvent(handles))
745
+ .then((handles: any) => {
746
+ signal = beginProcessing();
747
+ return getFilesFromEvent(handles);
748
+ })
591
749
  .then((files: Array<File | DataTransferItem>) => {
592
- setFiles(files as FileWithPath[], null);
750
+ // Close the dialog as soon as we have the selection; validation runs afterwards and is
751
+ // reflected by isProcessing rather than by keeping the dialog "active".
593
752
  dispatch({type: "closeDialog"});
753
+ // A drop elsewhere superseded this selection while reading files - it owns isProcessing.
754
+ if (signal!.aborted) {
755
+ return;
756
+ }
757
+ return setFiles(files as FileWithPath[], null, signal!);
594
758
  })
595
759
  .catch((e: any) => {
760
+ // Clear any processing this run started (e.g. a getFilesFromEvent failure).
761
+ if (signal) {
762
+ endProcessing(signal);
763
+ }
596
764
  // AbortError means the user canceled
597
765
  if (isAbort(e)) {
598
766
  onFileDialogCancelCb(e);
@@ -626,7 +794,18 @@ export function useDropzone(props: DropzoneOptions = {}): DropzoneState {
626
794
  inputRef.current.value = "";
627
795
  inputRef.current.click();
628
796
  }
629
- }, [dispatch, onFileDialogOpenCb, onFileDialogCancelCb, useFsAccessApi, setFiles, onErrCb, pickerTypes, multiple]);
797
+ }, [
798
+ dispatch,
799
+ onFileDialogOpenCb,
800
+ onFileDialogCancelCb,
801
+ useFsAccessApi,
802
+ setFiles,
803
+ onErrCb,
804
+ pickerTypes,
805
+ multiple,
806
+ beginProcessing,
807
+ endProcessing
808
+ ]);
630
809
 
631
810
  // Cb to open the file dialog when SPACE/ENTER occurs on the dropzone
632
811
  const onKeyDownCb = useCallback(
@@ -814,11 +993,17 @@ function reducer(state: DropzoneInternalState, action: any): DropzoneInternalSta
814
993
  isDragReject: action.isDragReject,
815
994
  isDragUnknown: action.isDragUnknown
816
995
  };
996
+ case "setProcessing":
997
+ return {
998
+ ...state,
999
+ isProcessing: action.isProcessing
1000
+ };
817
1001
  case "setFiles":
818
1002
  return {
819
1003
  ...state,
820
1004
  acceptedFiles: action.acceptedFiles,
821
1005
  fileRejections: action.fileRejections,
1006
+ isProcessing: false,
822
1007
  isDragReject: false,
823
1008
  isDragUnknown: false
824
1009
  };
@@ -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)) {