Problem
Sending a reply or a document comment with attachments deletes the uploads of those attachments while the message is being saved. Here an attachment is the composer entry, and its upload is the file in the media bucket.
The chain, on 14ab7f9c1. Paths are under apps/webapp/src/components/chatroom/.
- A send that fits one row clears the composer before dispatch.
shouldClearComposerEarly is true when the chunker returns no chunks and there is no edit (utils/outboundMessagePipeline.ts:52-55, :125). An empty chunk list means the text fits one row (apps/webapp/src/utils/chunkHtmlContent.ts:61-66). A split message has at least two chunks, and attachments with it are refused (utils/outboundMessagePipeline.ts:92). So every reply or comment with attachments takes this path.
cleanupAfterSubmit() runs at components/MessageComposer/hooks/useComposerSubmit.ts:160. It sets the reply and comment memory to null (:86-89).
useComposerAttachmentLifecycle watches that memory. When it goes from set to unset, it calls clearAttachments({ deleteStorage: true }) (components/MessageComposer/hooks/useComposerAttachmentLifecycle.ts:79-86).
clearAttachments calls deleteNonPersistedAttachmentStorage on the current list (components/MessageComposer/hooks/useComposerAttachments.ts:77-81). The attachments being sent are ready and not persisted, so each upload is removed from the bucket (stores/composerAttachmentsStore.ts:97-103).
- The composer clears the list with
deleteStorage: false only after the send succeeds (useComposerSubmit.ts:182). By then the effect in step 3 has run.
The send and the delete race:
- The send path marks the media as checked, so
persistChatMessage skips its storage probe (utils/outboundMessagePipeline.ts:216, utils/sendChatMessage.ts:58).
- The effect in step 3 runs before the INSERT is sent. A scratch harness with React 19.2.8 and zustand 5.0.15 showed the delete request leaving first. It did so with the feed at its newest message, and with it scrolled back. Which request the server finishes first is not known.
So there are two outcomes:
- The message is saved, and its uploads are deleted a moment later. Every reader then sees an unavailable tile, such as "Image unavailable", in place of the media.
validate_message_medias() rejects the INSERT. A reply then shows a failed row, and Retry cannot succeed, because the uploads are gone. A comment has no optimistic row and no Retry. It shows only an error toast, and the early clear has already removed its text.
A plain send with attachments is not affected. It has no reply or comment memory, so step 3 does not fire.
Steps to reproduce
- Open a channel. Choose Reply on any message.
- Attach one image and type a short caption. Send.
- Reload the page, or open the channel in a second browser.
- See the reply with an "Image unavailable" tile. Or, before the reload, see it marked as failed.
- Check the
media bucket. The upload is gone.
Acceptance criteria
Agent Brief
Category: bug
Summary: Leaving reply or comment mode because of a send must not delete the uploads that the send carries.
Current behavior:
The early composer clear sets the reply or comment memory to null. A lifecycle effect reads that change as a cancel. It deletes the upload of every unsent attachment, including the attachments the send carries.
Desired behavior:
Attachments that belong to an outbound message are kept, not deleted. Only a real cancel deletes unsent uploads. A real cancel is the user leaving reply or comment mode without sending.
Key interfaces:
cleanupAfterSubmit() — runs before dispatch when shouldClearComposerEarly is true.
useComposerAttachmentLifecycle() — the set-to-unset effect on reply and comment memory.
clearAttachments({ deleteStorage }) and deleteNonPersistedAttachmentStorage() — the delete path.
ComposerAttachment.persisted — today it is true only for row-backed media in edit mode.
Out of scope
Notes
Coordinate with #263. It builds the send hand-off on the same early clear.
Coordinate with #290. After a failed plain send, it makes the failed row the only owner of the media. If this fix keeps a failed reply's attachments in the composer, the composer and the failed row share the same uploads.
Leaving reply or comment mode without sending also deletes attachments that were added before that mode began. #296 tracks that path.
Present since 7b6622687 (2026-06-24), which added chat media and this effect.
The chain is a code trace on 14ab7f9c1, and the request order comes from a scratch harness. It was not reproduced on a device.
Problem
Sending a reply or a document comment with attachments deletes the uploads of those attachments while the message is being saved. Here an attachment is the composer entry, and its upload is the file in the
mediabucket.The chain, on
14ab7f9c1. Paths are underapps/webapp/src/components/chatroom/.shouldClearComposerEarlyis true when the chunker returns no chunks and there is no edit (utils/outboundMessagePipeline.ts:52-55,:125). An empty chunk list means the text fits one row (apps/webapp/src/utils/chunkHtmlContent.ts:61-66). A split message has at least two chunks, and attachments with it are refused (utils/outboundMessagePipeline.ts:92). So every reply or comment with attachments takes this path.cleanupAfterSubmit()runs atcomponents/MessageComposer/hooks/useComposerSubmit.ts:160. It sets the reply and comment memory to null (:86-89).useComposerAttachmentLifecyclewatches that memory. When it goes from set to unset, it callsclearAttachments({ deleteStorage: true })(components/MessageComposer/hooks/useComposerAttachmentLifecycle.ts:79-86).clearAttachmentscallsdeleteNonPersistedAttachmentStorageon the current list (components/MessageComposer/hooks/useComposerAttachments.ts:77-81). The attachments being sent arereadyand notpersisted, so each upload is removed from the bucket (stores/composerAttachmentsStore.ts:97-103).deleteStorage: falseonly after the send succeeds (useComposerSubmit.ts:182). By then the effect in step 3 has run.The send and the delete race:
persistChatMessageskips its storage probe (utils/outboundMessagePipeline.ts:216,utils/sendChatMessage.ts:58).So there are two outcomes:
validate_message_medias()rejects the INSERT. A reply then shows a failed row, and Retry cannot succeed, because the uploads are gone. A comment has no optimistic row and no Retry. It shows only an error toast, and the early clear has already removed its text.A plain send with attachments is not affected. It has no reply or comment memory, so step 3 does not fire.
Steps to reproduce
mediabucket. The upload is gone.Acceptance criteria
Agent Brief
Category: bug
Summary: Leaving reply or comment mode because of a send must not delete the uploads that the send carries.
Current behavior:
The early composer clear sets the reply or comment memory to null. A lifecycle effect reads that change as a cancel. It deletes the upload of every unsent attachment, including the attachments the send carries.
Desired behavior:
Attachments that belong to an outbound message are kept, not deleted. Only a real cancel deletes unsent uploads. A real cancel is the user leaving reply or comment mode without sending.
Key interfaces:
cleanupAfterSubmit()— runs before dispatch whenshouldClearComposerEarlyis true.useComposerAttachmentLifecycle()— the set-to-unset effect on reply and comment memory.clearAttachments({ deleteStorage })anddeleteNonPersistedAttachmentStorage()— the delete path.ComposerAttachment.persisted— today it is true only for row-backed media in edit mode.Out of scope
Notes
Coordinate with #263. It builds the send hand-off on the same early clear.
Coordinate with #290. After a failed plain send, it makes the failed row the only owner of the media. If this fix keeps a failed reply's attachments in the composer, the composer and the failed row share the same uploads.
Leaving reply or comment mode without sending also deletes attachments that were added before that mode began. #296 tracks that path.
Present since
7b6622687(2026-06-24), which added chat media and this effect.The chain is a code trace on
14ab7f9c1, and the request order comes from a scratch harness. It was not reproduced on a device.