Skip to content

feat(MessageComposer): add composition middleware for pending attachment uploads - #1845

Merged
MartinCupela merged 7 commits into
masterfrom
feat/upload-awaiting-response
Aug 28, 2026
Merged

feat(MessageComposer): add composition middleware for pending attachment uploads#1845
MartinCupela merged 7 commits into
masterfrom
feat/upload-awaiting-response

Conversation

@MartinCupela

@MartinCupela MartinCupela commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Goal

Make it possible to compose a message while its attachment uploads are still in flight.

Today that is refused twice over: the attachments composition middleware discards any composition with uploads running (warning Wait until all attachments have uploaded), and MessageComposer.hasSendableData returns false for as long as uploadsInProgressCount > 0. A UI SDK therefore cannot offer sending before every transfer has finished, however long that takes.

This PR adds the composer-side half of that flow, plus the fixes it exposed in upload state, cancellation and preview lifecycle.

It is deliberately only half. The composition produced is not ready for the wire — message.attachments omits every attachment that has no URL yet — so the other half belongs to whoever performs the send: await the pending uploads, write the resolved URLs into the payload, then make the request. That is why nothing here is enabled by a config option.

Backwards compatible except in one respect: outgoing attachments are now sanitized on every send and every update, which strips composer-internal state that was previously forwarded to the API. Everything else takes effect only once a UI SDK installs the new middleware.

Implementation details

Composing with pending uploads

createSendWithPendingUploadsAttachmentsMiddleware replaces the default attachments composition middleware and shares its id, so replace() keeps its position in the chain. It splits the two payloads instead of discarding the composition:

  • localMessage.attachments keeps localMetadata for anything still uploading — the UploadManager id, the file handle needed to await the upload, the local preview URI;
  • message.attachments carries only attachments that already resolved to a URL.

Neither key is introduced when its list is empty, matching the default middleware — an empty attachments array is not "nothing to say", it reads as "remove every attachment" on an edit.

Attachments still uploading stay in the composer when the composition carries a poll. A poll composition does not clear the composer, so handing the attachment over would leave the same localMetadata.id owned by both the message and the composer, and a second UploadManager.upload call would restart a request whose in-flight entry had already been cleaned up.

One switch: the middleware declares what it does

MessageCompositionMiddleware.allowsPendingUploads is a declaration a composition middleware sets on itself, and MessageComposer.hasSendableData reads it: while such a middleware is installed, an upload in flight is no longer a blocker and a pending attachment counts as content in its own right. failed and blocked attachments still do not count, so a message whose only attachment was rejected is not sendable.

Installing the middleware is therefore the whole switch — there is no second flag for a UI SDK to keep in sync with it, and a custom middleware implementing the same contract is recognised regardless of the id it uses.

Reading the declaration needs MiddlewareExecutor.installedMiddleware, a new read-only view of the chain (MessageComposerMiddlewareExecutor narrows it to the composition middleware type). Both the declaration and MessageComposer.allowsPendingUploads are marked temporary: v10 moves this into the composer configuration.

The window between the last byte and the response

UploadRecord.uploadConfirmationPending marks the gap between the request body being flushed and the server answering, and is mirrored onto localMetadata. Upload progress counts bytes written to the connection, not bytes acknowledged, so it reaches 100% while the file is still being ingested — long enough to matter for a large file on a slow link. The flag is lowered only by a progress report carrying a number below 100: a report with no number means the transport cannot measure this upload, not that flushed bytes were un-sent.

Outgoing attachments are sanitized

sanitizeOutgoingAttachments strips localMetadata and drops any attachment that never resolved, reporting the drop through client.logger and console.warn. It runs in channel._sendMessage and client._updateMessage — the two methods every path converges on, including the offline replay of a queued task, which calls them directly with a payload that a merged failed edit may have rewritten since it was stored.

"Resolved" means a remote http(s) URL, not merely the presence of one. A local reference — blob:, file:, content:, an inline data: payload — resolves only on the device that produced it, and forwarding it would store an attachment nobody else can load. Reaching that branch means the code that composed the message has a bug, so it is logged rather than raised as a notification, which is a channel for things an end user can act on. partialUpdateMessage is deliberately not covered: its set is a caller-authored patch that no composer output flows through.

Cancellation is not a failure

Removing an attachment mid-upload aborts the request through its AbortController. isUploadCancellation recognises the result — an axios cancellation, or a DOMException named AbortError, which is what a custom transport conventionally throws — and both the post-upload error middleware and the deprecated uploadAttachment path now stay quiet instead of raising an upload error for something the user asked for.

Preview lifecycle

removeAttachments releases the removed attachment's blob: preview, which previously leaked one object URL per removal.

The post-upload enrichment middleware releases a preview only while the composer still holds the attachment, which its new optional composer argument is what tells it. The middleware runs when an upload finishes, and that can now happen after the composer was cleared — at which point something else is rendering from that preview and releasing it would blank it out.

New exports

isPendingUpload / isFinishedUpload (upload-state predicates, typed against AttachmentLoadingState), isUploadCancellation, and the tag-keyed async runners withoutConcurrency, withCancellation, hasPending, settled — so a UI SDK can serialise its own actions with the primitive this SDK already uses internally instead of hand-rolling promise chains.

