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 2a4366be5a..da13cb70d3 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 8066926067..3f7bd5a910 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 5842ad42c9..e17ee66f32 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 93ae280543..b2ecef5a02 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", );