diff --git a/packages/app/src/composer/attachments/destination.ts b/packages/app/src/composer/attachments/destination.ts index 15f1d677cdf..76d583c961f 100644 --- a/packages/app/src/composer/attachments/destination.ts +++ b/packages/app/src/composer/attachments/destination.ts @@ -42,6 +42,7 @@ export function useAttachmentDestination(controls: Accessor) { // from disk; nothing is buffered in the renderer. function write(url: URL, file: File, password: string | undefined, report: (loaded: number) => void, signal: AbortSignal) { return new Promise((resolve, reject) => { + if (signal.aborted) return reject(new DOMException("Upload aborted", "AbortError")) const xhr = new XMLHttpRequest() xhr.open("POST", url) xhr.responseType = "json" diff --git a/packages/app/src/composer/attachments/uploads.test.ts b/packages/app/src/composer/attachments/uploads.test.ts new file mode 100644 index 00000000000..2762313e623 --- /dev/null +++ b/packages/app/src/composer/attachments/uploads.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test" +import { uploads } from "./uploads" + +test("cancelling an upload drops it even when its work never settles", async () => { + const pending = uploads.track({ id: "stalled", filename: "image.png", mime: "image/png", size: 1024 }, () => + new Promise(() => {}), + ) + + expect(uploads.items().map((item) => item.id)).toEqual(["stalled"]) + uploads.items()[0]?.cancel() + + expect(await pending).toBeUndefined() + expect(uploads.items()).toEqual([]) +}) diff --git a/packages/app/src/composer/attachments/uploads.tsx b/packages/app/src/composer/attachments/uploads.tsx index c6f6d356e4b..b8f5470b048 100644 --- a/packages/app/src/composer/attachments/uploads.tsx +++ b/packages/app/src/composer/attachments/uploads.tsx @@ -25,13 +25,20 @@ export const uploads = { work: (report: (loaded: number) => void, signal: AbortSignal) => Promise, ): Promise { const controller = new AbortController() + + // The work may wait where it cannot observe the signal, such as a request queued behind + // others, so cancelling settles without it. + const cancelled = new Promise((resolve) => + controller.signal.addEventListener("abort", () => resolve(undefined), { once: true }), + ) + setState("items", (items) => [...items, { ...input, loaded: 0, cancel: () => controller.abort() }]) try { - return await work( - (loaded) => setState("items", (item) => item.id === input.id, "loaded", loaded), - controller.signal, - ) + return await Promise.race([ + work((loaded) => setState("items", (item) => item.id === input.id, "loaded", loaded), controller.signal), + cancelled, + ]) } catch (error) { if (controller.signal.aborted) return undefined throw error