What a UI SDK has to implement

  1. Install createSendWithPendingUploadsAttachmentsMiddleware on the composers it wants this for. Sendability follows from the declaration; nothing else needs switching.
  2. In its send path, await the uploads still in flight (UploadManager.upload is idempotent by localMetadata.id, so calling it again returns the in-flight promise), write the resolved URLs into message.attachments, and only then make the request. Doing this in the send path rather than at submit time also covers resending a failed message, and skipping attachments that already carry a URL makes a retry re-upload only what failed.
  3. Decide what ordering it wants. Two sends started in quick succession now differ in duration by however long an upload takes, and created_at is stamped when the request arrives — withoutConcurrency is exported for serialising them per channel.

uploadConfirmationPending is available for both a composer attachment (from localMetadata) and a message attachment (from the live UploadManager record) if the SDK wants to distinguish "sending" from "sent but unconfirmed".

Tests

39 cases across the composition middleware, hasSendableData, UploadManager, AttachmentManager, the post-upload middlewares, sanitizeOutgoingAttachments, and the channel / client send and update paths.

Adds the pieces a UI SDK needs to let a message be sent before its
attachments finish uploading, plus the fixes that flow made necessary.

- createSendWithPendingUploadsAttachmentsMiddleware: drop-in replacement
  for the default attachments middleware (same id) that stops discarding
  a composition with uploads in flight. localMessage.attachments keeps
  localMetadata for what is still uploading; message.attachments carries
  only attachments that already resolved to a URL.
- MessageComposer.hasSendableDataWithPendingUploads: the matching
  sendability rule, so an upload in flight does not disable the send.
  failed and blocked attachments still do not count.
- No config option turns this on: the composition it produces is not
  ready for the wire, so the switch belongs to the UI SDK that awaits
  the uploads and sends afterwards.
- UploadRecord.uploadConfirmationPending marks the window between the
  last byte being written to the connection and the server responding,
  mirrored onto localMetadata. Progress hits 100% before anything is
  confirmed, so a UI can go indeterminate instead of claiming success.
- isPendingUpload / isFinishedUpload predicates.
- withoutConcurrency, withCancellation, hasPending and settled are now
  exported, so UI SDKs can serialise their own actions with the same
  primitive rather than hand-rolling promise chains.

Fixes:

- A cancelled upload is no longer reported as a failure. Removing an
  attachment mid-upload aborts the request through its AbortController;
  both the post-upload error middleware and the deprecated
  uploadAttachment path now recognise that (axios cancellations and
  DOMException AbortError, which is what the React Native adapter
  throws) and stay quiet.
- removeAttachments releases the attachment's blob preview instead of
  leaking one URL per removed attachment.
- The post-upload enrichment middleware releases a preview only while
  the composer still holds the attachment. Once a message renders from
  it, releasing would blank that message out.
- channel.sendMessage strips localMetadata from outgoing attachments and
  drops any that never resolved, with a warning, so a UI that composes
  with pending uploads but does not await them cannot store an
  attachment pointing at nothing.
Comment thread src/channel.ts Outdated
Comment thread src/messageComposer/attachmentIdentity.ts Outdated
Comment thread src/messageComposer/messageComposer.ts Outdated
(!composerIsKeptAsDraft && isPendingUpload(attachment)),
);

const localAttachments = (state.localMessage.attachments ?? []).concat(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question about the shape that gets persisted, RN stringifies message attachments straight into SQLite, so this localMetadata survives a restart and comes back as uploading with nothing in flight. normalizeSnapshotAttachment covers the composer, but nothing covers a stored message.

Is that ours to deal with or maybe we can add a normalizer here? Changes whether we need our own hydration pass (right now the implementation will stay in RN but eventually I would like to integrate it using the new middleware for consistency)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that this is currently covered by the RN SDK, so I would defer the introduction into the LLC to a next PR to make sure RN is not broken with changes that could be introduced in this subject.

Comment thread src/messageComposer/middleware/messageComposer/attachments.ts
Comment thread src/uploadManager.ts Outdated
Comment thread src/utils.ts Outdated
Comment thread src/utils.ts Outdated
…oads

createSendWithPendingUploadsAttachmentsMiddleware wrote message.attachments even when no upload had finished, so a composition whose uploads were all still in flight carried \`attachments: []\`. On an edit the API reads that as \"remove every attachment\". The default middleware deliberately never introduces the key when it has nothing to add.

Both payloads now get the key only when their list is non-empty — localMessage too, since it can be empty while message.attachments is not.
@MartinCupela MartinCupela changed the title feat: support sending messages while attachments are still uploading feat(MessageComposer): add composition middleware for pending attachment uploads Aug 28, 2026
@MartinCupela
MartinCupela merged commit 68e5d69 into master Aug 28, 2026
13 checks passed
@MartinCupela
MartinCupela deleted the feat/upload-awaiting-response branch August 28, 2026 14:48
github-actions Bot pushed a commit that referenced this pull request Aug 28, 2026
## [9.52.0](v9.51.0...v9.52.0) (2026-08-28)

### Bug Fixes

* do not reset channel unread count on thread read ([#1835](#1835)) ([79fbf54](79fbf54))

### Features

* **MessageComposer:** add composition middleware for pending attachment uploads ([#1845](#1845)) ([68e5d69](68e5d69))
@stream-ci-bot

Copy link
Copy Markdown

🎉 This PR is included in version 9.52.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants