Skip to content

Media: Enable client-side media uploads in the Media Library grid and media-new.php#12585

Open
adamsilverstein wants to merge 13 commits into
WordPress:trunkfrom
adamsilverstein:add/media-library-client-side-uploads
Open

Media: Enable client-side media uploads in the Media Library grid and media-new.php#12585
adamsilverstein wants to merge 13 commits into
WordPress:trunkfrom
adamsilverstein:add/media-library-client-side-uploads

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Jul 17, 2026

Copy link
Copy Markdown
Member

Trac ticket: https://core.trac.wordpress.org/ticket/65661

What

WordPress 7.1's client-side media pipeline (wasm-vips) only runs in the block editor: it swaps the editor's mediaUpload setting and never touches the admin upload screens, which upload via plupload to async-upload.php with all image processing server-side. This PR extends the pipeline to both remaining admin upload surfaces, so uploads are processed in the browser (REST upload of the original, client-side thumbnails, sideload, finalize) instead of server-side:

  1. The Media Library grid (upload.php) - drag-and-drop and "Add New", both of which flow through the one grid wp.Uploader.
  2. The "Add New Media File" screen (media-new.php) - a separate uploader path: plupload-handlers creates a raw plupload.Uploader (wp.Uploader never loads there).

It also adds a beforeunload guard on both screens while pipeline uploads are in flight. Interrupting a pipeline upload is worse than interrupting a classic one: classic uploads complete server-side once the bytes arrive, but an interrupted pipeline upload loses browser-generated thumbnails that were not sideloaded yet and leaves the attachment unfinalized.

This ports client-side-media-everywhere PR #49 (see issue #44) into core, adapted to core's DIP-only isolation model.

How

Media Library grid

Cross-origin isolation. A new wp_set_up_media_library_cross_origin_isolation() on load-upload.php reuses core's existing wp_start_cross_origin_isolation_output_buffer() (Document-Isolation-Policy, Chromium 137+). It is gated to grid mode (resolved the same way upload.php resolves it later in the request, via a new wp_get_media_library_mode()) and to users with upload_files. Unlike the plugin, no COEP/COOP fallback path is needed: core's isolation is DIP-only across all screens.

Pipeline routing. A new media-library-upload script (vanilla IIFE in src/js/_enqueues/admin/, matching the surrounding admin scripts) configures the @wordpress/upload-media store via MediaUploadProvider (useSubRegistry: false), wraps wp.Uploader.prototype.init to intercept FilesAdded at a higher plupload priority, and routes each file through the store while mirroring wp-plupload's placeholder tiles, progress, queue reset, and error sidebar so the grid UI works unchanged. mediaSideload/mediaFinalize are thin apiFetch wrappers rather than the private @wordpress/media-utils APIs. The script is enqueued from the upload.php grid branch via wp_enqueue_media_library_upload(), with pipeline settings (wp_get_media_library_upload_settings(): max upload size, allowed mime types, registered sub-sizes, big-image threshold, strip-meta and bit-depth filters) passed as an inline script.

media-new.php

A new wp_set_up_media_new_cross_origin_isolation() on load-media-new.php extends the same Document-Isolation-Policy output buffer to the screen, gated on client-side processing being enabled and upload_files (the screen itself already requires it; no mode gating applies). A new media-new-upload admin script binds a higher-priority FilesAdded handler on the plupload-handlers uploader instance and routes files through the @wordpress/upload-media store, sharing settings with the grid integration via wp_get_media_library_upload_settings().

The screen's existing UI helpers are reused rather than replicated: fileQueued() builds the progress item, uploadSuccess() renders the finished attachment row through the existing async-upload.php?fetch=3 markup endpoint, itemAjaxError() surfaces per-file errors, and uploadComplete() runs when the queue drains - so the screen looks and behaves unchanged. Store progress is mirrored onto the screen's progress bars.

The mediaSideload/mediaFinalize apiFetch wrappers are intentionally duplicated from the grid script rather than extracted into a shared handle: they are thin, and the two screens share no other loadable script (the grid script requires wp.Uploader/media-views, which media-new.php never loads).

One deliberate scope choice: the built-in FilesAdded handler blocks WebP/AVIF (and warns for HEIC) when the server cannot edit them; the pipeline handler bypasses those checks because conversion happens client-side.

beforeunload guard

Both upload scripts trigger the browser's leave confirmation while their progress map is non-empty (models stay there from interception until success or error). Scoped to the pipeline; classic uploads behave exactly as before.

Graceful degradation

