Skip to content

Commit a3e9b62

Browse files
committed
fix(attachments): rebuild revoked previews and cover the disabled option
A hook hidden by Activity for longer than a tick cannot tell the hide from an unmount, so useAttachments revokes its previews while the host still holds the items, and the previews came back blank. Attachment now rebuilds the picture from the item's file when its url fails, owning that url and revoking it on unmount or hide. The hook's docs say the held url itself stays dead, so code that reads it should make its own from the file or keep the hook above the Activity. No test passed disabled to useFileDrop, and the test named for it covered the stray-drop guard instead; both are now tested. AttachmentPreview takes a label for its open button, as AttachmentRemove already does. The docs say AttachmentMore renders last and how to name the drop zone as a region, carriesFiles is no longer public, and tests exercise the uncontrolled fold.
1 parent f144b53 commit a3e9b62

7 files changed

Lines changed: 342 additions & 28 deletions

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ function mountHost({ strict = false, initial = [], ...limits }: Options = {}) {
133133
act(() => setHidden(true));
134134
act(() => setHidden(false));
135135
},
136+
/** Hides the hook in its `Activity` for longer than a tick, then shows it. */
137+
async hideAndShow() {
138+
act(() => setHidden(true));
139+
await act(microtasks);
140+
act(() => setHidden(false));
141+
await act(microtasks);
142+
},
136143
unmount() {
137144
act(() => root.unmount());
138145
container.remove();
@@ -249,6 +256,21 @@ describe("useAttachments", () => {
249256
expect([...revoked].sort()).toEqual([...created].sort());
250257
});
251258

259+
// Nothing tells a hide from an unmount, so the previews go and the held
260+
// items keep their dead URLs; `Attachment` rebuilds the pictures from
261+
// `file` (see attachments.test.tsx).
262+
test("a hide longer than a tick revokes the held previews", async () => {
263+
const host = mountHost();
264+
const [item] = host.drop([file("a.png", 4, "image/png")]).accepted;
265+
if (item === undefined) throw new Error("missing");
266+
267+
await host.hideAndShow();
268+
expect(revoked).toEqual([item.url ?? ""]);
269+
expect(host.list).toEqual([item]);
270+
expect(host.list[0]?.file).toBeInstanceOf(File);
271+
host.unmount();
272+
});
273+
252274
test("the host's own URLs are never revoked", async () => {
253275
const uploaded: AttachmentItem = {
254276
id: "uploaded-1",

‎src/hooks/use-attachments.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,13 @@ function nextAttachmentId(): string {
6969
*
7070
* The unmount revoke waits a microtask and is called off if the hook mounts
7171
* again, which is what StrictMode's simulated remount does within one
72-
* commit. A hook hidden by `<Activity mode="hidden">` counts as unmounted,
73-
* and its previews are revoked.
72+
* commit. A hook hidden by `<Activity mode="hidden">` for longer than that
73+
* counts as unmounted: its previews are revoked, and once it shows again
74+
* the attachments still held keep URLs that no longer load. `Attachment`
75+
* rebuilds their pictures from `file`. Anything else that reads `url`
76+
* after such a hide, such as a viewer opened from `AttachmentPreview`'s
77+
* `onOpen`, should make its own URL from `file`, or the hook should sit
78+
* above the `Activity`.
7479
*/
7580
export function useAttachments({
7681
attachments,

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

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2-
import { act, createElement } from "react";
2+
import { act, createElement, useState } from "react";
33
import { createRoot } from "react-dom/client";
44

55
import {
@@ -192,16 +192,23 @@ function dropEffectOf(event: Event): unknown {
192192

193193
type Received = { files: readonly File[]; details: FileDropDetails };
194194

195-
function mountTarget({ guard = true }: { guard?: boolean } = {}) {
195+
function mountTarget({
196+
guard = true,
197+
disabled: initiallyDisabled = false,
198+
}: { guard?: boolean; disabled?: boolean } = {}) {
196199
const container = document.createElement("div");
197200
document.body.appendChild(container);
198201
const root = createRoot(container);
199202
const received: Received[] = [];
200203
let over = false;
204+
let setDisabled: (disabled: boolean) => void = () => {};
201205

202206
function Target() {
207+
const [disabled, setTargetDisabled] = useState(initiallyDisabled);
208+
setDisabled = setTargetDisabled;
203209
const drop = useFileDrop<HTMLDivElement>({
204210
onFiles: (files, details) => received.push({ files, details }),
211+
disabled,
205212
});
206213
usePreventStrayDrops(guard);
207214
over = drop.over;
@@ -223,6 +230,9 @@ function mountTarget({ guard = true }: { guard?: boolean } = {}) {
223230
get over() {
224231
return over;
225232
},
233+
disable(disabled = true) {
234+
act(() => setDisabled(disabled));
235+
},
226236
fire(target: EventTarget, event: Event) {
227237
act(() => {
228238
target.dispatchEvent(event);
@@ -243,6 +253,37 @@ function settle(): Promise<void> {
243253
}
244254

245255
describe("useFileDrop", () => {
256+
// The stray-drop guard is off, so only the hook can claim these events.
257+
test("takes no drag while disabled", async () => {
258+
const target = mountTarget({ guard: false, disabled: true });
259+
const files = source([new File(["a"], "a.txt")]);
260+
for (const type of ["dragenter", "dragover", "dragleave", "drop"]) {
261+
const event = target.fire(target.zone, dragEvent(type, files));
262+
expect(event.defaultPrevented).toBe(false);
263+
expect(target.over).toBe(false);
264+
}
265+
await settle();
266+
expect(target.received).toEqual([]);
267+
target.unmount();
268+
});
269+
270+
test("disabling mid-drag drops over, and enabling takes drags again", () => {
271+
const target = mountTarget({ guard: false });
272+
const files = source([new File(["a"], "a.txt")]);
273+
target.fire(target.zone, dragEvent("dragenter", files));
274+
expect(target.over).toBe(true);
275+
276+
target.disable();
277+
expect(target.over).toBe(false);
278+
target.disable(false);
279+
expect(target.over).toBe(false);
280+
281+
const enter = target.fire(target.zone, dragEvent("dragenter", files));
282+
expect(enter.defaultPrevented).toBe(true);
283+
expect(target.over).toBe(true);
284+
target.unmount();
285+
});
286+
246287
test("a dropped folder reaches onFiles walked to its files", async () => {
247288
const target = mountTarget();
248289
const drop = target.fire(target.zone, dragEvent("drop", source([PROJECT])));
@@ -405,7 +446,7 @@ describe("usePreventStrayDrops", () => {
405446
expect(after.defaultPrevented).toBe(false);
406447
});
407448

408-
test("does nothing when disabled", () => {
449+
test("stray-drop guard off leaves body drops alone", () => {
409450
const target = mountTarget({ guard: false });
410451
const files = source([new File(["a"], "a.txt")]);
411452
expect(

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,7 @@ export type ReadDroppedFilesOptions = {
5454
const DEFAULT_MAX_FOLDER_ENTRIES = 500;
5555

5656
/** Whether a drag carries files, as opposed to text or a link. */
57-
export function carriesFiles(
58-
source: Pick<DropSource, "types"> | null,
59-
): boolean {
57+
function carriesFiles(source: Pick<DropSource, "types"> | null): boolean {
6058
return source !== null && source.types.includes("Files");
6159
}
6260

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ 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.
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.
3435
*/
3536
export function AttachmentDropZone({
3637
onFiles,

‎src/ui/attachments.test.tsx‎

Lines changed: 193 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { act, useRef, useState } from "react";
1+
import { Activity, act, useRef, useState } from "react";
22
import { createRoot } from "react-dom/client";
3-
import { describe, expect, test } from "bun:test";
3+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
44

5+
import {
6+
useAttachments,
7+
type AttachmentItem,
8+
} from "../hooks/use-attachments.js";
59
import {
610
Attachment,
11+
AttachmentMore,
712
AttachmentPreview,
813
AttachmentRemove,
914
Attachments,
@@ -114,4 +119,190 @@ describe("Attachments", () => {
114119
expect(opened).toEqual(["notes.txt"]);
115120
unmount();
116121
});
122+
123+
test("a preview that opens takes its name from label", () => {
124+
const { container, unmount } = mount(
125+
<Attachments>
126+
<Attachment data={NOTES}>
127+
<AttachmentPreview label="View the notes" onOpen={() => {}} />
128+
</Attachment>
129+
</Attachments>,
130+
);
131+
expect(button(container, "View the notes").dataset["slot"]).toBe(
132+
"attachment-preview",
133+
);
134+
unmount();
135+
});
136+
137+
test("an uncontrolled list folds until More expands it", () => {
138+
const files: readonly AttachmentData[] = ["a.txt", "b.txt", "c.txt"].map(
139+
(name) => ({ name, mediaType: "text/plain" }),
140+
);
141+
const { container, unmount } = mount(
142+
<Attachments variant="list">
143+
{({ expanded }) => {
144+
const shown = expanded ? files : files.slice(0, 1);
145+
return (
146+
<>
147+
{shown.map((data) => (
148+
<Attachment key={data.name} data={data}>
149+
<AttachmentRemove />
150+
</Attachment>
151+
))}
152+
{expanded ? null : <AttachmentMore count={files.length - 1} />}
153+
</>
154+
);
155+
}}
156+
</Attachments>,
157+
);
158+
const names = () =>
159+
[...container.querySelectorAll("[data-slot=attachment-remove]")].map(
160+
(element) => element.getAttribute("aria-label"),
161+
);
162+
expect(names()).toEqual(["Remove a.txt"]);
163+
164+
const more = button(container, "Show 2 more");
165+
more.focus();
166+
act(() => more.click());
167+
expect(names()).toEqual(["Remove a.txt", "Remove b.txt", "Remove c.txt"]);
168+
expect(container.querySelector("ul")?.dataset["expanded"]).toBe("true");
169+
// Focus goes to the first attachment that was folded away.
170+
expect(document.activeElement).toBe(button(container, "Remove b.txt"));
171+
unmount();
172+
});
173+
174+
test("defaultExpanded starts an uncontrolled list expanded", () => {
175+
const { container, unmount } = mount(
176+
<Attachments variant="list" defaultExpanded>
177+
{({ expanded }) => (expanded ? <li>all</li> : <li>some</li>)}
178+
</Attachments>,
179+
);
180+
expect(container.textContent).toBe("all");
181+
unmount();
182+
});
183+
});
184+
185+
describe("a preview held across a hidden Activity", () => {
186+
let live = new Set<string>();
187+
let made = 0;
188+
const realCreate = URL.createObjectURL;
189+
const realRevoke = URL.revokeObjectURL;
190+
191+
beforeEach(() => {
192+
live = new Set();
193+
made = 0;
194+
URL.createObjectURL = () => {
195+
made += 1;
196+
const url = `blob:test/${made}`;
197+
live.add(url);
198+
return url;
199+
};
200+
URL.revokeObjectURL = (url: string) => {
201+
live.delete(url);
202+
};
203+
});
204+
205+
afterEach(() => {
206+
URL.createObjectURL = realCreate;
207+
URL.revokeObjectURL = realRevoke;
208+
});
209+
210+
function tick(): Promise<void> {
211+
return act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
212+
}
213+
214+
test("is rebuilt from its file once its URL stops loading", async () => {
215+
let attach: (files: readonly File[]) => void = () => {};
216+
let setHidden: (hidden: boolean) => void = () => {};
217+
218+
function Composer() {
219+
const [held, setHeld] = useState<readonly AttachmentItem[]>([]);
220+
const { add } = useAttachments({ attachments: held });
221+
attach = (files) => {
222+
const { accepted } = add(files);
223+
setHeld((previous) => [...previous, ...accepted]);
224+
};
225+
return (
226+
<Attachments variant="tiles">
227+
{held.map((item) => (
228+
<Attachment key={item.id} data={item}>
229+
<AttachmentPreview />
230+
</Attachment>
231+
))}
232+
</Attachments>
233+
);
234+
}
235+
236+
function Route() {
237+
const [hidden, setRouteHidden] = useState(false);
238+
setHidden = setRouteHidden;
239+
return (
240+
<Activity mode={hidden ? "hidden" : "visible"}>
241+
<Composer />
242+
</Activity>
243+
);
244+
}
245+
246+
const { container, unmount } = mount(<Route />);
247+
act(() => attach([new File(["x"], "shot.png", { type: "image/png" })]));
248+
const picture = () => container.querySelector("img");
249+
const first = picture()?.getAttribute("src") ?? "";
250+
expect(live.has(first)).toBe(true);
251+
252+
// Hidden for longer than a tick: the hook's previews go.
253+
act(() => setHidden(true));
254+
await tick();
255+
act(() => setHidden(false));
256+
await tick();
257+
expect(live.has(first)).toBe(false);
258+
259+
// The browser fails to load the revoked URL.
260+
act(() => {
261+
picture()?.dispatchEvent(new Event("error"));
262+
});
263+
const rebuilt = picture()?.getAttribute("src") ?? "";
264+
expect(rebuilt).not.toBe(first);
265+
expect(live.has(rebuilt)).toBe(true);
266+
267+
// The rebuilt URL follows the same rule: gone while hidden, made again
268+
// on show.
269+
act(() => setHidden(true));
270+
expect(live.has(rebuilt)).toBe(false);
271+
act(() => setHidden(false));
272+
const again = picture()?.getAttribute("src") ?? "";
273+
expect(live.has(again)).toBe(true);
274+
275+
unmount();
276+
await tick();
277+
expect([...live]).toEqual([]);
278+
});
279+
280+
test("falls back to the type when the rebuilt URL fails too", () => {
281+
const shot = new File(["x"], "shot.png", { type: "image/png" });
282+
const { container, unmount } = mount(
283+
<Attachments variant="tiles">
284+
<Attachment
285+
data={{
286+
name: "shot.png",
287+
mediaType: "image/png",
288+
url: "blob:test/gone",
289+
file: shot,
290+
}}
291+
>
292+
<AttachmentPreview />
293+
</Attachment>
294+
</Attachments>,
295+
);
296+
const fail = () =>
297+
act(() => {
298+
container.querySelector("img")?.dispatchEvent(new Event("error"));
299+
});
300+
fail();
301+
expect(container.querySelector("img")).not.toBeNull();
302+
fail();
303+
expect(container.querySelector("img")).toBeNull();
304+
expect(container.textContent).toContain("PNG");
305+
unmount();
306+
expect([...live]).toEqual([]);
307+
});
117308
});

0 commit comments

Comments
 (0)