Skip to content

Media: Handle editor's media modal uploads with client-side pipeline - #82473

Open
adamsilverstein wants to merge 19 commits into
WordPress:trunkfrom
adamsilverstein:fix/82409-media-modal-client-side-uploads
Open

adamsilverstein wants to merge 19 commits into
WordPress:trunkfrom
adamsilverstein:fix/82409-media-modal-client-side-uploads

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Sep 4, 2026 •

Copy link
Copy Markdown
Member

Fixes #82409

Uploads the editor starts itself - dropping a file on a block, the inserter, the Upload button - go through the block editor's mediaUpload setting, which the provider swaps for the @wordpress/upload-media pipeline when client-side processing is available. The media modal is Backbone wp.media, and its uploader is core's wp.Uploader/plupload, which nothing intercepts, so it posts the original bytes to async-upload.php. That is why the same HEIC file converts and uploads on an Image block but fails in the modal, and why anything uploaded there also skips browser-generated sub-sizes, the big image threshold, and animated GIF handling.

This binds a higher priority FilesAdded handler on every wp.Uploader instance and hands the files to the same pipeline instead. plupload's placeholder attachments, progress and wp.Uploader.errors are mirrored, so the modal's own UI works unchanged. The batch falls back to plupload whenever the pipeline is unavailable - no cross-origin isolation, an older browser - or when it cannot take every file in the batch.

Related: WordPress/wordpress-develop#12585 does the same for the Media Library grid and the Add New Media File screen. Those scripts are not enqueued on editor screens, so that PR does not cover the modal and the two are complementary.

How has this been tested

Test in WordPress Playground

  1. Open a post in the editor in a browser where the pipeline runs (Chrome 137+, or Firefox with the isolation headers).
  2. Add an Image block and drop a HEIC or AVIF file on it. It converts and uploads.
  3. Add another Image block, click "Media Library", and upload the same file from the modal. Before this change the AVIF failed and the HEIC came back with "The server cannot process HEIC images. Convert it to JPEG before uploading."
  4. Watch the network tab while it uploads: the file goes to /wp/v2/media, then /sideload once per sub-size, then /finalize. Nothing hits async-upload.php.
  5. For the fallback, load the editor without cross-origin isolation (or in Safari) and upload from the modal again - it still uploads the classic way.

Automated coverage, both green locally:

  • npm run test:unit:vitest -- packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts
  • npm run test:e2e -- test/e2e/specs/editor/various/media-modal-client-side-upload.spec.js

The e2e spec was run against the unfixed build first, where it fails because the upload never reaches the REST API. The AVIF case is covered there too; the HEIC case was not, since there is no HEIC fixture in the repo, and it takes the same path.

Types of changes

  • Bind a higher priority FilesAdded handler on the wp.Uploader instances the media modal creates.
  • Route those files through the @wordpress/upload-media store and mirror plupload's placeholder tiles, progress and error list.
  • Fall back to the classic upload when the pipeline is not configured, when a file has no native File, or when the browser can only convert HEIC and the batch holds something else.
  • Forward plupload's multipart params so anything a plugin added through plupload_default_params still reaches the upload.

Open questions

  • The interception is installed when the modal opens, so the classic block's own media modal (opened from TinyMCE) is not covered. Worth a follow up, or is the block editor's modal enough?
  • Whether this belongs here at all, or in core's wp-plupload.js detecting an active pipeline itself, is still the open question from the issue.

cc: @swissspidy @andrewserong

AI Use

Code and description both written with 🤖 Claude Code. I will review and test.

adamsilverstein and others added 2 commits September 4, 2026 12:26
Uploads the editor starts itself go through the block editor's mediaUpload
setting, which the provider swaps for the @wordpress/upload-media pipeline
when client-side processing is available. The media modal is Backbone
wp.media, and its uploader is core's wp.Uploader/plupload, which nothing
intercepts, so it posts the original bytes to async-upload.php. The same
HEIC file converts and uploads on an Image block but fails in the modal,
and files uploaded there skip browser-generated sub-sizes, the big-image
threshold and animated GIF handling.

Bind a higher-priority FilesAdded handler on every wp.Uploader instance and
hand the files to the same pipeline, mirroring plupload's placeholder
attachments, progress and wp.Uploader.errors so the modal's UI works
unchanged. The batch falls back to plupload whenever the pipeline is
unavailable or cannot take every file in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMK7kaTqz41w6rTnjiP9KC
The e2e spec asserts the modal's upload reaches the REST API and never
async-upload.php, and that a format the server may not be able to process
uploads cleanly. The unit tests cover the plupload interception itself:
priority, the placeholder attachment, the forwarded multipart params, the
fallback cases, and how success and failure are reported back to the modal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMK7kaTqz41w6rTnjiP9KC
@coderabbitai