On both screens, when the browser is not cross-origin isolated or lacks client-side support, the scripts no-op and the classic plupload flow (and the ?browser-uploader HTML fallback form on media-new.php) keep handling uploads unchanged - degradation, never data loss.

Test coverage

E2E. New specs for each screen assert the DIP header (including its absence in the grid's list mode), the happy-path upload (create + sideload + finalize via REST, no file upload through async-upload.php; media-new's fetch=3 markup POST is expected and excluded), and the disallowed-file-type error path. The happy paths go past request counting: the finalized attachment must carry thumbnail and medium sub-sizes in its REST media_details, and the sideloaded thumbnail file must be servable. The beforeunload guards get direct coverage on both screens: sideload requests are held via request routing so an upload is deterministically in flight, then a synthetic cancelable beforeunload event must be prevented while uploading and no longer prevented after completion. CI's Playwright Chromium supports Document-Isolation-Policy, so the full pipeline (browser-side thumbnailing, sideload, finalize) is exercised in CI; the upload assertions still skip in browsers where isolation is unavailable, and the header assertions always run. The test image is 640x480 so registered sub-sizes are actually generated and sideloaded.

Adjusted existing test. The media-upload.test.js spec asserts a dismissible error for a failed SVG upload on media-new.php. With the pipeline active, the disallowed file is rejected client-side and renders the standard per-file error UI, where the dismiss control is a link, rather than the server-rendered async-upload.php notice, where it is a button. The spec now targets the .dismiss control by class so both variants pass; the asserted error text and dismissal behavior are unchanged.

PHPUnit. 33 new tests across four suites (wpMediaLibraryCrossOriginIsolation.php, wpEnqueueMediaLibraryUpload.php, wpMediaNewCrossOriginIsolation.php, wpEnqueueMediaNewUpload.php) cover the isolation gating, the load-* hook registrations in default-filters.php, script enqueueing, that the inline settings are exactly the JSON encoding of wp_get_media_library_upload_settings(), and that the upload_mimes, image_strip_meta, and image_max_bit_depth filters flow through to the settings the browser pipeline consumes.

Testing

Test this pull request with WordPress Playground.

  • All 33 new PHPUnit tests pass locally, as does the existing wpCrossOriginIsolation.php suite.
  • PHPCS: no new issues on the touched files. JSHint passes on the new and modified scripts.
  • Manual flow to verify (Chrome 137+, secure origin): upload on the Media Library grid and on Media > Add New Media File and observe REST create/sideload/finalize in DevTools with no upload POST to async-upload.php; the media-new progress item resolves to the normal row with Edit and Copy URL links; closing the tab mid-upload prompts for confirmation; Firefox/Safari and ?browser-uploader keep the classic path.
  • The equivalent implementation passed the plugin's E2E suite on chromium, firefox, and webkit against core trunk (7.1-alpha), including full-pipeline runs on browsers where isolation is available.

The client-side media pipeline needs SharedArrayBuffer, which requires a
cross-origin isolated context. Core only isolates the block editor
screens, so uploads from the Media Library grid cannot use the pipeline.

Hook the existing Document-Isolation-Policy output buffer on
load-upload.php, gated to grid mode for users who can upload files.
The mode is resolved the same way upload.php resolves it later in the
request, without updating the saved user option. List mode has no
pipeline integration and stays untouched, avoiding isolation side
effects on a screen that gets no benefit.
Grid uploads go through wp.Uploader/plupload to async-upload.php, doing
all image processing server-side even when the browser could handle it.

Add a media-library-upload script that configures the
@wordpress/upload-media store and intercepts plupload's FilesAdded at a
higher priority, routing each file through the pipeline: REST upload of
the original, client-side thumbnails via wasm-vips, then sideload and
finalize. The grid UI is preserved by mirroring wp-plupload's
placeholder tiles, progress, queue reset, and error sidebar.
mediaSideload/mediaFinalize are thin apiFetch wrappers because the
@wordpress/media-utils equivalents are private APIs.

When the browser is not cross-origin isolated or lacks client-side
support, the script no-ops and classic plupload keeps handling uploads,
so degraded environments lose nothing.
Assert the Document-Isolation-Policy header is sent on the grid and not
in list mode, that a JPEG upload flows through the REST create,
sideload, and finalize endpoints with no async-upload.php requests, and
that a disallowed file type surfaces in the error sidebar.

Playwright's Chromium build ships without Document-Isolation-Policy
support, so the upload assertions skip when the context is not
cross-origin isolated; the header assertions still run everywhere.
@github-actions

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.

Core Committers: Use this line as a base for the props when committing in SVN:

Props adamsilverstein.

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

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

