Skip to content

Commit 4cf8269

Browse files
committed
fix(attachments): take a nested picker's drop once and drop late reads
A drop on a FileInput inside the drop zone reached both the picker and the zone, so the file was attached and then refused as already there, or attached twice by a host that does not de-duplicate. The zone now leaves a drop a child already handled to that child. A folder walk could also outlive the zone: it still delivered after the zone was disabled, and after unmount it added files whose preview URLs nothing would revoke. A read that finishes once the zone is disabled or unmounted is now dropped, and useAttachments makes no preview URL once it has unmounted. A nameless file was checked for duplicates under its empty name but held as "Untitled", so two of them could slip in and a real Untitled was refused. The check now uses the name the file is held under.
1 parent 3187e5b commit 4cf8269

7 files changed

Lines changed: 191 additions & 24 deletions

File tree

‎e2e/attachments.spec.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,28 @@ test("a dropped folder is walked to the files inside it", async ({ page }) => {
262262
}
263263
});
264264

265+
test("a drop on the picker inside the zone is taken once", async ({ page }) => {
266+
await page.goto("/attachments");
267+
await page.locator("[data-testid=zone] label").evaluate((picker) => {
268+
const data = new DataTransfer();
269+
data.items.add(new File(["hi"], "notes.txt", { type: "text/plain" }));
270+
for (const type of ["dragenter", "dragover", "drop"]) {
271+
picker.dispatchEvent(
272+
new DragEvent(type, {
273+
dataTransfer: data,
274+
bubbles: true,
275+
cancelable: true,
276+
}),
277+
);
278+
}
279+
});
280+
await expect(count(page)).toHaveText("1");
281+
await expect(page.getByRole("status", { name: "Refused" })).toHaveText("");
282+
await expect(
283+
page.locator("[data-slot=attachment-drop-zone-overlay]"),
284+
).toHaveCount(0);
285+
});
286+
265287
test("files dropped outside the zone do not open in place of the page", async ({
266288
page,
267289
}) => {

‎e2e/fixtures.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
Attachment,
6969
AttachmentDropZone,
7070
AttachmentInfo,
71+
FileInput,
7172
AttachmentMore,
7273
AttachmentPreview,
7374
AttachmentRemove,
@@ -1368,6 +1369,11 @@ function AttachmentsScenario() {
13681369
});
13691370
const folded = !expanded && attachments.length > 5;
13701371
const shown = folded ? attachments.slice(0, 4) : attachments;
1372+
const take = (files: Iterable<File> | ArrayLike<File>) => {
1373+
const { accepted, rejected } = add(files);
1374+
setAttachments((previous) => [...previous, ...accepted]);
1375+
setRefused(refusals(rejected));
1376+
};
13711377
return (
13721378
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
13731379
<div data-testid="outside" style={{ height: 80 }}>
@@ -1376,9 +1382,7 @@ function AttachmentsScenario() {
13761382
<AttachmentDropZone
13771383
data-testid="zone"
13781384
onFiles={(files, details) => {
1379-
const { accepted, rejected } = add(files);
1380-
setAttachments((previous) => [...previous, ...accepted]);
1381-
setRefused(refusals(rejected));
1385+
take(files);
13821386
setFolders(details.folders.join(","));
13831387
}}
13841388
style={{ padding: "96px 16px 16px", width: 640 }}
@@ -1421,6 +1425,7 @@ function AttachmentsScenario() {
14211425
</Button>
14221426
) : null}
14231427
<textarea ref={message} aria-label="Message" />
1428+
<FileInput multiple onFiles={take} />
14241429
</AttachmentDropZone>
14251430
<output aria-label="Count">{attachments.length}</output>
14261431
<output aria-label="Refused">{refused}</output>

‎src/hooks/use-attachments.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,24 @@ describe("useAttachments", () => {
178178
host.unmount();
179179
});
180180

181+
test("a nameless file is checked as the Untitled it is held as", () => {
182+
const host = mountHost();
183+
const [item] = host.drop([file("")]).accepted;
184+
if (item === undefined) throw new Error("missing");
185+
expect(item.name).toBe("Untitled");
186+
187+
const nameless = file("");
188+
const named = file("Untitled");
189+
expect(host.drop([nameless]).rejected).toEqual([
190+
{ file: nameless, reason: "duplicate" },
191+
]);
192+
expect(host.drop([named]).rejected).toEqual([
193+
{ file: named, reason: "duplicate" },
194+
]);
195+
expect(host.list).toEqual([item]);
196+
host.unmount();
197+
});
198+
181199
test("two adds before the list updates still respect the limits", () => {
182200
const host = mountHost({ maxFiles: 2 });
183201
let second: ReturnType<UseAttachmentsResult["add"]> | undefined;
@@ -242,6 +260,17 @@ describe("useAttachments", () => {
242260
// StrictMode replays effects only at mount, before any URL is made, so
243261
// the replay here comes after: the effects are torn down and set up again
244262
// while the previews are on screen.
263+
// A drop still being read, or any other late caller.
264+
test("an add after unmounting makes no preview URL", async () => {
265+
const host = mountHost();
266+
host.unmount();
267+
await microtasks();
268+
const [item] = host.add([file("a.txt")]).accepted;
269+
expect(item?.url).toBeUndefined();
270+
expect(item?.file).toBeInstanceOf(File);
271+
expect(created).toEqual([]);
272+
});
273+
245274
test("an effect replay while previews are held revokes nothing", async () => {
246275
const host = mountHost({ strict: true });
247276
host.drop([file("a.txt"), file("b.txt")]);

‎src/hooks/use-attachments.ts‎

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ export type UseAttachmentsResult = {
4545

4646
let lastAttachmentId = 0;
4747

48+
/** The name a file is held under. */
49+
function nameOf(file: File): string {
50+
return file.name === "" ? "Untitled" : file.name;
51+
}
52+
4853
function nextAttachmentId(): string {
4954
lastAttachmentId += 1;
5055
return `attachment-${lastAttachmentId}`;
@@ -62,7 +67,7 @@ function nextAttachmentId(): string {
6267
* Every URL the hook makes is revoked at the first commit whose list does
6368
* not hold its attachment: once the host removes it, however it does, or
6469
* right away if the host never appended it. The rest go when the hook
65-
* unmounts. Only
70+
* unmounts, and an `add` after that makes no URL. Only
6671
* URLs the hook made are revoked; the host's own are left alone. To keep an
6772
* attachment on screen after it leaves the list (sending moves it into a
6873
* message), `release` it first.
@@ -86,7 +91,9 @@ export function useAttachments({
8691
const pending = useRef<AttachmentItem[]>([]);
8792
const latest = useRef({ attachments, maxFiles, maxFileBytes });
8893
latest.current = { attachments, maxFiles, maxFileBytes };
89-
const mounted = useRef(false);
94+
// True until the hook unmounts, so an `add` before its effects run still
95+
// makes a URL.
96+
const mounted = useRef(true);
9097

9198
// After every commit, not only when the list changes: an accepted file the
9299
// host did not append must neither keep its URL nor count as a duplicate
@@ -124,26 +131,40 @@ export function useAttachments({
124131
...(max === undefined ? {} : { maxFiles: max }),
125132
...(maxBytes === undefined ? {} : { maxFileBytes: maxBytes }),
126133
};
134+
// Checked under the name each file will be held under, so a nameless
135+
// file is a duplicate of the "Untitled" one already held.
127136
const { accepted, rejected } = checkAttachments(
128137
[...held, ...pending.current],
129-
Array.from(files),
138+
Array.from(files, (file) => ({
139+
name: nameOf(file),
140+
size: file.size,
141+
file,
142+
})),
130143
limits,
131144
);
132-
const items = accepted.map((file): AttachmentItem => {
145+
const items = accepted.map(({ name, file }): AttachmentItem => {
133146
const id = nextAttachmentId();
134-
const url = URL.createObjectURL(file);
135-
urls.current.set(id, url);
136-
return {
147+
const item = {
137148
id,
138-
name: file.name === "" ? "Untitled" : file.name,
149+
name,
139150
mediaType: file.type === "" ? "application/octet-stream" : file.type,
140151
size: file.size,
141-
url,
142152
file,
143153
};
154+
// Past unmount nothing would revoke it.
155+
if (!mounted.current) return item;
156+
const url = URL.createObjectURL(file);
157+
urls.current.set(id, url);
158+
return { ...item, url };
144159
});
145160
pending.current = [...pending.current, ...items];
146-
return { accepted: items, rejected };
161+
return {
162+
accepted: items,
163+
rejected: rejected.map(({ file, reason }) => ({
164+
file: file.file,
165+
reason,
166+
})),
167+
};
147168
}, []);
148169

149170
const release = useCallback((ids: Iterable<string>) => {

‎src/hooks/use-file-drop.test.ts‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, test } from "bun:test";
22
import { act, createElement, useState } from "react";
3+
import type { DragEvent as ReactDragEvent } from "react";
34
import { createRoot } from "react-dom/client";
45

56
import {
@@ -59,6 +60,27 @@ function folderEntry(
5960
};
6061
}
6162

63+
/** A folder whose listing waits until `release` is called. */
64+
function heldFolder(name: string, entries: readonly DroppedEntry[]) {
65+
let release = () => {};
66+
const listed = new Promise<void>((resolve) => {
67+
release = resolve;
68+
});
69+
const folder = folderEntry(name, entries);
70+
const entry: DroppedEntry = {
71+
...folder,
72+
createReader: () => {
73+
const reader = folder.createReader?.();
74+
return {
75+
readEntries: (success, failure) => {
76+
void listed.then(() => reader?.readEntries(success, failure));
77+
},
78+
};
79+
},
80+
};
81+
return { entry, release };
82+
}
83+
6284
function source(
6385
dropped: readonly (DroppedEntry | File)[],
6486
types: readonly string[] = ["Files"],
@@ -195,7 +217,8 @@ type Received = { files: readonly File[]; details: FileDropDetails };
195217
function mountTarget({
196218
guard = true,
197219
disabled: initiallyDisabled = false,
198-
}: { guard?: boolean; disabled?: boolean } = {}) {
220+
childTakesDrops = false,
221+
}: { guard?: boolean; disabled?: boolean; childTakesDrops?: boolean } = {}) {
199222
const container = document.createElement("div");
200223
document.body.appendChild(container);
201224
const root = createRoot(container);
@@ -215,7 +238,13 @@ function mountTarget({
215238
return createElement(
216239
"div",
217240
{ "data-testid": "zone", ...drop.dropProps },
218-
createElement("span", null, "Drop here"),
241+
createElement(
242+
"span",
243+
childTakesDrops
244+
? { onDrop: (event: ReactDragEvent) => event.preventDefault() }
245+
: null,
246+
"Drop here",
247+
),
219248
);
220249
}
221250

@@ -385,6 +414,41 @@ describe("useFileDrop", () => {
385414
target.unmount();
386415
});
387416

417+
// A picker such as FileInput inside the zone reads its own drops.
418+
test("a drop a child took itself is left to it", async () => {
419+
const target = mountTarget({ childTakesDrops: true });
420+
const files = source([new File(["a"], "a.txt")]);
421+
target.fire(target.child, dragEvent("dragenter", files));
422+
expect(target.over).toBe(true);
423+
const drop = target.fire(target.child, dragEvent("drop", files));
424+
expect(drop.defaultPrevented).toBe(true);
425+
expect(target.over).toBe(false);
426+
await settle();
427+
expect(target.received).toEqual([]);
428+
target.unmount();
429+
});
430+
431+
test("a drop still being read when the target is disabled is dropped", async () => {
432+
const target = mountTarget();
433+
const folder = heldFolder("photos", [fileEntry("a.jpg")]);
434+
target.fire(target.zone, dragEvent("drop", source([folder.entry])));
435+
target.disable();
436+
folder.release();
437+
await settle();
438+
expect(target.received).toEqual([]);
439+
target.unmount();
440+
});
441+
442+
test("a drop still being read when the target unmounts is dropped", async () => {
443+
const target = mountTarget();
444+
const folder = heldFolder("photos", [fileEntry("a.jpg")]);
445+
target.fire(target.zone, dragEvent("drop", source([folder.entry])));
446+
target.unmount();
447+
folder.release();
448+
await settle();
449+
expect(target.received).toEqual([]);
450+
});
451+
388452
test("a drag without files is left alone", async () => {
389453
const target = mountTarget();
390454
const text = source([], ["text/plain"]);

‎src/hooks/use-file-drop.ts‎

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from "react";
1+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
22
import type { DragEvent as ReactDragEvent } from "react";
33

44
/**
@@ -230,7 +230,10 @@ export type UseFileDropOptions = ReadDroppedFilesOptions & {
230230
* what it held. A drop of only empty folders calls it with no files.
231231
*/
232232
readonly onFiles: (files: readonly File[], details: FileDropDetails) => void;
233-
/** While true, drags are ignored and the browser's default stands. */
233+
/**
234+
* While true, drags are ignored and the browser's default stands. A drop
235+
* still being read when it turns true is dropped, errors and all.
236+
*/
234237
readonly disabled?: boolean;
235238
/**
236239
* Called when a drop could not be read in full: a folder that could not
@@ -262,7 +265,10 @@ function report(error: unknown) {
262265

263266
/**
264267
* Makes an element a target for dropped files and folders. Only drags that
265-
* carry files are taken; text and links dragged over it behave as usual.
268+
* carry files are taken; text and links dragged over it behave as usual. A
269+
* drop that a child handles itself (calls `preventDefault`), as `FileInput`
270+
* does, is left to the child. A drop still being read when the target
271+
* unmounts is dropped.
266272
*
267273
* `over` is true while files are held over the target, including over its
268274
* children. It turns false when the drag leaves the target, is dropped, or
@@ -278,8 +284,18 @@ export function useFileDrop<T extends Element = HTMLElement>({
278284
onError,
279285
}: UseFileDropOptions): UseFileDropResult<T> {
280286
const [over, setOver] = useState(false);
281-
const latest = useRef({ onFiles, onError, maxFolderEntries });
282-
latest.current = { onFiles, onError, maxFolderEntries };
287+
// The latest options, for a drop still being read under an earlier render.
288+
const latest = useRef({ onFiles, onError, maxFolderEntries, disabled });
289+
useLayoutEffect(() => {
290+
latest.current = { onFiles, onError, maxFolderEntries, disabled };
291+
});
292+
const mounted = useRef(false);
293+
useEffect(() => {
294+
mounted.current = true;
295+
return () => {
296+
mounted.current = false;
297+
};
298+
}, []);
283299
// Counted rather than read from `relatedTarget`, which WebKit leaves null:
284300
// moving between two children enters one before leaving the other, so the
285301
// count only reaches zero once the drag has left the target.
@@ -343,22 +359,29 @@ export function useFileDrop<T extends Element = HTMLElement>({
343359
},
344360
onDrop: (event) => {
345361
if (!takes(event)) return;
346-
event.preventDefault();
347362
depth.current = 0;
348363
setOver(false);
364+
// A child that takes its own drops, such as `FileInput`, took it.
365+
if (event.isDefaultPrevented()) return;
366+
event.preventDefault();
349367
const options = latest.current.maxFolderEntries;
368+
// Disabled or unmounted while the drop was being read.
369+
const stale = () => latest.current.disabled || !mounted.current;
350370
readDroppedFiles(
351371
event.dataTransfer,
352372
options === undefined ? {} : { maxFolderEntries: options },
353373
).then(
354374
({ files, ...details }) => {
375+
if (stale()) return;
355376
const { onFiles: deliver, onError: fail } = latest.current;
356377
for (const error of details.errors) (fail ?? report)(error);
357378
if (files.length > 0 || details.folders.length > 0) {
358379
deliver(files, details);
359380
}
360381
},
361-
(error: unknown) => (latest.current.onError ?? report)(error),
382+
(error: unknown) => {
383+
if (!stale()) (latest.current.onError ?? report)(error);
384+
},
362385
);
363386
},
364387
},

‎src/ui/attachment-drop-zone.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ export type AttachmentDropZoneProps = Omit<
3030
* open the file in place of the page.
3131
*
3232
* Dropping needs a pointer, so put a file picker (such as `FileInput`)
33-
* inside it for keyboard and touch. The zone is a plain `div`: give it
34-
* `role="region"` and an `aria-label` when it should be a landmark.
33+
* inside it for keyboard and touch. A child that takes its own drops keeps
34+
* them: a drop right on `FileInput` reaches only the picker's `onFiles`,
35+
* which reads loose files and does not walk folders. The zone is a plain
36+
* `div`: give it `role="region"` and an `aria-label` when it should be a
37+
* landmark.
3538
*/
3639
export function AttachmentDropZone({
3740
onFiles,

0 commit comments

Comments
 (0)