coderabbitai Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. To trigger a review, include @coderabbitai review in the PR description. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: f1845232-3169-4d9b-963b-b91e60f9d7d0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the [Package] Media Utils /packages/media-utils label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: adamsilverstein <adamsilverstein@git.wordpress.org>
Co-authored-by: andrewserong <andrewserong@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@adamsilverstein adamsilverstein added [Type] Bug An existing feature does not function as intended [Feature] Media Anything that impacts the experience of managing media [Feature] Client Side Media Media processing in the browser with WASM labels Sep 4, 2026
adamsilverstein and others added 9 commits September 4, 2026 15:10
`@wordpress/fields` is a bundled package and reaches `@wordpress/media-utils`
through its media-edit component. Importing the `store` descriptor from
`@wordpress/upload-media` pulled the store, its lock-unlock module, and
`@wordpress/private-apis` into that bundle, failing the private API check.

Address the store by name instead and keep only the feature-detection
imports, which tree-shake cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMK7kaTqz41w6rTnjiP9KC
- Reach the pipeline through the `wp.uploadMedia` global instead of
  importing `@wordpress/upload-media`, so the `wp-media-utils` handle does
  not drag the processing stack onto every screen that enqueues it - and
  so no bundled package importing media-utils reaches private APIs.
- Match a queue item to its upload by the identity of the `onSuccess`
  callback rather than by file name/size/mtime, so an item the block
  editor queued for the same file cannot claim the modal's tile.
- Report an upload's outcome once: the store can call `onSuccess` twice
  for a parent item, and a cancel can be followed by a late success.
- Fail a tile whose queue item leaves the store without reporting, which
  `cancelItem()` does silently. It used to stick at 99% and keep the
  modal from returning to browse mode.
- Set the attachment id non-silently so `Attachments` re-keys the model
  from its cid to its id; without it a library refetch duplicated the
  tile.
- Map the REST attachment to `wp.media` attributes in the refetch-failure
  fallback, instead of writing REST field names onto the model.
- Leave a batch of already-failed files to the built-in handler, which is
  the only caller of `up.start()`.
- Unsubscribe from the store once nothing is in flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMK7kaTqz41w6rTnjiP9KC
Addressing a store by name gives `unknown` from `dispatch()`, so
`dispatch( 'core/upload-media' ).addItems()` failed the type check.
Declare the selectors and action creators this module uses and route
every store access through a typed accessor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMK7kaTqz41w6rTnjiP9KC
`isPipelineReady()` accepted any truthy `mediaUpload` in the upload-media
store, but that setting defaults to a no-op: it takes a file and never
calls back. On a screen that carries the media modal without a block
editor behind it - the site editor's page list, where "Set featured
image" opens the modal from a DataViews quick edit - the store is
registered but never configured, so every file handed to it stranded the
modal's tile at "uploading" and left the Select button disabled.

Require `mediaSideload` and `mediaFinalize` as well. Neither has a
default, and the provider writes all three in one dispatch, so their
presence is what tells a configured store from an untouched one. An
unconfigured store now falls back to the classic server-side upload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XymJGmRuNKwf8srrtsrcLR
Both specs waited for a tile without the `uploading` class to appear,
which the modal can satisfy without this upload having done anything:
queuing the file flips the frame from the upload tab to the library
grid, so any attachment an earlier spec in the shard left behind renders
a settled tile at once. The first spec then read zero `/wp/v2/media`
requests and failed; the second passed in 1.5s without ever checking the
upload it was meant to exercise.

Wait for the pipeline's finalize request - the last one it makes for a
file - then assert no uploading tile is left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XymJGmRuNKwf8srrtsrcLR
The 5.55.0 release on 2026-09-10 renamed the `## Unreleased` heading the
entry was written under, stranding it in a published version. The new
changelog structure validator rejects that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XymJGmRuNKwf8srrtsrcLR
…l-client-side-uploads

# Conflicts:
#	packages/media-utils/CHANGELOG.md
@adamsilverstein adamsilverstein changed the title Media: Route uploads from the editor's media modal through the client-side pipeline Media: Handle editor's media modal uploads with client-side pipeline Sep 23, 2026
The pipeline hands onSuccess the attachment after transformAttachment(),
which replaces source_url and alt_text with url and alt and flattens the
title to a string. When the tile's refetch failed, the fallback read the
raw REST fields and left a finished tile with no URL, alt or title.

Read the transformed fields first, keep the REST ones as a fallback, and
cover the refetch-failure path with a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FsWX1VRwNrAQQtytWXzTQ9
@adamsilverstein
adamsilverstein requested review from andrewserong and removed request for andrewserong September 25, 2026 01:08
The attachment details sidebar does not re-render on every model change,
only on a title change. The refetch brought the title while the model was
still marked as uploading, and uploading: false arrived afterwards, so the
sidebar kept showing a progress bar for a finished upload. wp-plupload.js
avoids this by setting the response and uploading: false in one call.