The ticket did not exist yet when the tests were written; annotate all
new test methods now that it has been filed.
CI's Playwright Chromium now supports Document-Isolation-Policy, so the
pipeline E2E test runs for real instead of skipping. The previous asset
was the 50x50 phpunit fixture, smaller than every registered sub-size,
so the pipeline correctly generated zero thumbnails and the
sideload-count assertion failed. Swap in the 640x480 canola.jpg fixture
so thumbnail and medium sub-sizes are generated and sideloaded, and
update the skip comments that claimed Playwright lacks DIP support.

See #65661
The suites gated the isolation callback and enqueue but never proved the
callback is actually wired to load-upload.php, that the inline settings
match what the server computes, or that the pipeline's output is real:

* Assert the default-filters.php hook registration, without which none
  of the gating logic runs.
* Assert the inline settings are exactly the JSON encoding of
  wp_get_media_library_upload_settings(), and that the upload_mimes,
  image_strip_meta, and image_max_bit_depth filters flow through to the
  settings the browser pipeline consumes.
* Extend the E2E happy path past request counting: the finalized
  attachment must carry thumbnail and medium sub-sizes in its metadata,
  and the sideloaded thumbnail file must actually be servable.
Interrupting a client-side pipeline upload is worse than interrupting a
classic plupload one: classic uploads complete server-side once the bytes
arrive, but an interrupted pipeline upload loses browser-generated
thumbnails that were not sideloaded yet and leaves the attachment
unfinalized. Trigger the browser's leave confirmation while the progress
map is non-empty; the guard is scoped to the pipeline, so classic uploads
behave exactly as before.

See #65662
…eline

media-new.php was the last admin upload surface without pipeline
integration: plupload-handlers creates a raw plupload.Uploader (wp.Uploader
never loads there) posting to async-upload.php with all image processing
server-side.

Extend cross-origin isolation to the screen via a new
wp_set_up_media_new_cross_origin_isolation() on load-media-new.php, gated
on client-side processing being enabled and the upload_files capability
(the screen itself already requires it). Add a new media-new-upload admin
script that binds a higher-priority FilesAdded handler on the
plupload-handlers uploader instance and routes files through the
@wordpress/upload-media store, sharing its settings with the grid
integration via wp_get_media_library_upload_settings().

The screen's existing UI helpers are reused rather than replicated:
fileQueued() builds the progress item, uploadSuccess() renders the
finished attachment row through the existing async-upload.php markup
endpoint, itemAjaxError() surfaces per-file errors, and uploadComplete()
runs when the queue drains, so the screen looks and behaves unchanged.
The same beforeunload guard as the grid warns while pipeline uploads are
in flight. When the browser is not cross-origin isolated or lacks
client-side support the script no-ops and the classic plupload flow (and
the browser-uploader HTML fallback form) keep working unchanged.

See #65662
Assert the Document-Isolation-Policy header on media-new.php, the
happy-path pipeline upload (create, sideload, and finalize via REST with
no file upload through async-upload.php; the fetch=3 markup POST is
expected and excluded), and the disallowed-file-type error path.

CI's Playwright Chromium supports Document-Isolation-Policy, so the full
pipeline is exercised there; the upload assertions still skip in browsers
where isolation is unavailable, and the existing media-upload spec keeps
covering the classic path as the degradation check.

See #65662
The test asserts that a failed SVG upload on media-new.php shows a
dismissible error. With the client-side pipeline active (CI's Chromium is
cross-origin isolated), the disallowed file is rejected client-side and
the error renders through the standard per-file error UI, where the
dismiss control is a link, instead of the server-rendered
async-upload.php notice, where it is a button. Target the .dismiss
control by class so both variants pass; the error text and the dismissal
behavior asserted are unchanged.

See #65662
…oreunload guards

The beforeunload guards from this branch had no test coverage at all,
and the media-new.php suites had the same blind spots just closed for
the grid on the base branch:

* Assert the load-media-new.php hook registration in
  default-filters.php and that the inline settings are exactly the JSON
  encoding of wp_get_media_library_upload_settings().
* Add an E2E test per screen for the beforeunload guard: hold sideload
  requests via routing so the upload is deterministically in flight,
  then dispatch a synthetic cancelable beforeunload and assert it is
  prevented while uploading and no longer prevented after completion.
* Extend the media-new.php E2E happy path past request counting: the
  finalized attachment must carry thumbnail and medium sub-sizes and
  the sideloaded thumbnail file must actually be servable.
@adamsilverstein adamsilverstein changed the title Media: Enable client-side media uploads in the Media Library grid Media: Enable client-side media uploads in the Media Library grid and media-new.php Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant