From 0982fe7e7e3112dd4501aa97d87d11480f041444 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 15 Aug 2026 09:08:51 -0600 Subject: [PATCH] fix(desktop): clean up canceled pending sends **Category:** fix **User Impact:** Canceling a deferred attachment during send preflight no longer leaves a phantom timeline row. Expose prepared-upload cancellation state so send staging cannot begin after the task was canceled, and defensively remove any adopted row when upload start loses the cancellation race. Extend focused coverage for pre-start cancellation and navigation-bound row identity. Co-authored-by: Carl Signed-off-by: Wes --- .../lib/backgroundMediaUploadStore.test.mjs | 22 +++++++++++++++++++ .../lib/backgroundMediaUploadStore.ts | 3 +++ .../messages/ui/useMentionSendFlow.ts | 6 ++--- desktop/tests/e2e/file-attachment.spec.ts | 9 ++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs b/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs index 2a4366be5..da13cb70d 100644 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { cancelStartedMediaUploads, dispatchTrackedMediaUpload, + prepareBackgroundMediaUpload, } from "./backgroundMediaUploadStore.ts"; const descriptor = { @@ -22,6 +23,27 @@ function deferred() { return { promise, resolve }; } +test("reports cancellation before a prepared upload starts", () => { + const prepared = prepareBackgroundMediaUpload([ + { + file: new File(["video"], "large-video.mp4", { type: "video/mp4" }), + id: 1, + spoilered: false, + }, + ]); + + assert.equal(prepared.isCanceled(), false); + prepared.cancel(); + assert.equal(prepared.isCanceled(), true); + assert.equal( + prepared.start({ + onComplete: async () => {}, + onError: () => {}, + }), + false, + ); +}); + test("cancels only uploads whose native commands were dispatched", async () => { const releaseUpload = deferred(); const startedProgressIds = new Map(); diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts index 806692606..3f7bd5a91 100644 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -54,6 +54,7 @@ type StartBackgroundUploadOptions = Omit< export type PreparedBackgroundMediaUpload = { cancel: () => void; + isCanceled: () => boolean; start: (options: StartBackgroundUploadOptions) => boolean; }; @@ -243,6 +244,7 @@ export function prepareBackgroundMediaUpload( let started = false; return { cancel: () => undefined, + isCanceled: () => false, start: ({ onComplete, onError }) => { if (started) return false; started = true; @@ -274,6 +276,7 @@ export function prepareBackgroundMediaUpload( cancel: () => { cancelTask(task); }, + isCanceled: () => task.canceled, start: ({ onCancel, onComplete, onError }) => { if (started || task.canceled) return false; started = true; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 5842ad42c..e17ee66f3 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -572,7 +572,7 @@ export function useMentionSendFlow({ ); } }; - if (!optimisticId && sendChannelId) { + if (!optimisticId && sendChannelId && !preparedUpload?.isCanceled()) { const initialMessage = buildOutgoingMessage( draft.trimmed, draft.savedImeta, @@ -621,9 +621,7 @@ export function useMentionSendFlow({ restoreComposerAfterFailure(); }, }); - if (!uploadStarted) { - return; - } + if (!uploadStarted) return removePendingSend(); } if ( draft.capturedChannelId === channelIdRef.current || diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index 93ae28054..b2ecef5a0 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -265,6 +265,12 @@ test("sends immediately and keeps upload progress across channels", async ({ await expect(queuedSpoiler).toHaveCSS("opacity", "1"); await page.getByTestId("send-message").click(); + const pendingRow = page.getByTestId("message-row").last(); + await expect( + pendingRow.getByTestId("message-preparation-status"), + ).toContainText("Preparing large-video.mp4…"); + const pendingMessageId = await pendingRow.getAttribute("data-message-id"); + expect(pendingMessageId).not.toBeNull(); await expect(page.getByTestId("message-composer")).not.toContainText( "large-video.mp4", ); @@ -278,6 +284,9 @@ test("sends immediately and keeps upload progress across channels", async ({ }); await page.getByTestId("channel-general").click(); + await expect( + page.locator(`[data-message-id="${pendingMessageId}"]`), + ).toHaveCount(1); await expect(page.getByTestId("file-card").last()).toContainText( "quarterly-report.pdf", );