Clear the uploading state silently before the refetch so the render the
title change triggers already sees a finished attachment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vnh6X3n9GDEZjAbXSYvQNX
@adamsilverstein

Copy link
Copy Markdown
Member Author

Captured this PR in action, uploading every format I could think of through the editor's media modal, plus a couple of files that should fail.

Claude ran the uploads and recorded them, here is what came back:

Tested on a wp-env build of 291acb6, opening the modal from an Image block's "Media Library" button and dropping files on the "Upload files" tab. The modal flips to the Media Library tab as soon as the files are queued, same as it does for classic uploads.

All formats (Google Chrome 153, macOS) - JPEG, PNG, WebP, AVIF, HEIC and an animated GIF in one batch:

Uploading six formats through the media modal

Every file went through the client-side pipeline: 6 creates, 27 sideloads, 6 finalizes, and zero requests to async-upload.php.

File Result
mountain-photo.jpg JPEG plus 7 sub-sizes
sunset-graphic.png stays PNG, 5 sub-sizes
forest.webp stays WebP, 5 sub-sizes
beach.avif stays AVIF, 5 sub-sizes
iphone-photo.heic converted to iphone-photo.jpg, original kept as source_image
animation.gif animated_video: animation.mp4 and animated_video_poster: animation.jpeg in the attachment metadata; the mp4 serves as video/mp4

Uploading, then the finished Media Library tab:

Six tiles uploading

All six formats in the Media Library tab

HEIC details show the converted JPEG, and the GIF details after its video conversion:

HEIC attachment details showing iphone-photo.jpg

GIF attachment details

Selecting it hands the upload back to the Image block as usual:

Selected image inserted into the block

Failures (Playwright's Chromium, which has no HEIC decoder) - a valid JPEG, a HEIC, and a garbage.jpg of random bytes in one batch:

Uploads that fail in the media modal

Errors listed in the modal sidebar

  • The JPEG uploads normally.
  • The HEIC fails in the browser: "Chrome couldn't decode HEIC on this PC, so we couldn't convert this one. You can upload a JPEG instead."
  • garbage.jpg is rejected by the server: "Sorry, you are not allowed to upload this file type."

In the modal, errors show up in the modal's own upload error list with a "Dismiss errors" button, not in a snackbar (the snackbar is only for uploads started inside the block editor). That matches classic uploads: the same garbage.jpg with client-side processing turned off shows the same message in the same place. Nothing is left stuck at "uploading", and the Select button works for the file that succeeded.

One note on "corrupt" files: a file with a valid JPEG header followed by junk bytes is accepted by both paths (the server takes it as image/jpeg and it ends up with no sub-sizes), so it is not a useful failure case. Random bytes with no image header are.

Bug found and fixed along the way: after a client-side upload, the Attachment Details sidebar kept showing a progress bar for the finished file. The details view only re-renders when the title changes. The refetch changed the title while the model was still marked uploading, and uploading: false came after. Classic wp-plupload.js sets both in one call. Fixed in 291acb6 with a unit test that fails without the fix. It was confirmed in the browser: the progress bar was stuck before the fix and cleared after, while classic uploads were fine both times.

Happy to capture any other formats or flows if that helps with review.

getMockImplementation() on an untyped vi.fn() returns a union that
includes a constructor type, which fails the TS2349 typecheck in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vnh6X3n9GDEZjAbXSYvQNX

@andrewserong andrewserong left a comment

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.

This is testing nicely for me so far! The images uploaded using the classic media modal in the block editor are uploaded via the client-side workflow with sideload requests etc showing correctly in the network tab 👍

A couple of questions about the scope:

  • This overrides all uploads via the global wp.Uploader once a user opens the media modal. However, it's possible for plugins to be using wp.Uploader from the block editor, too, and once a user opens the core media modal, then wp.Uploader will have the client-side overridden approach instead. Is there a way to ensure the client-side-modal-uploads overridden approach only fires on requests that are intended for the attachment uploader? I.e. so that plugins that are doing their own upload approach aren't affected?
  • I like that this is set up in a separate file as it makes it easy to load. But it also expects that the upload-media store is available. Looks like it correctly falls back when it isn't available, though? Something to consider for further down the track is how we'll roll this out for instances of the classic media modal that live outside of the block editor and media library screens. E.g. the site icon button on http://localhost:8888/wp-admin/options-general.php — not something to worry about in this PR, but just thought I'd mention it in case it informs where things should live (or where the logic might move one day)

* @param attachment The finalized attachment.
* @return Attributes for a `wp.media` attachment model.
*/
function toModelAttributes( attachment: any ): Record< string, unknown > {

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.

Is there an opportunity to use real types here? We should have a couple of types to play with in ./types.ts that might fit?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yes, good idea

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good idea, done in 76a2634.

Claude did the typing here:

The attachment the pipeline hands back is now Partial< Attachment > from ./types, which also made the raw REST field fallbacks (source_url, alt_text, title.raw) unreachable, so they are gone. The plupload uploader, wp.Uploader instance and Backbone model have no types in the repo, so those got narrow local shapes alongside the existing PluploadFile, the same way the rest of the module already declares the store's selectors.

@adamsilverstein

Copy link
Copy Markdown
Member Author

A couple of questions about the scope:

Good questions, I'll see if we can make it so the "overridden approach only fires on requests that are intended for the attachment uploader"

I like that this is set up in a separate file as it makes it easy to load. But ...

I will review the approach and see how that impacts other uses.

adamsilverstein and others added 2 commits September 30, 2026 15:18
…l-client-side-uploads

# Conflicts:
#	packages/media-utils/CHANGELOG.md
… module

The FilesAdded patch reaches every wp.Uploader built after the modal opens,
including one a plugin builds from the block editor for its own endpoint.
Check core's defaults (async-upload.php with the upload-attachment action)
before taking a batch, so anything pointed elsewhere keeps its own upload.

Replace the module's `any` parameters with the package's Attachment type for
what the pipeline returns and narrow local shapes for the plupload, wp.Uploader
and Backbone model objects, and drop the raw REST field fallbacks the typed
shape makes unreachable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q272S5jKgWCuQYrbz2SKb
@adamsilverstein

Copy link
Copy Markdown
Member Author

@andrewserong thanks for testing, and for the scope questions - the first one was a real gap. Both are addressed in 76a2634, along with the types you asked about in the thread.

I had Claude work through both points, here is the summary:

On the first question: the patch does reach every wp.Uploader built after the modal opens, and nothing checked whose uploader it was. The handler now bails unless the instance is an attachment uploader by core's own defaults - it posts to async-upload.php with the upload-attachment action. A plugin that points its uploader at another endpoint, or gives it another action, keeps its own upload path untouched. A plugin that keeps both defaults is uploading an attachment, and routing that through the pipeline seems like the right outcome. Two unit tests cover the custom action and custom endpoint cases.

On the second: agreed this module is the Gutenberg-side stopgap, and reading the pipeline through globals rather than importing the package was meant to keep it movable. The durable home is probably core's wp-plupload.js detecting an active pipeline itself, which would cover the site icon picker, the customizer and the classic block's modal at once, and would let this handler back off. Worth tracking that in a follow up issue, alongside WordPress/wordpress-develop#12585 which does the equivalent for the Media Library grid and Add New screens.

Does the gate look right to you, or would you rather see it keyed off the modal's UploaderWindow view instead of the uploader settings? I'll open the follow up issue for the core side once we settle on the approach here.

Core's media-frame-upload script (wordpress-develop WordPress#13875) binds the same
FilesAdded handler at the same priority and sets window.__wpMediaFrameUpload
once it has. Skip installing this module's handler when that flag is present,
so the first `false` return is core's by design rather than by load order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q272S5jKgWCuQYrbz2SKb
@adamsilverstein

Copy link
Copy Markdown
Member Author

One more small change in cea684f, tied to the core side of the second question above: the module now does nothing when core's window.__wpMediaFrameUpload flag is set. That flag comes from WordPress/wordpress-develop#13875 (https://core.trac.wordpress.org/ticket/66220), which moves this integration into core's media frame script, so once that lands core's handler takes over here instead of the two binding at the same priority and racing on load order.

@andrewserong

Copy link
Copy Markdown
Contributor

Thanks for the updates!

Does the gate look right to you, or would you rather see it keyed off the modal's UploaderWindow view instead of the uploader settings? I'll open the follow up issue for the core side once we settle on the approach here.

That gating looks good to me, and is what I had in mind. With the caveat that my knowledge of plupload and the core media library code is pretty vague — but in principle gating on the upload-attachment actions sounds good to me 👍

That flag comes from WordPress/wordpress-develop#13875 (https://core.trac.wordpress.org/ticket/66220), which moves this integration into core's media frame script, so once that lands core's handler takes over here instead of the two binding at the same priority and racing on load order.

Nice work pursuing this in core! That raises another question: if this is handled canonically in core, do we need this PR anymore? I.e. what if we consolidated our work in WordPress/wordpress-develop#13875 so we don't need to worry about managing the support in Gutenberg itself? I imagine this might help us avoid duplication double handling? Or was there another reason to have this in Gutenberg, too?

This branch has not been deployed

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

Labels

[Feature] Client Side Media Media processing in the browser with WASM [Feature] Media Anything that impacts the experience of managing media [Package] Media Utils /packages/media-utils [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Uploads from the editor's media library modal still go through the server

2 participants