diff --git a/Gruntfile.js b/Gruntfile.js
index 139cb6fb5b286..2570e99d41351 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -526,6 +526,9 @@ module.exports = function(grunt) {
[ WORKING_DIR + 'wp-admin/js/language-chooser.js' ]: [ './src/js/_enqueues/lib/language-chooser.js' ],
[ WORKING_DIR + 'wp-admin/js/link.js' ]: [ './src/js/_enqueues/admin/link.js' ],
[ WORKING_DIR + 'wp-admin/js/media-gallery.js' ]: [ './src/js/_enqueues/deprecated/media-gallery.js' ],
+ [ WORKING_DIR + 'wp-admin/js/media-library-upload.js' ]: [ './src/js/_enqueues/admin/media-library-upload.js' ],
+ [ WORKING_DIR + 'wp-admin/js/media-new-upload.js' ]: [ './src/js/_enqueues/admin/media-new-upload.js' ],
+ [ WORKING_DIR + 'wp-admin/js/media-upload-pipeline.js' ]: [ './src/js/_enqueues/admin/media-upload-pipeline.js' ],
[ WORKING_DIR + 'wp-admin/js/media-upload.js' ]: [ './src/js/_enqueues/admin/media-upload.js' ],
[ WORKING_DIR + 'wp-admin/js/media.js' ]: [ './src/js/_enqueues/admin/media.js' ],
[ WORKING_DIR + 'wp-admin/js/nav-menu.js' ]: [ './src/js/_enqueues/lib/nav-menu.js' ],
@@ -1280,6 +1283,9 @@ module.exports = function(grunt) {
'src/wp-admin/js/language-chooser.js': 'src/js/_enqueues/lib/language-chooser.js',
'src/wp-admin/js/link.js': 'src/js/_enqueues/admin/link.js',
'src/wp-admin/js/media-gallery.js': 'src/js/_enqueues/deprecated/media-gallery.js',
+ 'src/wp-admin/js/media-library-upload.js': 'src/js/_enqueues/admin/media-library-upload.js',
+ 'src/wp-admin/js/media-new-upload.js': 'src/js/_enqueues/admin/media-new-upload.js',
+ 'src/wp-admin/js/media-upload-pipeline.js': 'src/js/_enqueues/admin/media-upload-pipeline.js',
'src/wp-admin/js/media-upload.js': 'src/js/_enqueues/admin/media-upload.js',
'src/wp-admin/js/media.js': 'src/js/_enqueues/admin/media.js',
'src/wp-admin/js/nav-menu.js': 'src/js/_enqueues/lib/nav-menu.js',
diff --git a/src/js/_enqueues/admin/media-library-upload.js b/src/js/_enqueues/admin/media-library-upload.js
new file mode 100644
index 0000000000000..286e1184708bd
--- /dev/null
+++ b/src/js/_enqueues/admin/media-library-upload.js
@@ -0,0 +1,303 @@
+/**
+ * Routes Media Library grid uploads through the client-side media pipeline.
+ *
+ * On wp-admin/upload.php (grid mode) WordPress uploads via wp.Uploader /
+ * plupload to async-upload.php. When the browser is cross-origin isolated
+ * and supports the client-side pipeline, this script intercepts the
+ * uploader's FilesAdded handler and hands files to wp.mediaUploadPipeline
+ * (media-upload-pipeline.js) instead: the original image is uploaded via
+ * the REST API and thumbnails are generated in the browser (wasm-vips),
+ * then sideloaded and finalized.
+ *
+ * The grid's own UI is reused: the same placeholder tiles, progress bars,
+ * "Uploading n/m" status, and error sidebar that wp-plupload.js drives.
+ *
+ * When client-side support is unavailable the script cleanly no-ops and
+ * the classic plupload flow is left untouched.
+ *
+ * @output wp-admin/js/media-library-upload.js
+ */
+
+/* global plupload */
+
+/**
+ * The parts of a wp.Uploader instance this script relies on.
+ *
+ * @typedef {Object} WPUploader
+ * @property {plupload.Uploader} uploader The plupload uploader it wraps.
+ * @property {( model: WPAttachment ) => void} added Runs when a file is queued.
+ * @property {( model: WPAttachment ) => void} success Runs when an upload finished.
+ * @property {( message: string, data: Object, file: { name: string } ) => void} error Runs when an upload failed.
+ */
+
+/**
+ * The parts of a wp.media.model.Attachment (a Backbone model) this script
+ * relies on.
+ *
+ * @typedef {Object} WPAttachment
+ * @property {( key: string ) => unknown} get Reads a model attribute.
+ * @property {( attributes: Object, options?: Object ) => void} set Sets model attributes.
+ * @property {( key: string, options?: Object ) => void} unset Removes a model attribute.
+ * @property {() => JQuery.jqXHR} fetch Refetches the attachment from the REST API.
+ * @property {() => void} destroy Removes the model and its tile.
+ */
+
+/**
+ * The placeholder attributes wp-plupload.js builds for an uploading tile.
+ *
+ * @typedef {Object} PlaceholderAttributes
+ * @property {plupload.File} file The file being uploaded.
+ * @property {boolean} uploading Always true while the upload runs.
+ * @property {Date} date When the upload started.
+ * @property {string} filename The file name.
+ * @property {number} menuOrder Menu order of the attachment.
+ * @property {number} uploadedTo The post the upload is attached to.
+ * @property {number} loaded Bytes uploaded so far.
+ * @property {number} size Size of the file in bytes.
+ * @property {number} percent Progress percentage.
+ * @property {string} [type] Mime type guessed from the file name.
+ * @property {string} [subtype] Mime subtype guessed from the file name.
+ */
+
+/*
+ * PipelineAttachment and UploadError are declared by media-upload-pipeline.js,
+ * which this script depends on.
+ */
+
+( function () {
+ // Guard against double execution (e.g. duplicate enqueues).
+ if ( window.__wpMediaLibraryUpload ) {
+ return;
+ }
+
+ const pipeline = window.wp && wp.mediaUploadPipeline;
+
+ // Bail unless the browser actually supports client-side processing. This
+ // is the clean no-op: when the isolation headers did not land, classic
+ // plupload keeps handling uploads.
+ if (
+ ! pipeline ||
+ typeof plupload === 'undefined' ||
+ ! wp.Uploader ||
+ ! wp.media ||
+ ! pipeline.configure()
+ ) {
+ return;
+ }
+
+ window.__wpMediaLibraryUpload = true;
+
+ /**
+ * Resets the upload queue once every attachment has finished uploading.
+ *
+ * Parity with wp-plupload.js so browse mode flips back when done.
+ */
+ function maybeResetQueue() {
+ const complete = wp.Uploader.queue.all( function (
+ /** @type {WPAttachment} */ attachment
+ ) {
+ return ! attachment.get( 'uploading' );
+ } );
+
+ if ( complete ) {
+ wp.Uploader.queue.reset();
+ }
+ }
+
+ /**
+ * Handles a completed upload by syncing the grid tile with the server data.
+ *
+ * @param {WPUploader} wpUploader The wp.Uploader instance that queued the file.
+ * @param {WPAttachment} model The placeholder Attachment model.
+ * @param {PipelineAttachment} attachment The finalized attachment from the pipeline.
+ */
+ function handleSuccess( wpUploader, model, attachment ) {
+ model.set( { id: attachment.id }, { silent: true } );
+
+ // Register the model in Attachments.all (parity with wp-plupload.js).
+ wp.media.model.Attachment.get( attachment.id, model );
+
+ model
+ .fetch()
+ .done( function () {
+ [ 'file', 'loaded', 'size', 'percent' ].forEach( function (
+ key
+ ) {
+ model.unset( key, { silent: true } );
+ } );
+ model.set( { uploading: false } );
+ } )
+ .fail( function () {
+ // Fetch failed, but the upload succeeded: clear the uploading
+ // state with what the pipeline gave us so no tile is stuck.
+ [ 'file', 'loaded', 'size', 'percent' ].forEach( function (
+ key
+ ) {
+ model.unset( key, { silent: true } );
+ } );
+ model.set( attachment );
+ model.set( { uploading: false } );
+ } )
+ .always( function () {
+ maybeResetQueue();
+
+ // Parity with wp-plupload.js, which exposes this callback so
+ // other code can react to a finished upload.
+ wpUploader.success( model );
+ } );
+ }
+
+ /**
+ * Handles an upload error by removing the tile and surfacing the message.
+ *
+ * The error goes into wp.Uploader.errors exactly like a classic upload
+ * error, so the grid's error sidebar renders it, announces it, and
+ * moves focus to its Dismiss button.
+ *
+ * @param {WPUploader} wpUploader The wp.Uploader instance that queued the file.
+ * @param {WPAttachment} model The placeholder Attachment model.
+ * @param {UploadError} error The upload error.
+ * @param {File} nativeFile The original file (for the error label).
+ */
+ function handleError( wpUploader, model, error, nativeFile ) {
+ const message = pipeline.getErrorText( error, nativeFile.name );
+ const file = { name: nativeFile.name };
+
+ model.destroy();
+
+ wp.Uploader.errors.unshift( {
+ message: message,
+ data: {},
+ file: file,
+ } );
+
+ maybeResetQueue();
+
+ // Parity with wp-plupload.js, which exposes this callback so other
+ // code can react to a failed upload.
+ wpUploader.error( message, {}, file );
+ }
+
+ /**
+ * Intercepts files added to a plupload uploader.
+ *
+ * Returns undefined (not false) when the pipeline cannot take the batch
+ * so the built-in handler runs and uploads server-side - a degradation,
+ * never data loss. Otherwise builds the same placeholder tiles as
+ * wp-plupload, routes each file through the pipeline, and returns false
+ * to suppress the built-in handler.
+ *
+ * @param {WPUploader} wpUploader The wp.Uploader instance.
+ * @param {plupload.Uploader} up The plupload uploader instance.
+ * @param {plupload.File[]} files Files added to the queue.
+ * @return {boolean|undefined} False to suppress the built-in handler.
+ */
+ function handleFilesAdded( wpUploader, up, files ) {
+ if ( ! pipeline.isReady() || ! pipeline.canHandleBatch( files ) ) {
+ return;
+ }
+
+ // The classic flow posts plupload's multipart params to
+ // async-upload.php; forward them so plugins reading $_POST see the
+ // same fields, with `post_id` spelled `post` for the REST API.
+ const params = ( up.settings && up.settings.multipart_params ) || {};
+ const additionalData = pipeline.additionalDataFromParams(
+ params,
+ parseInt( params.post_id, 10 ) || 0
+ );
+
+ files.forEach( function ( file ) {
+ // Ignore failed uploads.
+ if ( plupload.FAILED === file.status ) {
+ return;
+ }
+
+ // Build the same placeholder attributes as wp-plupload.js so the
+ // grid's progress tiles and "Uploading n/m" status work unchanged.
+ /** @type {PlaceholderAttributes} */
+ const attributes = {
+ file: file,
+ uploading: true,
+ date: new Date(),
+ filename: file.name,
+ menuOrder: 0,
+ uploadedTo: wp.media.model.settings.post.id,
+ loaded: file.loaded,
+ size: file.size,
+ percent: file.percent,
+ };
+
+ /*
+ * Early mime type scanning for images, as wp-plupload.js does,
+ * extended with the formats the client-side pipeline accepts.
+ */
+ const image = /(?:jpe?g|png|gif|webp|avif|heic|heif)$/i.exec(
+ file.name
+ );
+ if ( image ) {
+ attributes.type = 'image';
+ // `jpg` is not a valid subtype, so map it to `jpeg`.
+ attributes.subtype =
+ 'jpg' === image[ 0 ].toLowerCase()
+ ? 'jpeg'
+ : image[ 0 ].toLowerCase();
+ }
+
+ const model = wp.media.model.Attachment.create( attributes );
+ wp.Uploader.queue.add( model );
+ wpUploader.added( model );
+
+ // canHandleBatch() already established that every file has one.
+ const nativeFile = /** @type {File} */ ( file.getNative() );
+
+ // Remove the file from plupload so it is not uploaded twice.
+ up.removeFile( file );
+
+ pipeline.queueFile( nativeFile, additionalData, {
+ onSuccess: function (
+ /** @type {PipelineAttachment} */ attachment
+ ) {
+ handleSuccess( wpUploader, model, attachment );
+ },
+ onError: function ( /** @type {UploadError} */ error ) {
+ handleError( wpUploader, model, error, nativeFile );
+ },
+ onProgress: function ( /** @type {number} */ percent ) {
+ model.set( { percent: percent } );
+ },
+ } );
+ } );
+
+ up.refresh();
+
+ return false;
+ }
+
+ // Wrap wp.Uploader.prototype.init (an empty stub called once per instance
+ // after plupload is initialized) to bind a higher-priority FilesAdded
+ // handler on every uploader instance, including the Media Library grid's.
+ const originalInit = wp.Uploader.prototype.init;
+ wp.Uploader.prototype.init = function () {
+ originalInit.apply( this, arguments );
+
+ const wpUploader = /** @type {WPUploader} */ ( this );
+ const up = /** @type {plupload.Uploader|undefined} */ ( this.uploader );
+
+ if ( ! up || up.__wpMediaLibraryUploadBound ) {
+ return;
+ }
+ up.__wpMediaLibraryUploadBound = true;
+
+ // plupload sorts handlers by priority (descending) and a `false`
+ // return breaks the chain, so priority 100 runs before and suppresses
+ // the built-in FilesAdded handler.
+ up.bind(
+ 'FilesAdded',
+ function ( uploader, files ) {
+ return handleFilesAdded( wpUploader, uploader, files );
+ },
+ this,
+ 100
+ );
+ };
+} )();
diff --git a/src/js/_enqueues/admin/media-new-upload.js b/src/js/_enqueues/admin/media-new-upload.js
new file mode 100644
index 0000000000000..60643dfc7a49b
--- /dev/null
+++ b/src/js/_enqueues/admin/media-new-upload.js
@@ -0,0 +1,284 @@
+/**
+ * Routes "Add New Media File" uploads through the client-side media pipeline.
+ *
+ * On wp-admin/media-new.php WordPress uploads via a raw plupload.Uploader
+ * (created by plupload-handlers) posting to async-upload.php. When the
+ * browser is cross-origin isolated and supports the client-side pipeline,
+ * this script intercepts the uploader's FilesAdded handler and hands files
+ * to wp.mediaUploadPipeline (media-upload-pipeline.js) instead: the
+ * original image is uploaded via the REST API and thumbnails are generated
+ * in the browser (wasm-vips), then sideloaded and finalized.
+ *
+ * The screen's existing UI helpers from plupload-handlers are reused:
+ * fileQueued() builds the progress item, uploadSuccess() renders the
+ * finished attachment row (via the async-upload.php markup endpoint), and
+ * uploadComplete() runs when the queue drains. Failed uploads render the
+ * same notice async-upload.php returns for a server-side failure, so the
+ * screen looks and behaves unchanged.
+ *
+ * When client-side support is unavailable the script cleanly no-ops and
+ * the classic plupload flow is left untouched.
+ *
+ * @output wp-admin/js/media-new-upload.js
+ */
+
+/* global plupload, pluploadL10n, uploader, fileQueued, uploadStart, uploadSuccess, uploadComplete */
+
+( function () {
+ // Guard against double execution (e.g. duplicate enqueues).
+ if ( window.__wpMediaNewUpload ) {
+ return;
+ }
+
+ const pipeline = window.wp && wp.mediaUploadPipeline;
+
+ // Bail unless the browser actually supports client-side processing. This
+ // is the clean no-op: when the isolation headers did not land, classic
+ // plupload keeps handling uploads.
+ if (
+ ! pipeline ||
+ typeof plupload === 'undefined' ||
+ typeof jQuery === 'undefined' ||
+ ! wp.a11y ||
+ ! pipeline.configure()
+ ) {
+ return;
+ }
+
+ window.__wpMediaNewUpload = true;
+
+ const __ = wp.i18n.__;
+ const sprintf = wp.i18n.sprintf;
+
+ // Number of pipeline uploads currently in flight, used to fire
+ // uploadComplete() when the queue drains.
+ let inFlightCount = 0;
+
+ /**
+ * Marks one pipeline upload as finished, firing uploadComplete() when
+ * the queue drains.
+ *
+ * The built-in UploadComplete binding never fires for pipeline uploads
+ * because every file is removed from plupload before its queue starts.
+ */
+ function finishUpload() {
+ inFlightCount--;
+ if ( inFlightCount === 0 ) {
+ uploadComplete();
+ }
+ }
+
+ /**
+ * Returns the post the screen attaches uploads to, or 0.
+ *
+ * media-new.php validates `post_id` (the post must exist and be
+ * editable by the user) before printing it into the form, whereas the
+ * raw request value is what plupload carries in its multipart params.
+ * async-upload.php re-validates that raw value and silently uploads
+ * unattached; the REST API rejects it instead, so read the validated one.
+ *
+ * @return {number} The parent post ID, or 0.
+ */
+ function getParentPostId() {
+ const input = /** @type {HTMLInputElement|null} */ (
+ document.getElementById( 'post_id' )
+ );
+ const postId = input ? parseInt( input.value, 10 ) : 0;
+ return postId > 0 ? postId : 0;
+ }
+
+ /**
+ * Escapes text for insertion into HTML.
+ *
+ * @param {string} text The text to escape.
+ * @return {string} The escaped text.
+ */
+ function escapeHtml( text ) {
+ return jQuery( '
' ).text( text ).html();
+ }
+
+ /**
+ * Renders a failed upload the way async-upload.php does for a
+ * server-side failure: an error notice with a real Dismiss button
+ * described by the notice, a screen reader announcement, and focus
+ * returned to the browse button once dismissed.
+ *
+ * @param {plupload.File} file The plupload file that failed.
+ * @param {string} message The reason the upload failed.
+ */
+ function renderError( file, message ) {
+ const item = jQuery( '#media-item-' + file.id );
+ const buttonId = 'dismiss-' + file.id;
+ const descriptionId = 'error-description-' + file.id;
+
+ const button = jQuery( '
', {
+ type: 'button',
+ id: buttonId,
+ 'class': 'dismiss button-link',
+ 'aria-describedby': descriptionId,
+ text: pluploadL10n.dismiss,
+ } );
+
+ const notice = jQuery( '', {
+ id: descriptionId,
+ 'class': 'notice notice-error error-div error',
+ } )
+ .append( button )
+ .append( ' ' )
+ .append(
+ jQuery( '
' ).html(
+ pluploadL10n.error_uploading.replace(
+ '%s',
+ escapeHtml( file.name )
+ )
+ )
+ )
+ .append( ' ' )
+ .append( document.createTextNode( message ) );
+
+ item.empty().append( notice ).data( 'last-err', file.id );
+
+ setTimeout( function () {
+ wp.a11y.speak(
+ sprintf(
+ /* translators: %s: Name of the file that failed to upload. */
+ __( '%s has failed to upload.' ),
+ file.name
+ )
+ );
+ }, 1500 );
+
+ button.on( 'click', function () {
+ jQuery( this )
+ .parents( 'div.media-item' )
+ .slideUp( 200, function () {
+ jQuery( this ).remove();
+ wp.a11y.speak( __( 'Error dismissed.' ) );
+ jQuery( '#plupload-browse-button' ).trigger( 'focus' );
+ } );
+ } );
+ }
+
+ /**
+ * Reflects pipeline progress onto the screen's progress bar for a file.
+ *
+ * The bar is 200px wide at 100%, matching uploadProgress() in
+ * plupload-handlers.
+ *
+ * @param {plupload.File} file The plupload file being uploaded.
+ * @param {number} percent Progress percentage.
+ */
+ function renderProgress( file, percent ) {
+ const item = jQuery( '#media-item-' + file.id );
+ item.find( '.bar' ).width( 2 * percent );
+ item.find( '.percent' ).html( percent + '%' );
+ }
+
+ /**
+ * Intercepts files added to the plupload uploader.
+ *
+ * Returns undefined (not false) when the pipeline cannot take the batch
+ * so the built-in handler runs and uploads server-side - a degradation,
+ * never data loss. Otherwise builds the screen's progress items, routes
+ * each file through the pipeline, and returns false to suppress the
+ * built-in handler (which would otherwise queue and start a classic
+ * upload).
+ *
+ * @param {plupload.Uploader} up The plupload uploader instance.
+ * @param {plupload.File[]} files Files added to the queue.
+ * @return {boolean|undefined} False to suppress the built-in handler.
+ */
+ function handleFilesAdded( up, files ) {
+ if ( ! pipeline.isReady() || ! pipeline.canHandleBatch( files ) ) {
+ return;
+ }
+
+ // Parity with the built-in handler: clear stale queue errors and run
+ // the shared upload-start housekeeping.
+ jQuery( '#media-upload-error' ).empty();
+ uploadStart();
+
+ // The classic flow posts plupload's multipart params to
+ // async-upload.php; forward them so plugins reading $_POST see the
+ // same fields. Without the parent post a file uploaded from
+ // media-new.php?post_id=N would land unattached even though the
+ // screen counts it against that post.
+ const params = ( up.settings && up.settings.multipart_params ) || {};
+ const additionalData = pipeline.additionalDataFromParams(
+ params,
+ getParentPostId()
+ );
+
+ files.forEach( function ( file ) {
+ // Ignore failed uploads.
+ if ( plupload.FAILED === file.status ) {
+ return;
+ }
+
+ // Build the screen's progress item for this file.
+ fileQueued( file );
+
+ // canHandleBatch() already established that every file has one.
+ const nativeFile = /** @type {File} */ ( file.getNative() );
+
+ // Remove the file from plupload so it is not uploaded twice.
+ up.removeFile( file );
+
+ inFlightCount++;
+
+ pipeline.queueFile( nativeFile, additionalData, {
+ onSuccess: function (
+ /** @type {PipelineAttachment} */ attachment
+ ) {
+ // uploadSuccess() renders the finished attachment row via
+ // the existing async-upload.php markup endpoint; the
+ // server normally returns the ID as a string.
+ uploadSuccess( file, String( attachment.id ) );
+ finishUpload();
+ },
+ onError: function ( /** @type {UploadError} */ error ) {
+ renderError(
+ file,
+ pipeline.getErrorText( error, nativeFile.name )
+ );
+ finishUpload();
+ },
+ onProgress: function ( /** @type {number} */ percent ) {
+ renderProgress( file, percent );
+ },
+ } );
+ } );
+
+ up.refresh();
+
+ return false;
+ }
+
+ jQuery( function () {
+ // plupload-handlers creates the global `uploader` in its own ready
+ // callback, which runs before this one: ready callbacks run in
+ // registration order and this script loads after plupload-handlers.
+ // The global stays undefined when wpUploaderInit is missing (the
+ // html-uploader fallback), in which case there is nothing to bind.
+ if (
+ typeof uploader !== 'object' ||
+ ! uploader ||
+ uploader.__wpMediaNewUploadBound
+ ) {
+ return;
+ }
+ uploader.__wpMediaNewUploadBound = true;
+
+ // plupload sorts handlers by priority (descending) and a `false`
+ // return breaks the chain, so priority 100 runs before and suppresses
+ // the built-in FilesAdded handler.
+ uploader.bind(
+ 'FilesAdded',
+ function ( up, files ) {
+ return handleFilesAdded( up, files );
+ },
+ null,
+ 100
+ );
+ } );
+} )();
diff --git a/src/js/_enqueues/admin/media-upload-pipeline.js b/src/js/_enqueues/admin/media-upload-pipeline.js
new file mode 100644
index 0000000000000..4890e28148db7
--- /dev/null
+++ b/src/js/_enqueues/admin/media-upload-pipeline.js
@@ -0,0 +1,626 @@
+/**
+ * Shared glue for routing classic admin uploads through the client-side
+ * media pipeline.
+ *
+ * The Media Library grid (media-library-upload.js) and the Add New Media
+ * File screen (media-new-upload.js) both intercept plupload and hand files
+ * to @wordpress/upload-media instead. Everything that is not specific to
+ * one screen's UI lives here: feature detection, configuring the
+ * upload-media store once, the REST sideload/finalize/delete helpers the
+ * store needs, queueing a file with success, error, and progress
+ * callbacks, the error text shown for a failed upload, and the guard that
+ * warns before leaving while uploads are in flight.
+ *
+ * Exposed as wp.mediaUploadPipeline.
+ *
+ * @output wp-admin/js/media-upload-pipeline.js
+ */
+
+/* global plupload */
+
+/**
+ * An item in the @wordpress/upload-media queue.
+ *
+ * @typedef {Object} QueueItem
+ * @property {string} id Queue item ID.
+ * @property {string} [parentId] Set on the sub-size children of an upload.
+ * @property {File} [sourceFile] The file being processed.
+ * @property {number} [progress] Progress percentage, when the store reports one.
+ * @property {string} [currentOperation] The operation being run.
+ * @property {unknown[]} [operations] The operations left to run.
+ * @property {unknown[]} [subSizes] The sub-sizes sideloaded so far.
+ */
+
+/**
+ * The pipeline's bookkeeping for one queued upload.
+ *
+ * @typedef {Object} UploadEntry
+ * @property {string} key Identity key of the file.
+ * @property {string|null} itemId ID of the queue item it matched.
+ * @property {( percent: number ) => void} [onProgress] Progress callback.
+ * @property {number} lastPercent Last reported percentage.
+ * @property {{ total: number, remaining: number }|null} totals Operation counts.
+ * @property {boolean} released Whether it finished.
+ */
+
+/**
+ * A finalized attachment as returned by the client-side pipeline.
+ *
+ * @typedef {Object} PipelineAttachment
+ * @property {number} id The attachment ID.
+ */
+
+/**
+ * Why an upload failed.
+ *
+ * A rejected apiFetch is not always an Error: a REST failure arrives as a
+ * plain { code, message } object.
+ *
+ * @typedef {Object} UploadError
+ * @property {string} [code] Error code, when the pipeline supplied one.
+ * @property {string} [message] Human-readable reason.
+ */
+
+window.wp = window.wp || {};
+
+( function ( wp ) {
+ const __ = wp.i18n.__;
+ const settings = window._wpMediaUploadPipelineSettings || {};
+ /** @type {any} */
+ let uploadStore;
+ let configured = false;
+
+ // Number of queued files that have not succeeded or failed yet.
+ let inFlight = 0;
+
+ // Uploads waiting to be matched to a queue item, keyed by file identity
+ // (concurrent uploads of an identical file share a key and are matched
+ // in order), and uploads already matched, keyed by queue item id. Items
+ // are matched by id from then on because the store swaps `sourceFile`
+ // for HEIC files once they are converted to JPEG.
+ const pending = new Map();
+ const active = new Map();
+
+ const imageSizeCount = Object.keys( settings.allImageSizes || {} ).length;
+
+ /**
+ * Builds a stable identity key for a File.
+ *
+ * The queue item's `sourceFile` is a clone of the original file, so it
+ * cannot be matched by reference. The clone preserves name, size, and
+ * last-modified time, which together identify a file within one session.
+ *
+ * @param {File} file The file to key.
+ * @return {string} Identity key.
+ */
+ function fileKey( file ) {
+ return file.name + '::' + file.size + '::' + file.lastModified;
+ }
+
+ /**
+ * Recursively appends data to a FormData object, supporting nested objects.
+ *
+ * Mirrors flattenFormData() in the media-utils package.
+ *
+ * @param {FormData} formData The form data to append to.
+ * @param {string} key The key to append under.
+ * @param {*} data The value to append.
+ */
+ function flattenFormData( formData, key, data ) {
+ if (
+ data !== null &&
+ typeof data === 'object' &&
+ Object.getPrototypeOf( data ) === Object.prototype
+ ) {
+ Object.keys( data ).forEach( function ( name ) {
+ flattenFormData( formData, key + '[' + name + ']', data[ name ] );
+ } );
+ } else if ( data !== undefined ) {
+ formData.append( key, String( data ) );
+ }
+ }
+
+ /**
+ * Sideloads a client-generated thumbnail to an existing attachment.
+ *
+ * Reimplements the private sideloadMedia() helper from the media-utils
+ * package as a thin apiFetch wrapper.
+ *
+ * @param {Object} args The sideload arguments.
+ * @param {File} args.file The sub-size to sideload.
+ * @param {number} args.attachmentId The attachment to sideload it to.
+ * @param {Record} [args.additionalData] Extra fields to send with it.
+ * @param {AbortSignal} [args.signal] Signal aborting the request.
+ * @param {( subSize: Object ) => void} [args.onSuccess] Called with the sideloaded sub-size.
+ * @param {( error: Error ) => void} [args.onError] Called when the sideload failed.
+ */
+ function mediaSideload( args ) {
+ const file = args.file;
+ const additionalData = args.additionalData || {};
+
+ const data = new FormData();
+ data.append( 'file', file, file.name || file.type.replace( '/', '.' ) );
+ Object.keys( additionalData ).forEach( function ( key ) {
+ flattenFormData( data, key, additionalData[ key ] );
+ } );
+
+ wp.apiFetch( {
+ path: '/wp/v2/media/' + args.attachmentId + '/sideload',
+ body: data,
+ method: 'POST',
+ signal: args.signal,
+ } )
+ .then( function ( /** @type {Object} */ subSize ) {
+ if ( args.onSuccess ) {
+ args.onSuccess( subSize );
+ }
+ } )
+ .catch( function ( /** @type {unknown} */ error ) {
+ if ( args.onError ) {
+ let normalized = error;
+ if ( ! ( error instanceof Error ) ) {
+ const rest = /** @type {UploadError} */ ( error );
+ normalized = new Error(
+ rest && rest.message ? rest.message : String( error )
+ );
+ }
+ args.onError( /** @type {Error} */ ( normalized ) );
+ }
+ } );
+ }
+
+ /**
+ * Finalizes an upload once all client-side processing is complete.
+ *
+ * Reimplements the private mediaFinalize() helper. The returned
+ * attachment is load-bearing: it carries the post-finalize (scaled)
+ * URL used for srcset.
+ *
+ * @param {number} id The parent attachment ID.
+ * @param {Object[]} subSizes Accumulated sub-size data.
+ * @return {Promise} Resolves with the transformed attachment.
+ */
+ function mediaFinalize( id, subSizes ) {
+ return wp
+ .apiFetch( {
+ path: '/wp/v2/media/' + id + '/finalize',
+ method: 'POST',
+ data: { sub_sizes: subSizes || [] },
+ } )
+ .then( function ( /** @type {Object|undefined} */ response ) {
+ if ( ! response ) {
+ return undefined;
+ }
+ return wp.mediaUtils.transformAttachment( response );
+ } );
+ }
+
+ /**
+ * Deletes an attachment whose client-side processing failed outright.
+ *
+ * The queue calls this when every sub-size sideload for an upload fails:
+ * without it the original file is left behind as an attachment with no
+ * metadata, visible in the Media Library after the next page load. The
+ * block editor passes the same setting.
+ *
+ * @param {number} id The attachment ID to delete.
+ * @return {Promise} Resolves once the attachment is deleted.
+ */
+ function mediaDelete( id ) {
+ return wp.apiFetch( {
+ path: '/wp/v2/media/' + id + '?force=true',
+ method: 'DELETE',
+ } );
+ }
+
+ /**
+ * Whether every script the pipeline relies on is present.
+ *
+ * @return {boolean} True when the dependencies loaded.
+ */
+ function hasDependencies() {
+ return Boolean(
+ typeof plupload !== 'undefined' &&
+ wp.uploadMedia &&
+ wp.mediaUtils &&
+ wp.data &&
+ wp.element &&
+ wp.apiFetch
+ );
+ }
+
+ /**
+ * Whether the browser can run the client-side pipeline on this page.
+ *
+ * False when the isolation headers did not land (the page is not
+ * cross-origin isolated) or the browser lacks the required features,
+ * in which case classic plupload keeps handling uploads.
+ *
+ * @return {boolean} True when client-side processing is available.
+ */
+ function isSupported() {
+ return Boolean(
+ hasDependencies() &&
+ wp.uploadMedia.detectClientSideMediaSupport &&
+ wp.uploadMedia.detectClientSideMediaSupport().supported
+ );
+ }
+
+ /**
+ * Estimates the progress (0-100) of a queue item.
+ *
+ * The pipeline never reports a numeric `progress` on its queue items
+ * (nothing dispatches updateItemProgress), so estimate one from the
+ * item's operation queue instead: each finished operation (prepare,
+ * transcode, upload, thumbnails, finalize) advances the bar, and the
+ * sub-sizes sideloaded so far advance it within thumbnail generation.
+ * `item.progress` is preferred whenever it is present.
+ *
+ * @param {QueueItem} item The upload-media queue item.
+ * @param {UploadEntry} entry The pipeline's bookkeeping for the upload.
+ * @return {number} Estimated progress.
+ */
+ function estimateProgress( item, entry ) {
+ if ( typeof item.progress === 'number' ) {
+ return item.progress;
+ }
+
+ const remaining = item.operations ? item.operations.length : 0;
+ let totals = entry.totals;
+ if ( ! totals ) {
+ totals = { total: remaining, remaining: remaining };
+ entry.totals = totals;
+ }
+ // Operations are appended after preparation, so grow the total.
+ if ( remaining > totals.remaining ) {
+ totals.total += remaining - totals.remaining;
+ }
+ totals.remaining = remaining;
+
+ if ( totals.total === 0 ) {
+ return 0;
+ }
+
+ const completed = totals.total - remaining;
+ let fraction = 0;
+ if (
+ 'THUMBNAIL_GENERATION' === item.currentOperation &&
+ imageSizeCount > 0
+ ) {
+ fraction = Math.min(
+ 1,
+ ( item.subSizes || [] ).length / imageSizeCount
+ );
+ }
+
+ return ( ( completed + fraction ) / totals.total ) * 100;
+ }
+
+ /**
+ * Matches queue items to queued uploads and reports their progress.
+ *
+ * Runs on every change to the upload-media store. Sub-size children
+ * carry the parent's file and are skipped; only top-level items drive
+ * the screen's progress UI. Progress holds at 99 until the upload's
+ * success callback has run, so nothing looks finished before the
+ * screen has synced the result.
+ */
+ function onStoreChange() {
+ if ( inFlight === 0 ) {
+ return;
+ }
+
+ const items = wp.data.select( uploadStore ).getItems();
+ items.forEach( function ( /** @type {QueueItem} */ item ) {
+ if ( item.parentId || ! item.sourceFile ) {
+ return;
+ }
+
+ let entry = active.get( item.id );
+ if ( ! entry ) {
+ const key = fileKey( item.sourceFile );
+ const list = pending.get( key );
+ if ( ! list || ! list.length ) {
+ return;
+ }
+ entry = list.shift();
+ if ( ! list.length ) {
+ pending.delete( key );
+ }
+ entry.itemId = item.id;
+ active.set( item.id, entry );
+ }
+
+ if ( ! entry.onProgress ) {
+ return;
+ }
+
+ const percent = Math.min(
+ 99,
+ Math.round( estimateProgress( item, entry ) )
+ );
+ if ( percent !== entry.lastPercent ) {
+ entry.lastPercent = percent;
+ entry.onProgress( percent );
+ }
+ } );
+ }
+
+ /**
+ * Stops tracking an upload once it has succeeded or failed.
+ *
+ * @param {UploadEntry} entry The pipeline's bookkeeping for the upload.
+ */
+ function release( entry ) {
+ if ( entry.released ) {
+ return;
+ }
+ entry.released = true;
+ inFlight--;
+
+ const list = pending.get( entry.key );
+ if ( list ) {
+ const index = list.indexOf( entry );
+ if ( index !== -1 ) {
+ list.splice( index, 1 );
+ }
+ if ( ! list.length ) {
+ pending.delete( entry.key );
+ }
+ }
+
+ if ( entry.itemId ) {
+ active.delete( entry.itemId );
+ }
+ }
+
+ /**
+ * Configures the upload-media store for this page, once.
+ *
+ * Rendering the provider with useSubRegistry: false wires the settings
+ * into the store that wp.data.dispatch/select address (the block editor
+ * does the same).
+ *
+ * @return {boolean} True when the pipeline is configured and usable.
+ */
+ function configure() {
+ if ( configured ) {
+ return true;
+ }
+
+ if ( ! isSupported() ) {
+ return false;
+ }
+
+ configured = true;
+ uploadStore = wp.uploadMedia.store;
+
+ /*
+ * The media-utils package branches on this flag: without it
+ * uploadMedia() creates and revokes a throwaway blob URL per file
+ * and emits an extra onFileChange carrying it. The block editor
+ * sets the same flag before configuring the same pipeline.
+ */
+ window.__clientSideMediaProcessing = true;
+
+ wp.element
+ .createRoot( document.createElement( 'div' ) )
+ .render(
+ wp.element.createElement( wp.uploadMedia.MediaUploadProvider, {
+ settings: {
+ mediaUpload: wp.mediaUtils.uploadMedia,
+ mediaSideload: mediaSideload,
+ mediaFinalize: mediaFinalize,
+ mediaDelete: mediaDelete,
+ maxUploadFileSize: settings.maxUploadFileSize,
+ allowedMimeTypes: settings.allowedMimeTypes,
+ allImageSizes: settings.allImageSizes,
+ bigImageSizeThreshold: settings.bigImageSizeThreshold,
+ imageStripMeta: settings.imageStripMeta,
+ imageMaxBitDepth: settings.imageMaxBitDepth,
+ },
+ useSubRegistry: false,
+ } )
+ );
+
+ // Only the upload-media store can change what this listener reads.
+ wp.data.subscribe( onStoreChange, uploadStore );
+
+ // Warn before leaving while uploads are in flight: thumbnails that
+ // have not been sideloaded yet are lost and the attachment is left
+ // unfinalized, unlike classic uploads that complete server-side
+ // once the bytes arrive.
+ window.addEventListener( 'beforeunload', function ( event ) {
+ if ( inFlight > 0 ) {
+ event.preventDefault();
+ // Some Chromium versions only show the prompt for returnValue.
+ event.returnValue = '';
+ }
+ } );
+
+ return true;
+ }
+
+ /**
+ * Whether the store has received its settings.
+ *
+ * The provider renders asynchronously, so a file added before the
+ * settings land must be left to classic plupload - a degradation,
+ * never data loss.
+ *
+ * @return {boolean} True when the store is ready to accept files.
+ */
+ function isReady() {
+ if ( ! configured ) {
+ return false;
+ }
+ const storeSettings = wp.data.select( uploadStore ).getSettings();
+ return Boolean( storeSettings && storeSettings.mediaUpload );
+ }
+
+ /**
+ * Whether a batch of plupload files can be routed through the pipeline.
+ *
+ * Suppressing plupload's built-in FilesAdded handler is all-or-nothing,
+ * so the whole batch stays on the classic path when any file cannot be
+ * handled: plupload returns no native File for sources it cannot expose
+ * as one (the html4 runtime, say), and audio files are left to the
+ * classic upload so they keep the title and description that
+ * media_handle_upload() derives from their ID3 tags, which the REST
+ * endpoint does not do.
+ *
+ * @param {plupload.File[]} files Files added to the plupload queue.
+ * @return {boolean} True when every file can go through the pipeline.
+ */
+ function canHandleBatch( files ) {
+ return files.every( function ( file ) {
+ if ( plupload.FAILED === file.status ) {
+ return true;
+ }
+ if ( ! file.getNative || ! file.getNative() ) {
+ return false;
+ }
+ return ! /^audio\//i.test( file.type || '' );
+ } );
+ }
+
+ /**
+ * Builds the extra fields to send with an upload from plupload's
+ * multipart params.
+ *
+ * Anything a plugin added through the `plupload_default_params` filter
+ * or wp.Uploader.param() reached the classic upload as $_POST fields,
+ * so it is forwarded to the REST request the same way. The classic
+ * transport's own fields are dropped: `action` and `_wpnonce` belong to
+ * async-upload.php, and `post_id` is spelled `post` by the REST API and
+ * passed separately after the screen has validated it.
+ *
+ * @param {Record} params Plupload's multipart_params.
+ * @param {number} postId The validated post to attach the upload to, or 0.
+ * @return {Record} Additional data for the upload.
+ */
+ function additionalDataFromParams( params, postId ) {
+ /** @type {Record} */
+ const additionalData = {};
+ Object.keys( params || {} ).forEach( function ( key ) {
+ if ( 'action' === key || '_wpnonce' === key || 'post_id' === key ) {
+ return;
+ }
+ additionalData[ key ] = params[ key ];
+ } );
+ if ( postId ) {
+ additionalData.post = postId;
+ }
+ return additionalData;
+ }
+
+ /**
+ * Queues a file for client-side processing and upload.
+ *
+ * @param {File} nativeFile The file to upload.
+ * @param {Record} additionalData Extra fields to send with the attachment.
+ * @param {Object} callbacks Lifecycle callbacks.
+ * @param {( attachment: PipelineAttachment ) => void} [callbacks.onSuccess] Called with the finalized attachment.
+ * @param {( error: UploadError ) => void} [callbacks.onError] Called with the upload error.
+ * @param {( percent: number ) => void} [callbacks.onProgress] Called with an integer percentage (0-99) whenever it changes.
+ */
+ function queueFile( nativeFile, additionalData, callbacks ) {
+ callbacks = callbacks || {};
+
+ const entry = {
+ key: fileKey( nativeFile ),
+ itemId: null,
+ onProgress: callbacks.onProgress,
+ lastPercent: -1,
+ totals: null,
+ released: false,
+ };
+
+ const list = pending.get( entry.key );
+ if ( list ) {
+ list.push( entry );
+ } else {
+ pending.set( entry.key, [ entry ] );
+ }
+ inFlight++;
+
+ wp.data.dispatch( uploadStore ).addItems( {
+ files: [ nativeFile ],
+ additionalData: additionalData || {},
+ onSuccess: function (
+ /** @type {PipelineAttachment[]} */ attachments
+ ) {
+ release( entry );
+ if ( callbacks.onSuccess ) {
+ callbacks.onSuccess( attachments[ 0 ] );
+ }
+ },
+ onError: function ( /** @type {UploadError} */ error ) {
+ release( entry );
+ if ( callbacks.onError ) {
+ callbacks.onError( error );
+ }
+ },
+ } );
+ }
+
+ /**
+ * Builds the display text for a failed upload.
+ *
+ * wp.uploadMedia.getErrorMessage() maps an error *code* and a file name
+ * to a { title, description, action } object, so it can neither be handed
+ * the Error itself nor used as a string. Only codes it actually maps are
+ * worth using: its fallback (and the GENERAL code) says nothing the
+ * error's own message does not, and preferring the message there keeps a
+ * server-supplied reason instead of replacing it with "Please try again."
+ *
+ * @param {UploadError} error The upload error.
+ * @param {string} fileName Name of the file that failed to upload.
+ * @return {string} A human-readable message.
+ */
+ function getErrorText( error, fileName ) {
+ const errorCodes = wp.uploadMedia.ErrorCode || {};
+ const code = error && error.code;
+ let details;
+
+ if (
+ code &&
+ code !== errorCodes.GENERAL &&
+ Object.prototype.hasOwnProperty.call( errorCodes, code ) &&
+ wp.uploadMedia.getErrorMessage
+ ) {
+ details = wp.uploadMedia.getErrorMessage( code, fileName );
+
+ if ( details && details.description ) {
+ return details.action ?
+ details.description + ' ' + details.action :
+ details.description;
+ }
+ }
+
+ return (
+ ( error && error.message ) ||
+ __( 'An error occurred while uploading the file.' )
+ );
+ }
+
+ /**
+ * Whether any queued upload has not finished yet.
+ *
+ * @return {boolean} True while uploads are in flight.
+ */
+ function hasInFlight() {
+ return inFlight > 0;
+ }
+
+ wp.mediaUploadPipeline = {
+ isSupported: isSupported,
+ configure: configure,
+ isReady: isReady,
+ canHandleBatch: canHandleBatch,
+ additionalDataFromParams: additionalDataFromParams,
+ queueFile: queueFile,
+ getErrorText: getErrorText,
+ hasInFlight: hasInFlight,
+ };
+} )( window.wp );
diff --git a/src/wp-admin/media-new.php b/src/wp-admin/media-new.php
index 7be28743808fd..1e4e35c08405d 100644
--- a/src/wp-admin/media-new.php
+++ b/src/wp-admin/media-new.php
@@ -17,6 +17,7 @@
}
wp_enqueue_script( 'plupload-handlers' );
+wp_enqueue_media_new_upload();
$post_id = 0;
if ( isset( $_REQUEST['post_id'] ) ) {
diff --git a/src/wp-admin/upload.php b/src/wp-admin/upload.php
index 7cf0f6fe10108..35ffc3d9e887c 100644
--- a/src/wp-admin/upload.php
+++ b/src/wp-admin/upload.php
@@ -141,6 +141,7 @@
wp_enqueue_media();
wp_enqueue_script( 'media-grid' );
wp_enqueue_script( 'media' );
+ wp_enqueue_media_library_upload();
// Remove the error parameter added by deprecation of wp-admin/media.php.
add_filter(
diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index 12ca0045b98b4..4e3cab772792e 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -700,6 +700,8 @@
add_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' );
add_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' );
add_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' );
+add_action( 'load-upload.php', 'wp_set_up_cross_origin_isolation' );
+add_action( 'load-media-new.php', 'wp_set_up_cross_origin_isolation' );
// Nav menu.
add_filter( 'nav_menu_item_id', '_nav_menu_item_id_use_once', 10, 2 );
add_filter( 'nav_menu_css_class', 'wp_nav_menu_remove_menu_item_has_children_class', 10, 4 );
diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php
index 580ba2cb751ce..3b3ba5a9dc4d9 100644
--- a/src/wp-includes/media.php
+++ b/src/wp-includes/media.php
@@ -6608,9 +6608,7 @@ function wp_set_client_side_media_processing_flag(): void {
wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true;', 'before' );
- $chromium_version = wp_get_chromium_major_version();
-
- if ( null !== $chromium_version && $chromium_version >= 137 ) {
+ if ( wp_is_document_isolation_policy_supported() ) {
wp_add_inline_script( 'wp-block-editor', 'window.__documentIsolationPolicy = true;', 'before' );
}
}
@@ -6635,17 +6633,42 @@ function wp_get_chromium_major_version(): ?int {
}
/**
- * Enables cross-origin isolation in the block editor.
+ * Determines whether the current request's browser honors the
+ * Document-Isolation-Policy header.
+ *
+ * Document-Isolation-Policy is how WordPress makes a page cross-origin
+ * isolated for client-side media processing. Chromium 137+ is the only
+ * engine that implements it; other browsers ignore the header, so the
+ * page never becomes isolated and the client-side pipeline cannot run.
+ *
+ * @since 7.2.0
+ *
+ * @return bool True when the browser is Chromium 137 or newer.
+ */
+function wp_is_document_isolation_policy_supported(): bool {
+ $chromium_version = wp_get_chromium_major_version();
+
+ return null !== $chromium_version && $chromium_version >= 137;
+}
+
+/**
+ * Enables cross-origin isolation on screens that upload media client-side.
*
* Required for enabling SharedArrayBuffer for WebAssembly-based
- * media processing in the editor. Uses Document-Isolation-Policy
+ * media processing in the block editor, the Media Library grid, and
+ * the "Add New Media File" screen. Uses Document-Isolation-Policy
* on supported browsers (Chromium 137+).
*
+ * The Media Library is only isolated in grid mode: list mode has no
+ * client-side pipeline integration, its uploads go through the
+ * "Add New Media File" screen.
+ *
* Skips setup when a third-party page builder overrides the block
* editor via a custom `action` query parameter, as DIP would block
* same-origin iframe access that these editors rely on.
*
* @since 7.1.0
+ * @since 7.2.0 Also isolates the Media Library grid and the "Add New Media File" screen.
*/
function wp_set_up_cross_origin_isolation(): void {
if ( ! wp_is_client_side_media_processing_enabled() ) {
@@ -6658,7 +6681,9 @@ function wp_set_up_cross_origin_isolation(): void {
return;
}
- if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {
+ $is_media_screen = 'media' === $screen->id || ( 'upload' === $screen->id && 'grid' === wp_get_media_library_mode() );
+
+ if ( ! $is_media_screen && ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {
return;
}
@@ -6683,7 +6708,7 @@ function wp_set_up_cross_origin_isolation(): void {
* DIP isolates the document into its own agent cluster,
* which blocks same-origin iframe access that these editors rely on.
*/
- if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) {
+ if ( ! $is_media_screen && isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) {
return;
}
@@ -6695,6 +6720,136 @@ function wp_set_up_cross_origin_isolation(): void {
wp_start_cross_origin_isolation_output_buffer();
}
+/**
+ * Returns the current Media Library mode (grid or list).
+ *
+ * Replicates the mode resolution in wp-admin/upload.php, which runs after
+ * the `load-upload.php` hook, without updating the saved user option.
+ *
+ * upload.php falls back to grid mode for any falsey saved value and renders
+ * grid mode only for the exact value 'grid'. A truthy saved value outside the
+ * two known modes - one a plugin stored, say - therefore renders list mode
+ * there and is not reported as 'grid' here, so callers do not isolate a page
+ * that the Media Library renders in list mode.
+ *
+ * @since 7.2.0
+ *
+ * @return string The Media Library mode, 'grid' when none is saved.
+ */
+function wp_get_media_library_mode(): string {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET['mode'] ) && in_array( $_GET['mode'], array( 'grid', 'list' ), true ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ return $_GET['mode'];
+ }
+
+ $mode = get_user_option( 'media_library_mode', get_current_user_id() );
+
+ if ( ! $mode ) {
+ return 'grid';
+ }
+
+ return is_string( $mode ) ? $mode : 'list';
+}
+
+/**
+ * Returns the settings for the client-side media processing pipeline
+ * in the Media Library.
+ *
+ * These mirror the values the block editor consumes for the same
+ * pipeline: the REST index (image sizes and the big-image threshold)
+ * and get_block_editor_settings() (max upload size and allowed mime
+ * types), plus the image encoding filters.
+ *
+ * @since 7.2.0
+ *
+ * @return array {
+ * Settings for the client-side media processing pipeline.
+ *
+ * @type int $maxUploadFileSize Maximum upload file size in bytes.
+ * @type array $allowedMimeTypes Allowed mime types keyed by file extension.
+ * @type array $allImageSizes All registered image sub-sizes.
+ * @type int $bigImageSizeThreshold Threshold above which originals are scaled down.
+ * @type bool $imageStripMeta Whether metadata is stripped from generated images.
+ * @type int $imageMaxBitDepth Maximum bit depth for generated images.
+ * }
+ */
+function wp_get_media_library_upload_settings(): array {
+ /** This filter is documented in wp-admin/includes/image.php */
+ $big_image_size_threshold = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 );
+
+ /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
+ $image_strip_meta = (bool) apply_filters( 'image_strip_meta', true );
+
+ /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
+ $image_max_bit_depth = (int) apply_filters( 'image_max_bit_depth', 16, 16 );
+
+ return array(
+ 'maxUploadFileSize' => (int) wp_max_upload_size(),
+ 'allowedMimeTypes' => get_allowed_mime_types(),
+ 'allImageSizes' => wp_get_registered_image_subsizes(),
+ 'bigImageSizeThreshold' => $big_image_size_threshold,
+ 'imageStripMeta' => $image_strip_meta,
+ 'imageMaxBitDepth' => $image_max_bit_depth,
+ );
+}
+
+/**
+ * Enqueues a screen's client-side upload integration script along with the
+ * shared pipeline glue and its settings.
+ *
+ * Nothing is enqueued unless client-side media processing is enabled and the
+ * browser honors Document-Isolation-Policy: without isolation the page never
+ * becomes cross-origin isolated, the script would no-op, and the whole
+ * wp-upload-media dependency chain would be loaded for nothing.
+ *
+ * The screen script self-guards at runtime too: when the browser turns out
+ * not to be isolated or lacks client-side media support, it no-ops and the
+ * classic plupload flow keeps handling uploads.
+ *
+ * @since 7.2.0
+ * @access private
+ *
+ * @param string $handle The screen script to enqueue.
+ */
+function _wp_enqueue_media_upload_pipeline_script( string $handle ): void {
+ if ( ! wp_is_client_side_media_processing_enabled() ) {
+ return;
+ }
+
+ if ( ! wp_is_document_isolation_policy_supported() ) {
+ return;
+ }
+
+ wp_enqueue_script( $handle );
+
+ wp_add_inline_script(
+ 'media-upload-pipeline',
+ 'window._wpMediaUploadPipelineSettings = ' . wp_json_encode( wp_get_media_library_upload_settings() ) . ';',
+ 'before'
+ );
+}
+
+/**
+ * Enqueues the script that routes Media Library grid uploads through
+ * the client-side media processing pipeline.
+ *
+ * @since 7.2.0
+ */
+function wp_enqueue_media_library_upload(): void {
+ _wp_enqueue_media_upload_pipeline_script( 'media-library-upload' );
+}
+
+/**
+ * Enqueues the script that routes "Add New Media File" screen uploads
+ * through the client-side media processing pipeline.
+ *
+ * @since 7.2.0
+ */
+function wp_enqueue_media_new_upload(): void {
+ _wp_enqueue_media_upload_pipeline_script( 'media-new-upload' );
+}
+
/**
* Sends the Document-Isolation-Policy header for cross-origin isolation.
*
@@ -6703,9 +6858,7 @@ function wp_set_up_cross_origin_isolation(): void {
* @since 7.1.0
*/
function wp_start_cross_origin_isolation_output_buffer(): void {
- $chromium_version = wp_get_chromium_major_version();
-
- if ( null === $chromium_version || $chromium_version < 137 ) {
+ if ( ! wp_is_document_isolation_policy_supported() ) {
return;
}
diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php
index 7d5ba24e5617d..f9bd2a89c9058 100644
--- a/src/wp-includes/script-loader.php
+++ b/src/wp-includes/script-loader.php
@@ -1515,6 +1515,14 @@ function wp_default_scripts( $scripts ) {
$scripts->add( 'media', "/wp-admin/js/media$suffix.js", array( 'jquery', 'clipboard', 'wp-i18n', 'wp-a11y' ), false, 1 );
$scripts->set_translations( 'media' );
+ $scripts->add( 'media-upload-pipeline', "/wp-admin/js/media-upload-pipeline$suffix.js", array( 'plupload', 'wp-upload-media', 'wp-media-utils', 'wp-api-fetch', 'wp-data', 'wp-element', 'wp-i18n' ), false, 1 );
+ $scripts->set_translations( 'media-upload-pipeline' );
+
+ $scripts->add( 'media-library-upload', "/wp-admin/js/media-library-upload$suffix.js", array( 'media-views', 'media-upload-pipeline' ), false, 1 );
+
+ $scripts->add( 'media-new-upload', "/wp-admin/js/media-new-upload$suffix.js", array( 'plupload-handlers', 'media-upload-pipeline', 'wp-a11y', 'wp-i18n' ), false, 1 );
+ $scripts->set_translations( 'media-new-upload' );
+
$scripts->add( 'image-edit', "/wp-admin/js/image-edit$suffix.js", array( 'jquery', 'jquery-ui-core', 'imgareaselect', 'wp-a11y' ), false, 1 );
$scripts->set_translations( 'image-edit' );
diff --git a/tests/e2e/assets/test-audio.wav b/tests/e2e/assets/test-audio.wav
new file mode 100644
index 0000000000000..92349197e812f
Binary files /dev/null and b/tests/e2e/assets/test-audio.wav differ
diff --git a/tests/e2e/assets/test-image.jpg b/tests/e2e/assets/test-image.jpg
new file mode 100644
index 0000000000000..938be00cdec1b
Binary files /dev/null and b/tests/e2e/assets/test-image.jpg differ
diff --git a/tests/e2e/specs/media-library-client-side-upload.test.js b/tests/e2e/specs/media-library-client-side-upload.test.js
new file mode 100644
index 0000000000000..d32abb6035086
--- /dev/null
+++ b/tests/e2e/specs/media-library-client-side-upload.test.js
@@ -0,0 +1,518 @@
+/**
+ * WordPress dependencies
+ */
+import { test, expect } from '@wordpress/e2e-test-utils-playwright';
+
+/**
+ * External dependencies
+ */
+import path from 'path';
+
+// A 640x480 image: it must be larger than at least one registered sub-size
+// (thumbnail, medium) so the pipeline generates and sideloads thumbnails.
+const TEST_IMAGE_PATH = path.join( __dirname, '../assets/test-image.jpg' );
+
+// A short WAV file: audio stays on the classic upload path so it keeps the
+// ID3-derived title and description that media_handle_upload() sets.
+const TEST_AUDIO_PATH = path.join( __dirname, '../assets/test-audio.wav' );
+
+// The plupload HTML5 runtime creates this hidden file input over the
+// "Add New" browse button; setting files on it triggers FilesAdded.
+const FILE_INPUT_SELECTOR = '.moxie-shim-html5 input[type="file"]';
+
+// A REST error the pipeline surfaces as-is: its message matches none of the
+// transient patterns @wordpress/upload-media retries on.
+const SIMULATED_ERROR = {
+ code: 'rest_upload_simulated_failure',
+ message: 'Simulated server failure for testing',
+ data: { status: 500 },
+};
+
+/**
+ * Fails every REST media create request (not sideload/finalize).
+ *
+ * @param {import('@playwright/test').Page} page
+ */
+async function failMediaCreate( page ) {
+ await page.route(
+ ( url ) => {
+ const decoded = decodeURIComponent( url.href );
+ return (
+ /\/wp\/v2\/media(?:[?&]|$)/.test( decoded ) &&
+ ! /\/(sideload|finalize)/.test( decoded )
+ );
+ },
+ async ( route ) => {
+ if ( route.request().method() !== 'POST' ) {
+ await route.continue();
+ return;
+ }
+ await route.fulfill( {
+ status: 500,
+ contentType: 'application/json',
+ body: JSON.stringify( SIMULATED_ERROR ),
+ } );
+ }
+ );
+}
+
+test.describe( 'Media Library grid client-side uploads', () => {
+ test.afterEach( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllMedia();
+ } );
+
+ test( 'sends the Document-Isolation-Policy header on the grid', async ( {
+ page,
+ admin,
+ } ) => {
+ const responsePromise = page.waitForResponse(
+ ( resp ) =>
+ resp.url().includes( '/wp-admin/upload.php' ) &&
+ resp.request().resourceType() === 'document' &&
+ resp.status() === 200
+ );
+
+ await admin.visitAdminPage( 'upload.php', 'mode=grid' );
+
+ const headers = ( await responsePromise ).headers();
+ expect( headers[ 'document-isolation-policy' ] ).toBe(
+ 'isolate-and-credentialless'
+ );
+ } );
+
+ test( 'does not send the Document-Isolation-Policy header in list mode', async ( {
+ page,
+ admin,
+ } ) => {
+ const responsePromise = page.waitForResponse(
+ ( resp ) =>
+ resp.url().includes( '/wp-admin/upload.php' ) &&
+ resp.request().resourceType() === 'document' &&
+ resp.status() === 200
+ );
+
+ await admin.visitAdminPage( 'upload.php', 'mode=list' );
+
+ const headers = ( await responsePromise ).headers();
+ expect( headers[ 'document-isolation-policy' ] ).toBeUndefined();
+
+ // List mode uploads via media-new.php, so the grid integration
+ // script has no business on this screen either.
+ await expect(
+ page.locator( 'script[src*="media-library-upload"]' )
+ ).toHaveCount( 0 );
+
+ // Visiting with ?mode= persists the user's preference; restore it.
+ await admin.visitAdminPage( 'upload.php', 'mode=grid' );
+ } );
+
+ test( 'uploads an image through the client-side pipeline', async ( {
+ page,
+ admin,
+ requestUtils,
+ } ) => {
+ await admin.visitAdminPage( 'upload.php', 'mode=grid' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ // In Chromium builds without Document-Isolation-Policy support,
+ // isolation is legitimately unavailable and the pipeline falls back
+ // to classic uploads. Only assert where isolation is real.
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ // The REST route may be a pretty permalink (/wp/v2/media) or the
+ // plain form (index.php?rest_route=%2Fwp%2Fv2%2Fmedia), so match on
+ // the decoded URL.
+ let mediaCreateCount = 0;
+ let sideloadCount = 0;
+ let finalizeCount = 0;
+ const asyncUploads = [];
+ page.on( 'request', ( request ) => {
+ if ( request.method() !== 'POST' ) {
+ return;
+ }
+ const url = request.url();
+ if ( url.includes( '/async-upload.php' ) ) {
+ asyncUploads.push( url );
+ return;
+ }
+ const decoded = decodeURIComponent( url );
+ if ( /\/wp\/v2\/media\/\d+\/sideload/.test( decoded ) ) {
+ sideloadCount++;
+ } else if ( /\/wp\/v2\/media\/\d+\/finalize/.test( decoded ) ) {
+ finalizeCount++;
+ } else if ( /\/wp\/v2\/media(?:[?&]|$)/.test( decoded ) ) {
+ mediaCreateCount++;
+ }
+ } );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+
+ // The finalized attachment resolves to a normal (non-uploading) tile.
+ await expect(
+ page.locator( 'li.attachment:not(.uploading)' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+
+ // The original upload and every sideload go through the REST API,
+ // and the upload is finalized exactly once.
+ expect( mediaCreateCount ).toBeGreaterThanOrEqual( 1 );
+ expect( sideloadCount ).toBeGreaterThanOrEqual( 1 );
+ expect( finalizeCount ).toBe( 1 );
+
+ // Nothing goes through the classic async-upload.php endpoint.
+ expect( asyncUploads ).toEqual( [] );
+
+ // The finalized attachment carries the browser-generated sub-sizes
+ // in its metadata (the 640x480 source is larger than thumbnail and
+ // medium), and the sideloaded thumbnail file really exists.
+ const [ attachment ] = await requestUtils.rest( {
+ path: '/wp/v2/media',
+ params: { per_page: 1 },
+ } );
+ const sizes = attachment.media_details.sizes || {};
+ expect( Object.keys( sizes ) ).toEqual(
+ expect.arrayContaining( [ 'thumbnail', 'medium' ] )
+ );
+
+ const thumbnailResponse = await page.request.get(
+ sizes.thumbnail.source_url
+ );
+ expect( thumbnailResponse.status() ).toBe( 200 );
+ } );
+
+ test( 'warns before leaving while a pipeline upload is in flight', async ( {
+ page,
+ admin,
+ } ) => {
+ await admin.visitAdminPage( 'upload.php', 'mode=grid' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ // Hold sideload requests so the upload stays in flight at a
+ // deterministic point.
+ const heldRoutes = [];
+ let holding = true;
+ await page.route(
+ ( url ) => decodeURIComponent( url.href ).includes( '/sideload' ),
+ async ( route ) => {
+ if ( holding ) {
+ heldRoutes.push( route );
+ return;
+ }
+ await route.continue();
+ }
+ );
+ const sideloadRequested = page.waitForRequest(
+ ( request ) =>
+ decodeURIComponent( request.url() ).includes( '/sideload' ),
+ { timeout: 60_000 }
+ );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+ await sideloadRequested;
+
+ // A synthetic cancelable event exercises the guard's listener
+ // without triggering the real (untestable) browser prompt.
+ const preventedWhileUploading = await page.evaluate( () => {
+ const event = new Event( 'beforeunload', { cancelable: true } );
+ window.dispatchEvent( event );
+ return event.defaultPrevented;
+ } );
+ expect( preventedWhileUploading ).toBe( true );
+
+ // Release the held requests, let the upload finish, and verify the
+ // guard disengages once nothing is in flight anymore.
+ holding = false;
+ for ( const route of heldRoutes ) {
+ await route.continue();
+ }
+ await expect(
+ page.locator( 'li.attachment:not(.uploading)' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+
+ const preventedAfterUpload = await page.evaluate( () => {
+ const event = new Event( 'beforeunload', { cancelable: true } );
+ window.dispatchEvent( event );
+ return event.defaultPrevented;
+ } );
+ expect( preventedAfterUpload ).toBe( false );
+ } );
+
+ test( 'shows an error for a disallowed file type', async ( {
+ page,
+ admin,
+ } ) => {
+ await admin.visitAdminPage( 'upload.php', 'mode=grid' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( {
+ name: 'disallowed.xyz',
+ mimeType: 'application/octet-stream',
+ buffer: Buffer.from( 'not an allowed file type' ),
+ } );
+
+ // The Manage frame renders rejected uploads in the error sidebar.
+ const errorNotice = page
+ .locator( '.upload-error, .upload-errors' )
+ .first();
+ await expect( errorNotice ).toBeVisible( { timeout: 30_000 } );
+
+ // The sidebar names the file in its own span and keeps the reason
+ // in the message span; the reason has to be readable text, not a
+ // stringified object.
+ await expect(
+ errorNotice.locator( '.upload-error-filename' )
+ ).toHaveText( 'disallowed.xyz' );
+ const message = await errorNotice
+ .locator( '.upload-error-message' )
+ .innerText();
+ expect( message ).not.toContain( '[object Object]' );
+ expect( message.trim() ).not.toBe( '' );
+ } );
+ test( 'falls back to the classic uploader when the page is not isolated', async ( {
+ page,
+ admin,
+ } ) => {
+ // Blocking the Document-Isolation-Policy header reproduces every
+ // browser that ignores it: the page is not isolated, the script
+ // must no-op, and classic plupload must still upload the file.
+ await page.route(
+ ( url ) => url.pathname.endsWith( '/wp-admin/upload.php' ),
+ async ( route ) => {
+ const response = await route.fetch();
+ const headers = { ...response.headers() };
+ delete headers[ 'document-isolation-policy' ];
+ await route.fulfill( { response, headers } );
+ }
+ );
+
+ await admin.visitAdminPage( 'upload.php', 'mode=grid' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ expect( isolated ).toBe( false );
+
+ let asyncUploadCount = 0;
+ let restUploadCount = 0;
+ page.on( 'request', ( request ) => {
+ if ( request.method() !== 'POST' ) {
+ return;
+ }
+ if ( request.url().includes( '/async-upload.php' ) ) {
+ asyncUploadCount++;
+ } else if (
+ /\/wp\/v2\/media/.test( decodeURIComponent( request.url() ) )
+ ) {
+ restUploadCount++;
+ }
+ } );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+
+ await expect(
+ page.locator( 'li.attachment:not(.uploading)' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+
+ // Classic plupload handled the upload end to end.
+ expect( asyncUploadCount ).toBeGreaterThanOrEqual( 1 );
+ expect( restUploadCount ).toBe( 0 );
+ } );
+
+ test( 'uploads several files at once, including duplicates', async ( {
+ page,
+ admin,
+ } ) => {
+ await admin.visitAdminPage( 'upload.php', 'mode=grid' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ let finalizeCount = 0;
+ page.on( 'request', ( request ) => {
+ if (
+ request.method() === 'POST' &&
+ /\/wp\/v2\/media\/\d+\/finalize/.test(
+ decodeURIComponent( request.url() )
+ )
+ ) {
+ finalizeCount++;
+ }
+ } );
+
+ // The same file twice exercises the shared-identity path: both
+ // tiles must track progress and resolve independently.
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( [ TEST_IMAGE_PATH, TEST_IMAGE_PATH ] );
+
+ await expect( page.locator( 'li.attachment.uploading' ) ).toHaveCount(
+ 0,
+ { timeout: 90_000 }
+ );
+ await expect(
+ page.locator( 'li.attachment:not(.uploading)' )
+ ).toHaveCount( 2 );
+ expect( finalizeCount ).toBe( 2 );
+ } );
+
+ test( 'reports pipeline progress on the tile', async ( { page, admin } ) => {
+ await admin.visitAdminPage( 'upload.php', 'mode=grid' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ // Hold sideloads so the tile is observed mid-pipeline, after the
+ // original has been uploaded but before thumbnails have landed.
+ const heldRoutes = [];
+ let holding = true;
+ await page.route(
+ ( url ) => decodeURIComponent( url.href ).includes( '/sideload' ),
+ async ( route ) => {
+ if ( holding ) {
+ heldRoutes.push( route );
+ return;
+ }
+ await route.continue();
+ }
+ );
+ const sideloadRequested = page.waitForRequest(
+ ( request ) =>
+ decodeURIComponent( request.url() ).includes( '/sideload' ),
+ { timeout: 60_000 }
+ );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+ await sideloadRequested;
+
+ // The placeholder tile's progress bar has advanced past zero but
+ // is not reported complete while work remains.
+ const bar = page.locator(
+ 'li.attachment.uploading .media-progress-bar div'
+ );
+ await expect( bar ).toHaveAttribute( 'style', /width:\s*[1-9]/ );
+ await expect( bar ).not.toHaveAttribute( 'style', /width:\s*100%/ );
+
+ holding = false;
+ for ( const route of heldRoutes ) {
+ await route.continue();
+ }
+ await expect(
+ page.locator( 'li.attachment:not(.uploading)' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+ } );
+
+ test( 'surfaces a pipeline error with its message and file name', async ( {
+ page,
+ admin,
+ } ) => {
+ await admin.visitAdminPage( 'upload.php', 'mode=grid' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ await failMediaCreate( page );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+
+ // The error lands in the grid's error sidebar exactly like a classic
+ // upload error: the file name and the server's message.
+ const error = page.locator( '.upload-error' ).first();
+ await expect( error ).toBeVisible( { timeout: 60_000 } );
+ await expect( error.locator( '.upload-error-filename' ) ).toHaveText(
+ 'test-image.jpg'
+ );
+ await expect( error.locator( '.upload-error-message' ) ).toHaveText(
+ SIMULATED_ERROR.message
+ );
+
+ // The placeholder tile is removed rather than left spinning.
+ await expect( page.locator( 'li.attachment.uploading' ) ).toHaveCount(
+ 0
+ );
+ } );
+ test( 'leaves audio files to the classic uploader', async ( {
+ page,
+ admin,
+ } ) => {
+ await admin.visitAdminPage( 'upload.php', 'mode=grid' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ let asyncUploadCount = 0;
+ let restUploadCount = 0;
+ page.on( 'request', ( request ) => {
+ if ( request.method() !== 'POST' ) {
+ return;
+ }
+ if ( request.url().includes( '/async-upload.php' ) ) {
+ asyncUploadCount++;
+ } else if (
+ /\/wp\/v2\/media/.test( decodeURIComponent( request.url() ) )
+ ) {
+ restUploadCount++;
+ }
+ } );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_AUDIO_PATH );
+
+ await expect(
+ page.locator( 'li.attachment:not(.uploading)' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+
+ expect( asyncUploadCount ).toBeGreaterThanOrEqual( 1 );
+ expect( restUploadCount ).toBe( 0 );
+ } );
+} );
diff --git a/tests/e2e/specs/media-new-client-side-upload.test.js b/tests/e2e/specs/media-new-client-side-upload.test.js
new file mode 100644
index 0000000000000..a4243962f5164
--- /dev/null
+++ b/tests/e2e/specs/media-new-client-side-upload.test.js
@@ -0,0 +1,670 @@
+/**
+ * WordPress dependencies
+ */
+import { test, expect } from '@wordpress/e2e-test-utils-playwright';
+
+/**
+ * External dependencies
+ */
+import path from 'path';
+
+// A 640x480 image: it must be larger than at least one registered sub-size
+// (thumbnail, medium) so the pipeline generates and sideloads thumbnails.
+const TEST_IMAGE_PATH = path.join( __dirname, '../assets/test-image.jpg' );
+
+// A short WAV file: audio stays on the classic upload path so it keeps the
+// ID3-derived title and description that media_handle_upload() sets.
+const TEST_AUDIO_PATH = path.join( __dirname, '../assets/test-audio.wav' );
+
+// The plupload HTML5 runtime creates this hidden file input over the
+// "Select Files" browse button; setting files on it triggers FilesAdded.
+const FILE_INPUT_SELECTOR = '.moxie-shim-html5 input[type="file"]';
+
+// A REST error the pipeline surfaces as-is: its message matches none of the
+// transient patterns @wordpress/upload-media retries on.
+const SIMULATED_ERROR = {
+ code: 'rest_upload_simulated_failure',
+ message: 'Simulated server failure for testing',
+ data: { status: 500 },
+};
+
+/**
+ * Fails every REST media create request (not sideload/finalize).
+ *
+ * @param {import('@playwright/test').Page} page
+ */
+async function failMediaCreate( page ) {
+ await page.route(
+ ( url ) => {
+ const decoded = decodeURIComponent( url.href );
+ return (
+ /\/wp\/v2\/media(?:[?&]|$)/.test( decoded ) &&
+ ! /\/(sideload|finalize)/.test( decoded )
+ );
+ },
+ async ( route ) => {
+ if ( route.request().method() !== 'POST' ) {
+ await route.continue();
+ return;
+ }
+ await route.fulfill( {
+ status: 500,
+ contentType: 'application/json',
+ body: JSON.stringify( SIMULATED_ERROR ),
+ } );
+ }
+ );
+}
+
+test.describe( 'Add New Media File client-side uploads', () => {
+ test.afterEach( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllMedia();
+ await requestUtils.deleteAllPosts();
+ } );
+
+ test( 'sends the Document-Isolation-Policy header', async ( {
+ page,
+ admin,
+ } ) => {
+ const responsePromise = page.waitForResponse(
+ ( resp ) =>
+ resp.url().includes( '/wp-admin/media-new.php' ) &&
+ resp.request().resourceType() === 'document' &&
+ resp.status() === 200
+ );
+
+ await admin.visitAdminPage( 'media-new.php' );
+
+ const headers = ( await responsePromise ).headers();
+ expect( headers[ 'document-isolation-policy' ] ).toBe(
+ 'isolate-and-credentialless'
+ );
+ } );
+
+ test( 'uploads an image through the client-side pipeline', async ( {
+ page,
+ admin,
+ requestUtils,
+ } ) => {
+ await admin.visitAdminPage( 'media-new.php' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ // In Chromium builds without Document-Isolation-Policy support,
+ // isolation is legitimately unavailable and the pipeline falls back
+ // to classic uploads. Only assert where isolation is real.
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ // The REST route may be a pretty permalink (/wp/v2/media) or the
+ // plain form (index.php?rest_route=%2Fwp%2Fv2%2Fmedia), so match on
+ // the decoded URL.
+ let mediaCreateCount = 0;
+ let sideloadCount = 0;
+ let finalizeCount = 0;
+ const asyncUploads = [];
+ page.on( 'request', ( request ) => {
+ if ( request.method() !== 'POST' ) {
+ return;
+ }
+ const url = request.url();
+ if ( url.includes( '/async-upload.php' ) ) {
+ // The pipeline still POSTs to async-upload.php once per
+ // upload to fetch the finished item markup (fetch=3, no file
+ // payload); only file uploads must not go through it.
+ const postData = request.postData() || '';
+ if ( ! /(^|&)fetch=/.test( postData ) ) {
+ asyncUploads.push( url );
+ }
+ return;
+ }
+ const decoded = decodeURIComponent( url );
+ if ( /\/wp\/v2\/media\/\d+\/sideload/.test( decoded ) ) {
+ sideloadCount++;
+ } else if ( /\/wp\/v2\/media\/\d+\/finalize/.test( decoded ) ) {
+ finalizeCount++;
+ } else if ( /\/wp\/v2\/media(?:[?&]|$)/.test( decoded ) ) {
+ mediaCreateCount++;
+ }
+ } );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+
+ // The finished attachment row renders with the Edit link fetched
+ // from the async-upload.php markup endpoint.
+ await expect(
+ page.locator( '#media-items .media-item .edit-attachment' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+
+ // The original upload and every sideload go through the REST API,
+ // and the upload is finalized exactly once.
+ expect( mediaCreateCount ).toBeGreaterThanOrEqual( 1 );
+ expect( sideloadCount ).toBeGreaterThanOrEqual( 1 );
+ expect( finalizeCount ).toBe( 1 );
+
+ // No file upload goes through the classic async-upload.php endpoint.
+ expect( asyncUploads ).toEqual( [] );
+
+ // The finalized attachment carries the browser-generated sub-sizes
+ // in its metadata (the 640x480 source is larger than thumbnail and
+ // medium), and the sideloaded thumbnail file really exists.
+ const [ attachment ] = await requestUtils.rest( {
+ path: '/wp/v2/media',
+ params: { per_page: 1 },
+ } );
+ const sizes = attachment.media_details.sizes || {};
+ expect( Object.keys( sizes ) ).toEqual(
+ expect.arrayContaining( [ 'thumbnail', 'medium' ] )
+ );
+
+ const thumbnailResponse = await page.request.get(
+ sizes.thumbnail.source_url
+ );
+ expect( thumbnailResponse.status() ).toBe( 200 );
+ } );
+
+ test( 'warns before leaving while a pipeline upload is in flight', async ( {
+ page,
+ admin,
+ } ) => {
+ await admin.visitAdminPage( 'media-new.php' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ // Hold sideload requests so the upload stays in flight at a
+ // deterministic point.
+ const heldRoutes = [];
+ let holding = true;
+ await page.route(
+ ( url ) => decodeURIComponent( url.href ).includes( '/sideload' ),
+ async ( route ) => {
+ if ( holding ) {
+ heldRoutes.push( route );
+ return;
+ }
+ await route.continue();
+ }
+ );
+ const sideloadRequested = page.waitForRequest(
+ ( request ) =>
+ decodeURIComponent( request.url() ).includes( '/sideload' ),
+ { timeout: 60_000 }
+ );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+ await sideloadRequested;
+
+ // A synthetic cancelable event exercises the guard's listener
+ // without triggering the real (untestable) browser prompt.
+ const preventedWhileUploading = await page.evaluate( () => {
+ const event = new Event( 'beforeunload', { cancelable: true } );
+ window.dispatchEvent( event );
+ return event.defaultPrevented;
+ } );
+ expect( preventedWhileUploading ).toBe( true );
+
+ // Release the held requests, let the upload finish, and verify the
+ // guard disengages once nothing is in flight anymore.
+ holding = false;
+ for ( const route of heldRoutes ) {
+ await route.continue();
+ }
+ await expect(
+ page.locator( '#media-items .media-item .edit-attachment' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+
+ const preventedAfterUpload = await page.evaluate( () => {
+ const event = new Event( 'beforeunload', { cancelable: true } );
+ window.dispatchEvent( event );
+ return event.defaultPrevented;
+ } );
+ expect( preventedAfterUpload ).toBe( false );
+ } );
+
+ test( 'shows an error for a disallowed file type', async ( {
+ page,
+ admin,
+ } ) => {
+ await admin.visitAdminPage( 'media-new.php' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( {
+ name: 'disallowed.xyz',
+ mimeType: 'application/octet-stream',
+ buffer: Buffer.from( 'not an allowed file type' ),
+ } );
+
+ // The error surfaces either as a pipeline per-item error
+ // (itemAjaxError renders .error-div inside the media item) or as a
+ // plupload extension rejection (a .media-item.error element),
+ // depending on which layer rejects the file first.
+ const errorItem = page
+ .locator( '.media-item .error-div, .media-item.error' )
+ .first();
+ await expect( errorItem ).toBeVisible( { timeout: 30_000 } );
+
+ // The reason has to be readable: getErrorMessage() returns an object,
+ // so handing it straight to the UI renders "[object Object]".
+ const errorText = await errorItem.innerText();
+ expect( errorText ).not.toContain( '[object Object]' );
+ } );
+
+ test( 'attaches the upload to the post named by post_id', async ( {
+ page,
+ admin,
+ requestUtils,
+ } ) => {
+ // Published, so the attachment (post_status 'inherit') stays visible
+ // in the media collection.
+ const post = await requestUtils.createPost( {
+ title: 'Client-side upload parent',
+ status: 'publish',
+ } );
+
+ await admin.visitAdminPage( 'media-new.php', `post_id=${ post.id }` );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+
+ await expect(
+ page.locator( '#media-items .media-item .edit-attachment' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+
+ // The classic flow posts `post_id` to async-upload.php; the pipeline
+ // has to send the REST equivalent or the file lands unattached even
+ // though the screen counts it against the post.
+ const [ attachment ] = await requestUtils.rest( {
+ path: '/wp/v2/media',
+ params: { per_page: 1 },
+ } );
+ expect( attachment.post ).toBe( post.id );
+ } );
+ test( 'falls back to the classic uploader when the page is not isolated', async ( {
+ page,
+ admin,
+ } ) => {
+ // Blocking the Document-Isolation-Policy header reproduces every
+ // browser that ignores it: the page is not isolated, the script
+ // must no-op, and classic plupload must still upload the file.
+ await page.route(
+ ( url ) => url.pathname.endsWith( '/wp-admin/media-new.php' ),
+ async ( route ) => {
+ const response = await route.fetch();
+ const headers = { ...response.headers() };
+ delete headers[ 'document-isolation-policy' ];
+ await route.fulfill( { response, headers } );
+ }
+ );
+
+ await admin.visitAdminPage( 'media-new.php' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ expect( isolated ).toBe( false );
+
+ let asyncFileUploads = 0;
+ let restUploadCount = 0;
+ page.on( 'request', ( request ) => {
+ if ( request.method() !== 'POST' ) {
+ return;
+ }
+ if ( request.url().includes( '/async-upload.php' ) ) {
+ // Ignore the markup fetch (fetch=3); count file uploads.
+ if ( ! /(^|&)fetch=/.test( request.postData() || '' ) ) {
+ asyncFileUploads++;
+ }
+ } else if (
+ /\/wp\/v2\/media/.test( decodeURIComponent( request.url() ) )
+ ) {
+ restUploadCount++;
+ }
+ } );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+
+ await expect(
+ page.locator( '#media-items .media-item .edit-attachment' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+
+ expect( asyncFileUploads ).toBeGreaterThanOrEqual( 1 );
+ expect( restUploadCount ).toBe( 0 );
+ } );
+
+ test( 'uploads several files at once, including duplicates', async ( {
+ page,
+ admin,
+ } ) => {
+ await admin.visitAdminPage( 'media-new.php' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ let finalizeCount = 0;
+ page.on( 'request', ( request ) => {
+ if (
+ request.method() === 'POST' &&
+ /\/wp\/v2\/media\/\d+\/finalize/.test(
+ decodeURIComponent( request.url() )
+ )
+ ) {
+ finalizeCount++;
+ }
+ } );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( [ TEST_IMAGE_PATH, TEST_IMAGE_PATH ] );
+
+ // Each finished row carries the Edit link (the markup may repeat
+ // the class inside a row, so count rows rather than links).
+ await expect(
+ page.locator( '#media-items .media-item:has(.edit-attachment)' )
+ ).toHaveCount( 2, { timeout: 90_000 } );
+ expect( finalizeCount ).toBe( 2 );
+ } );
+
+ test( 'reports pipeline progress on the item', async ( { page, admin } ) => {
+ await admin.visitAdminPage( 'media-new.php' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ const heldRoutes = [];
+ let holding = true;
+ await page.route(
+ ( url ) => decodeURIComponent( url.href ).includes( '/sideload' ),
+ async ( route ) => {
+ if ( holding ) {
+ heldRoutes.push( route );
+ return;
+ }
+ await route.continue();
+ }
+ );
+ const sideloadRequested = page.waitForRequest(
+ ( request ) =>
+ decodeURIComponent( request.url() ).includes( '/sideload' ),
+ { timeout: 60_000 }
+ );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+ await sideloadRequested;
+
+ // The screen's own progress markup (from fileQueued) reflects the
+ // pipeline: past zero, not yet complete.
+ const item = page.locator( '#media-items .media-item' ).first();
+ await expect( item.locator( '.percent' ) ).toHaveText( /^[1-9]\d?%$/ );
+ await expect( item.locator( '.bar' ) ).toHaveAttribute(
+ 'style',
+ /width:\s*[1-9]/
+ );
+
+ holding = false;
+ for ( const route of heldRoutes ) {
+ await route.continue();
+ }
+ await expect(
+ page.locator( '#media-items .media-item .edit-attachment' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+ } );
+
+ test( 'surfaces a pipeline error with the server message', async ( {
+ page,
+ admin,
+ } ) => {
+ await admin.visitAdminPage( 'media-new.php' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ await failMediaCreate( page );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+
+ // itemAjaxError() renders the message inside the item, alongside
+ // the file name, exactly as a classic upload error would.
+ const item = page.locator( '#media-items .media-item' ).first();
+ await expect( item.locator( '.error-div' ) ).toBeVisible( {
+ timeout: 60_000,
+ } );
+ await expect( item.locator( '.error-div' ) ).toContainText(
+ SIMULATED_ERROR.message
+ );
+ await expect( item.locator( '.error-div' ) ).toContainText(
+ 'test-image.jpg'
+ );
+ } );
+ test( 'uploads unattached when post_id is not a valid post', async ( {
+ page,
+ admin,
+ requestUtils,
+ } ) => {
+ // media_upload_form() hands plupload the raw post_id, but the screen
+ // validates it and prints the validated value; async-upload.php
+ // silently attaches to nothing, so the pipeline must do the same
+ // instead of sending the REST API a parent it will reject.
+ await admin.visitAdminPage( 'media-new.php', 'post_id=999999999' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+
+ await expect(
+ page.locator( '#media-items .media-item .edit-attachment' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+ await expect( page.locator( '.media-item .error-div' ) ).toHaveCount(
+ 0
+ );
+
+ const [ attachment ] = await requestUtils.rest( {
+ path: '/wp/v2/media',
+ params: { per_page: 1 },
+ } );
+ expect( attachment.post ).toBeNull();
+ } );
+
+ test( 'forwards plupload multipart params to the REST upload', async ( {
+ page,
+ admin,
+ } ) => {
+ await admin.visitAdminPage( 'media-new.php' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ // A plugin adding a field through plupload_default_params or
+ // uploader.settings.multipart_params reads it back from $_POST on
+ // the classic path; the REST request must carry it the same way.
+ // The browser does not expose multipart bodies that carry a file,
+ // so record the FormData handed to fetch() from inside the page.
+ await page.evaluate( () => {
+ window.uploader.settings.multipart_params.e2e_custom_param =
+ 'forwarded';
+
+ window.__e2eCreateBodies = [];
+ const originalFetch = window.fetch;
+ window.fetch = function ( input, init ) {
+ const url = typeof input === 'string' ? input : input.url;
+ if (
+ init &&
+ init.body instanceof FormData &&
+ /\/wp\/v2\/media(?:[?&]|$)/.test( decodeURIComponent( url ) )
+ ) {
+ const fields = {};
+ init.body.forEach( ( value, key ) => {
+ fields[ key ] =
+ value instanceof Blob ? '[file]' : String( value );
+ } );
+ window.__e2eCreateBodies.push( fields );
+ }
+ return originalFetch.apply( this, arguments );
+ };
+ } );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+
+ await expect(
+ page.locator( '#media-items .media-item .edit-attachment' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+
+ const bodies = await page.evaluate( () => window.__e2eCreateBodies );
+ expect( bodies.length ).toBeGreaterThanOrEqual( 1 );
+ const fields = bodies[ 0 ];
+ expect( fields.e2e_custom_param ).toBe( 'forwarded' );
+ expect( fields.file ).toBe( '[file]' );
+ // The classic transport's own fields stay behind.
+ expect( fields ).not.toHaveProperty( '_wpnonce' );
+ expect( fields ).not.toHaveProperty( 'action' );
+ } );
+
+ test( 'leaves audio files to the classic uploader', async ( {
+ page,
+ admin,
+ } ) => {
+ await admin.visitAdminPage( 'media-new.php' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ let asyncFileUploads = 0;
+ let restUploadCount = 0;
+ page.on( 'request', ( request ) => {
+ if ( request.method() !== 'POST' ) {
+ return;
+ }
+ if ( request.url().includes( '/async-upload.php' ) ) {
+ if ( ! /(^|&)fetch=/.test( request.postData() || '' ) ) {
+ asyncFileUploads++;
+ }
+ } else if (
+ /\/wp\/v2\/media/.test( decodeURIComponent( request.url() ) )
+ ) {
+ restUploadCount++;
+ }
+ } );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_AUDIO_PATH );
+
+ await expect(
+ page.locator( '#media-items .media-item .edit-attachment' ).first()
+ ).toBeVisible( { timeout: 60_000 } );
+
+ expect( asyncFileUploads ).toBeGreaterThanOrEqual( 1 );
+ expect( restUploadCount ).toBe( 0 );
+ } );
+
+ test( 'renders a failed upload with an accessible Dismiss button', async ( {
+ page,
+ admin,
+ } ) => {
+ await admin.visitAdminPage( 'media-new.php' );
+
+ const isolated = await page.evaluate( () =>
+ Boolean( window.crossOriginIsolated )
+ );
+ test.skip(
+ ! isolated,
+ 'The client-side pipeline requires a cross-origin isolated context'
+ );
+
+ await failMediaCreate( page );
+
+ const fileInput = page.locator( FILE_INPUT_SELECTOR ).first();
+ await fileInput.waitFor( { state: 'attached', timeout: 30_000 } );
+ await fileInput.setInputFiles( TEST_IMAGE_PATH );
+
+ // Same markup as a server-side failure from async-upload.php: a
+ // real button, described by the notice it dismisses.
+ const notice = page.locator( '.media-item .error-div' ).first();
+ await expect( notice ).toBeVisible( { timeout: 60_000 } );
+ const dismiss = notice.getByRole( 'button', { name: 'Dismiss' } );
+ await expect( dismiss ).toBeVisible();
+ await expect( dismiss ).toHaveAttribute(
+ 'aria-describedby',
+ await notice.getAttribute( 'id' )
+ );
+ await expect( notice ).toContainText(
+ '“test-image.jpg” has failed to upload.'
+ );
+
+ // Dismissing removes the item and returns focus to the browse button.
+ await dismiss.click();
+ await expect( notice ).toHaveCount( 0 );
+ await expect( page.locator( '#plupload-browse-button' ) ).toBeFocused();
+ } );
+} );
diff --git a/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php b/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php
new file mode 100644
index 0000000000000..daaa1d2149ebb
--- /dev/null
+++ b/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php
@@ -0,0 +1,258 @@
+original_http_host = $_SERVER['HTTP_HOST'] ?? null;
+ $this->original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null;
+
+ // A secure origin so client-side media processing is enabled.
+ $_SERVER['HTTP_HOST'] = 'localhost';
+
+ /*
+ * Cross-origin isolation relies on Document-Isolation-Policy, so the
+ * enqueue is gated on Chromium 137+. The PHPUnit bootstrap defines no
+ * User-Agent at all, which reads as "not Chromium".
+ */
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36';
+
+ /*
+ * The script is registered in the admin-only branch of
+ * wp_default_scripts(), so default scripts must be (re)registered
+ * from an admin context.
+ */
+ set_current_screen( 'upload' );
+ $this->original_wp_scripts = $GLOBALS['wp_scripts'] ?? null;
+ $GLOBALS['wp_scripts'] = new WP_Scripts();
+ }
+
+ public function tear_down() {
+ if ( null === $this->original_http_host ) {
+ unset( $_SERVER['HTTP_HOST'] );
+ } else {
+ $_SERVER['HTTP_HOST'] = $this->original_http_host;
+ }
+
+ if ( null === $this->original_user_agent ) {
+ unset( $_SERVER['HTTP_USER_AGENT'] );
+ } else {
+ $_SERVER['HTTP_USER_AGENT'] = $this->original_user_agent;
+ }
+
+ $GLOBALS['wp_scripts'] = $this->original_wp_scripts;
+ $GLOBALS['current_screen'] = null;
+
+ remove_all_filters( 'wp_client_side_media_processing_enabled' );
+ parent::tear_down();
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_script_enqueued() {
+ wp_enqueue_media_library_upload();
+
+ $this->assertTrue( wp_script_is( 'media-library-upload', 'enqueued' ) );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_script_not_enqueued_when_client_side_processing_disabled() {
+ add_filter( 'wp_client_side_media_processing_enabled', '__return_false' );
+
+ wp_enqueue_media_library_upload();
+
+ $this->assertFalse( wp_script_is( 'media-library-upload', 'enqueued' ) );
+ }
+
+ /**
+ * Document-Isolation-Policy is Chromium-only, so a browser that can never
+ * be cross-origin isolated must not download the pipeline bundles for a
+ * script that could only no-op.
+ *
+ * @ticket 65661
+ */
+ public function test_script_not_enqueued_for_non_chromium_user_agent() {
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0';
+
+ wp_enqueue_media_library_upload();
+
+ $this->assertFalse( wp_script_is( 'media-library-upload', 'enqueued' ) );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_script_not_enqueued_for_older_chromium() {
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36';
+
+ wp_enqueue_media_library_upload();
+
+ $this->assertFalse( wp_script_is( 'media-library-upload', 'enqueued' ) );
+ }
+
+ /**
+ * The script depends on media-views and the shared pipeline glue, not
+ * wp-block-editor, so block editor bundles are not dragged onto
+ * the Media Library page.
+ *
+ * @ticket 65661
+ */
+ public function test_dependencies() {
+ wp_enqueue_media_library_upload();
+
+ $script = wp_scripts()->registered['media-library-upload'];
+ $this->assertContains( 'media-views', $script->deps );
+ $this->assertContains( 'media-upload-pipeline', $script->deps );
+ $this->assertNotContains( 'wp-block-editor', $script->deps );
+
+ // The pipeline packages are pulled in through the shared glue script.
+ $pipeline = wp_scripts()->registered['media-upload-pipeline'];
+ $this->assertContains( 'wp-upload-media', $pipeline->deps );
+ $this->assertContains( 'wp-media-utils', $pipeline->deps );
+ $this->assertNotContains( 'wp-block-editor', $pipeline->deps );
+ $this->assertNotContains( 'media-views', $pipeline->deps );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_inline_settings_expose_all_keys() {
+ wp_enqueue_media_library_upload();
+
+ $before = wp_scripts()->get_data( 'media-upload-pipeline', 'before' );
+ $inline = implode( "\n", (array) $before );
+
+ $this->assertStringContainsString( 'window._wpMediaUploadPipelineSettings', $inline );
+
+ foreach ( array(
+ 'maxUploadFileSize',
+ 'allowedMimeTypes',
+ 'allImageSizes',
+ 'bigImageSizeThreshold',
+ 'imageStripMeta',
+ 'imageMaxBitDepth',
+ ) as $key ) {
+ $this->assertStringContainsString( $key, $inline );
+ }
+ }
+
+ /**
+ * The inline settings must be exactly the JSON encoding of
+ * wp_get_media_library_upload_settings(), so the script consumes the
+ * same values the server computes.
+ *
+ * @ticket 65661
+ */
+ public function test_inline_settings_match_upload_settings() {
+ wp_enqueue_media_library_upload();
+
+ $before = wp_scripts()->get_data( 'media-upload-pipeline', 'before' );
+ $inline = implode( "\n", (array) $before );
+
+ $this->assertStringContainsString(
+ wp_json_encode( wp_get_media_library_upload_settings() ),
+ $inline
+ );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_allowed_mime_types_respect_upload_mimes_filter() {
+ add_filter(
+ 'upload_mimes',
+ static function ( $mimes ) {
+ unset( $mimes['gif'] );
+ return $mimes;
+ }
+ );
+
+ $settings = wp_get_media_library_upload_settings();
+
+ $this->assertArrayNotHasKey( 'gif', $settings['allowedMimeTypes'] );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_image_strip_meta_filter() {
+ add_filter( 'image_strip_meta', '__return_false' );
+
+ $settings = wp_get_media_library_upload_settings();
+
+ $this->assertFalse( $settings['imageStripMeta'] );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_image_max_bit_depth_filter() {
+ add_filter(
+ 'image_max_bit_depth',
+ static function () {
+ return 8;
+ }
+ );
+
+ $settings = wp_get_media_library_upload_settings();
+
+ $this->assertSame( 8, $settings['imageMaxBitDepth'] );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_big_image_size_threshold_filter() {
+ add_filter(
+ 'big_image_size_threshold',
+ static function () {
+ return 4096;
+ }
+ );
+
+ $settings = wp_get_media_library_upload_settings();
+
+ $this->assertSame( 4096, $settings['bigImageSizeThreshold'] );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_settings_value_types() {
+ $settings = wp_get_media_library_upload_settings();
+
+ $this->assertIsInt( $settings['maxUploadFileSize'] );
+ $this->assertIsArray( $settings['allowedMimeTypes'] );
+ $this->assertIsArray( $settings['allImageSizes'] );
+ $this->assertIsInt( $settings['bigImageSizeThreshold'] );
+ $this->assertIsBool( $settings['imageStripMeta'] );
+ $this->assertIsInt( $settings['imageMaxBitDepth'] );
+ }
+}
diff --git a/tests/phpunit/tests/media/wpEnqueueMediaNewUpload.php b/tests/phpunit/tests/media/wpEnqueueMediaNewUpload.php
new file mode 100644
index 0000000000000..4510760d3851e
--- /dev/null
+++ b/tests/phpunit/tests/media/wpEnqueueMediaNewUpload.php
@@ -0,0 +1,184 @@
+original_http_host = $_SERVER['HTTP_HOST'] ?? null;
+ $this->original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null;
+
+ // A secure origin so client-side media processing is enabled.
+ $_SERVER['HTTP_HOST'] = 'localhost';
+
+ /*
+ * Cross-origin isolation relies on Document-Isolation-Policy, so the
+ * enqueue is gated on Chromium 137+. The PHPUnit bootstrap defines no
+ * User-Agent at all, which reads as "not Chromium".
+ */
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36';
+
+ /*
+ * The script is registered in the admin-only branch of
+ * wp_default_scripts(), so default scripts must be (re)registered
+ * from an admin context.
+ */
+ set_current_screen( 'media' );
+ $this->original_wp_scripts = $GLOBALS['wp_scripts'] ?? null;
+ $GLOBALS['wp_scripts'] = new WP_Scripts();
+ }
+
+ public function tear_down() {
+ if ( null === $this->original_http_host ) {
+ unset( $_SERVER['HTTP_HOST'] );
+ } else {
+ $_SERVER['HTTP_HOST'] = $this->original_http_host;
+ }
+
+ if ( null === $this->original_user_agent ) {
+ unset( $_SERVER['HTTP_USER_AGENT'] );
+ } else {
+ $_SERVER['HTTP_USER_AGENT'] = $this->original_user_agent;
+ }
+
+ $GLOBALS['wp_scripts'] = $this->original_wp_scripts;
+ $GLOBALS['current_screen'] = null;
+
+ remove_all_filters( 'wp_client_side_media_processing_enabled' );
+ parent::tear_down();
+ }
+
+ /**
+ * @ticket 65662
+ */
+ public function test_script_enqueued() {
+ wp_enqueue_media_new_upload();
+
+ $this->assertTrue( wp_script_is( 'media-new-upload', 'enqueued' ) );
+ }
+
+ /**
+ * @ticket 65662
+ */
+ public function test_script_not_enqueued_when_client_side_processing_disabled() {
+ add_filter( 'wp_client_side_media_processing_enabled', '__return_false' );
+
+ wp_enqueue_media_new_upload();
+
+ $this->assertFalse( wp_script_is( 'media-new-upload', 'enqueued' ) );
+ }
+
+ /**
+ * Document-Isolation-Policy is Chromium-only, so a browser that can never
+ * be cross-origin isolated must not download the pipeline bundles for a
+ * script that could only no-op.
+ *
+ * @ticket 65662
+ */
+ public function test_script_not_enqueued_for_non_chromium_user_agent() {
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0';
+
+ wp_enqueue_media_new_upload();
+
+ $this->assertFalse( wp_script_is( 'media-new-upload', 'enqueued' ) );
+ }
+
+ /**
+ * @ticket 65662
+ */
+ public function test_script_not_enqueued_for_older_chromium() {
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36';
+
+ wp_enqueue_media_new_upload();
+
+ $this->assertFalse( wp_script_is( 'media-new-upload', 'enqueued' ) );
+ }
+
+ /**
+ * The script depends on plupload-handlers (whose UI helpers it reuses)
+ * and the shared pipeline glue, not on media-views or wp-block-editor,
+ * so the heavy Media Library and block editor bundles are not dragged
+ * onto the "Add New Media File" screen.
+ *
+ * @ticket 65662
+ */
+ public function test_dependencies() {
+ wp_enqueue_media_new_upload();
+
+ $script = wp_scripts()->registered['media-new-upload'];
+ $this->assertContains( 'plupload-handlers', $script->deps );
+ $this->assertContains( 'media-upload-pipeline', $script->deps );
+ $this->assertNotContains( 'wp-block-editor', $script->deps );
+
+ // The pipeline packages are pulled in through the shared glue script.
+ $pipeline = wp_scripts()->registered['media-upload-pipeline'];
+ $this->assertContains( 'wp-upload-media', $pipeline->deps );
+ $this->assertContains( 'wp-media-utils', $pipeline->deps );
+ $this->assertNotContains( 'wp-block-editor', $pipeline->deps );
+ $this->assertNotContains( 'media-views', $pipeline->deps );
+ }
+
+ /**
+ * @ticket 65662
+ */
+ public function test_inline_settings_expose_all_keys() {
+ wp_enqueue_media_new_upload();
+
+ $before = wp_scripts()->get_data( 'media-upload-pipeline', 'before' );
+ $inline = implode( "\n", (array) $before );
+
+ $this->assertStringContainsString( 'window._wpMediaUploadPipelineSettings', $inline );
+
+ foreach ( array(
+ 'maxUploadFileSize',
+ 'allowedMimeTypes',
+ 'allImageSizes',
+ 'bigImageSizeThreshold',
+ 'imageStripMeta',
+ 'imageMaxBitDepth',
+ ) as $key ) {
+ $this->assertStringContainsString( $key, $inline );
+ }
+ }
+
+ /**
+ * The inline settings must be exactly the JSON encoding of
+ * wp_get_media_library_upload_settings(), the same settings source
+ * the grid integration uses.
+ *
+ * @ticket 65662
+ */
+ public function test_inline_settings_match_upload_settings() {
+ wp_enqueue_media_new_upload();
+
+ $before = wp_scripts()->get_data( 'media-upload-pipeline', 'before' );
+ $inline = implode( "\n", (array) $before );
+
+ $this->assertStringContainsString(
+ wp_json_encode( wp_get_media_library_upload_settings() ),
+ $inline
+ );
+ }
+}
diff --git a/tests/phpunit/tests/media/wpIsDocumentIsolationPolicySupported.php b/tests/phpunit/tests/media/wpIsDocumentIsolationPolicySupported.php
new file mode 100644
index 0000000000000..8765ff1290ce8
--- /dev/null
+++ b/tests/phpunit/tests/media/wpIsDocumentIsolationPolicySupported.php
@@ -0,0 +1,61 @@
+original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null;
+ }
+
+ public function tear_down() {
+ if ( null === $this->original_user_agent ) {
+ unset( $_SERVER['HTTP_USER_AGENT'] );
+ } else {
+ $_SERVER['HTTP_USER_AGENT'] = $this->original_user_agent;
+ }
+ parent::tear_down();
+ }
+
+ /**
+ * @ticket 65661
+ *
+ * @dataProvider data_user_agents
+ *
+ * @param string|null $user_agent The User-Agent header, or null for none.
+ * @param bool $expected Whether Document-Isolation-Policy is supported.
+ */
+ public function test_user_agent_support( ?string $user_agent, bool $expected ) {
+ if ( null === $user_agent ) {
+ unset( $_SERVER['HTTP_USER_AGENT'] );
+ } else {
+ $_SERVER['HTTP_USER_AGENT'] = $user_agent;
+ }
+
+ $this->assertSame( $expected, wp_is_document_isolation_policy_supported() );
+ }
+
+ /**
+ * @return array[]
+ */
+ public function data_user_agents() {
+ return array(
+ 'no user agent' => array( null, false ),
+ 'Firefox' => array( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0', false ),
+ 'Safari' => array( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', false ),
+ 'Chrome 136' => array( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', false ),
+ 'Chrome 137' => array( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36', true ),
+ 'Edge 140' => array( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0', true ),
+ );
+ }
+}
diff --git a/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php b/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php
new file mode 100644
index 0000000000000..8617a568f272e
--- /dev/null
+++ b/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php
@@ -0,0 +1,318 @@
+original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null;
+ $this->original_http_host = $_SERVER['HTTP_HOST'] ?? null;
+ $this->original_get_mode = $_GET['mode'] ?? null;
+ }
+
+ public function tear_down() {
+ if ( null === $this->original_user_agent ) {
+ unset( $_SERVER['HTTP_USER_AGENT'] );
+ } else {
+ $_SERVER['HTTP_USER_AGENT'] = $this->original_user_agent;
+ }
+
+ if ( null === $this->original_http_host ) {
+ unset( $_SERVER['HTTP_HOST'] );
+ } else {
+ $_SERVER['HTTP_HOST'] = $this->original_http_host;
+ }
+
+ if ( null === $this->original_get_mode ) {
+ unset( $_GET['mode'] );
+ } else {
+ $_GET['mode'] = $this->original_get_mode;
+ }
+
+ // Clean up any output buffers started during tests.
+ while ( ob_get_level() > 1 ) {
+ ob_end_clean();
+ }
+
+ remove_all_filters( 'wp_client_side_media_processing_enabled' );
+ unset( $GLOBALS['current_screen'] );
+ parent::tear_down();
+ }
+
+ /**
+ * Sets up the environment for the isolation happy path: a secure
+ * origin, a Chromium 137+ User-Agent, and a user who can upload.
+ */
+ private function set_up_grid_isolation_environment() {
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36';
+ $_SERVER['HTTP_HOST'] = 'localhost';
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+ }
+
+ /**
+ * The isolation callback must be wired to the screen's load hook in
+ * default-filters.php: the buffer has to start before upload.php
+ * produces any output, and none of the gating below runs at all if
+ * the hook is missing.
+ *
+ * @ticket 65661
+ */
+ public function test_hooked_to_load_upload() {
+ $this->assertSame( 10, has_action( 'load-upload.php', 'wp_set_up_cross_origin_isolation' ) );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_mode_defaults_to_grid() {
+ unset( $_GET['mode'] );
+
+ $this->assertSame( 'grid', wp_get_media_library_mode() );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_mode_from_query_string() {
+ $_GET['mode'] = 'list';
+
+ $this->assertSame( 'list', wp_get_media_library_mode() );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_invalid_query_string_mode_falls_back_to_grid() {
+ $_GET['mode'] = 'bogus';
+
+ $this->assertSame( 'grid', wp_get_media_library_mode() );
+ }
+
+ /**
+ * A non-canonical query string mode is rejected, matching upload.php.
+ *
+ * upload.php compares the raw value strictly, so `?mode=GRID` falls
+ * back to the user option rather than being normalized to `grid`.
+ *
+ * @ticket 65661
+ */
+ public function test_non_canonical_query_string_mode_falls_back_to_user_option() {
+ $user_id = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $user_id );
+ update_user_option( $user_id, 'media_library_mode', 'list' );
+
+ $_GET['mode'] = 'GRID';
+
+ $this->assertSame( 'list', wp_get_media_library_mode() );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_mode_from_user_option() {
+ unset( $_GET['mode'] );
+
+ $user_id = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $user_id );
+ update_user_option( $user_id, 'media_library_mode', 'list' );
+
+ $this->assertSame( 'list', wp_get_media_library_mode() );
+ }
+
+ /**
+ * upload.php only renders grid mode for the exact saved value 'grid', so
+ * any other saved value must be returned verbatim rather than collapsed
+ * to 'grid'. Otherwise a page that upload.php renders in list mode - and
+ * that has no client-side pipeline - would be cross-origin isolated.
+ *
+ * @ticket 65661
+ */
+ public function test_unknown_user_option_mode_is_not_treated_as_grid() {
+ unset( $_GET['mode'] );
+
+ $user_id = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $user_id );
+ update_user_option( $user_id, 'media_library_mode', 'cards' );
+
+ $this->assertNotSame( 'grid', wp_get_media_library_mode() );
+ }
+
+ /**
+ * upload.php treats any falsey saved value as unset and renders grid
+ * mode, so a saved '0' must resolve to grid here too.
+ *
+ * @ticket 65661
+ */
+ public function test_falsey_user_option_mode_falls_back_to_grid() {
+ unset( $_GET['mode'] );
+
+ $user_id = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $user_id );
+ update_user_option( $user_id, 'media_library_mode', '0' );
+
+ $this->assertSame( 'grid', wp_get_media_library_mode() );
+ }
+
+ /**
+ * upload.php renders list mode for a truthy saved value that is not the
+ * string 'grid'. Scalars come back from user meta as strings, so the only
+ * non-string a plugin can store is an array or object, and that must
+ * resolve to list rather than grid here.
+ *
+ * @ticket 65661
+ */
+ public function test_non_string_user_option_mode_resolves_to_list() {
+ unset( $_GET['mode'] );
+
+ $user_id = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $user_id );
+ update_user_option( $user_id, 'media_library_mode', array( 'grid' ) );
+
+ $this->assertSame( 'list', wp_get_media_library_mode() );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_no_buffer_for_unknown_user_option_mode() {
+ $this->set_up_grid_isolation_environment();
+ unset( $_GET['mode'] );
+
+ update_user_option( get_current_user_id(), 'media_library_mode', 'cards' );
+
+ $level_before = ob_get_level();
+ set_current_screen( 'upload' );
+ wp_set_up_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before, $level_after );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_no_buffer_when_client_side_processing_disabled() {
+ $this->set_up_grid_isolation_environment();
+ $_GET['mode'] = 'grid';
+
+ add_filter( 'wp_client_side_media_processing_enabled', '__return_false' );
+
+ $level_before = ob_get_level();
+ set_current_screen( 'upload' );
+ wp_set_up_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before, $level_after );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_no_buffer_in_list_mode() {
+ $this->set_up_grid_isolation_environment();
+ $_GET['mode'] = 'list';
+
+ $level_before = ob_get_level();
+ set_current_screen( 'upload' );
+ wp_set_up_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before, $level_after );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_no_buffer_when_logged_out() {
+ $this->set_up_grid_isolation_environment();
+ $_GET['mode'] = 'grid';
+
+ wp_set_current_user( 0 );
+
+ $level_before = ob_get_level();
+ set_current_screen( 'upload' );
+ wp_set_up_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before, $level_after );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_no_buffer_when_user_cannot_upload() {
+ $this->set_up_grid_isolation_environment();
+ $_GET['mode'] = 'grid';
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) );
+
+ $level_before = ob_get_level();
+ set_current_screen( 'upload' );
+ wp_set_up_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before, $level_after );
+ }
+
+ /**
+ * This test must run in a separate process because the output buffer
+ * callback sends HTTP headers via header(), which would fail in the
+ * main PHPUnit process where output has already started.
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ *
+ * @ticket 65661
+ */
+ public function test_starts_output_buffer_in_grid_mode_for_chromium() {
+ $this->set_up_grid_isolation_environment();
+ $_GET['mode'] = 'grid';
+
+ $level_before = ob_get_level();
+ set_current_screen( 'upload' );
+ wp_set_up_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before + 1, $level_after, 'Output buffer should be started on the grid for Chromium 137+.' );
+
+ ob_end_clean();
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_no_buffer_for_firefox() {
+ $this->set_up_grid_isolation_environment();
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0';
+ $_GET['mode'] = 'grid';
+
+ $level_before = ob_get_level();
+ set_current_screen( 'upload' );
+ wp_set_up_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before, $level_after, 'Output buffer should not be started for non-Chromium browsers.' );
+ }
+}
diff --git a/tests/phpunit/tests/media/wpMediaNewCrossOriginIsolation.php b/tests/phpunit/tests/media/wpMediaNewCrossOriginIsolation.php
new file mode 100644
index 0000000000000..dcdba3ff94a47
--- /dev/null
+++ b/tests/phpunit/tests/media/wpMediaNewCrossOriginIsolation.php
@@ -0,0 +1,158 @@
+original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null;
+ $this->original_http_host = $_SERVER['HTTP_HOST'] ?? null;
+ }
+
+ public function tear_down() {
+ if ( null === $this->original_user_agent ) {
+ unset( $_SERVER['HTTP_USER_AGENT'] );
+ } else {
+ $_SERVER['HTTP_USER_AGENT'] = $this->original_user_agent;
+ }
+
+ if ( null === $this->original_http_host ) {
+ unset( $_SERVER['HTTP_HOST'] );
+ } else {
+ $_SERVER['HTTP_HOST'] = $this->original_http_host;
+ }
+
+ // Clean up any output buffers started during tests.
+ while ( ob_get_level() > 1 ) {
+ ob_end_clean();
+ }
+
+ remove_all_filters( 'wp_client_side_media_processing_enabled' );
+ unset( $GLOBALS['current_screen'] );
+ parent::tear_down();
+ }
+
+ /**
+ * Sets up the environment for the isolation happy path: a secure
+ * origin, a Chromium 137+ User-Agent, and a user who can upload.
+ */
+ private function set_up_isolation_environment() {
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36';
+ $_SERVER['HTTP_HOST'] = 'localhost';
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+ }
+
+ /**
+ * The isolation callback must be wired to the screen's load hook in
+ * default-filters.php: the buffer has to start before media-new.php
+ * produces any output, and none of the gating below runs at all if
+ * the hook is missing.
+ *
+ * @ticket 65662
+ */
+ public function test_hooked_to_load_media_new() {
+ $this->assertSame( 10, has_action( 'load-media-new.php', 'wp_set_up_cross_origin_isolation' ) );
+ }
+
+ /**
+ * @ticket 65662
+ */
+ public function test_no_buffer_when_client_side_processing_disabled() {
+ $this->set_up_isolation_environment();
+
+ add_filter( 'wp_client_side_media_processing_enabled', '__return_false' );
+
+ $level_before = ob_get_level();
+ set_current_screen( 'media' );
+ wp_set_up_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before, $level_after );
+ }
+
+ /**
+ * @ticket 65662
+ */
+ public function test_no_buffer_when_logged_out() {
+ $this->set_up_isolation_environment();
+
+ wp_set_current_user( 0 );
+
+ $level_before = ob_get_level();
+ set_current_screen( 'media' );
+ wp_set_up_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before, $level_after );
+ }
+
+ /**
+ * @ticket 65662
+ */
+ public function test_no_buffer_when_user_cannot_upload() {
+ $this->set_up_isolation_environment();
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) );
+
+ $level_before = ob_get_level();
+ set_current_screen( 'media' );
+ wp_set_up_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before, $level_after );
+ }
+
+ /**
+ * This test must run in a separate process because the output buffer
+ * callback sends HTTP headers via header(), which would fail in the
+ * main PHPUnit process where output has already started.
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ *
+ * @ticket 65662
+ */
+ public function test_starts_output_buffer_for_chromium() {
+ $this->set_up_isolation_environment();
+
+ $level_before = ob_get_level();
+ set_current_screen( 'media' );
+ wp_set_up_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before + 1, $level_after, 'Output buffer should be started on media-new.php for Chromium 137+.' );
+
+ ob_end_clean();
+ }
+
+ /**
+ * @ticket 65662
+ */
+ public function test_no_buffer_for_firefox() {
+ $this->set_up_isolation_environment();
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0';
+
+ $level_before = ob_get_level();
+ set_current_screen( 'media' );
+ wp_set_up_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before, $level_after, 'Output buffer should not be started for non-Chromium browsers.' );
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index c583f18b34ddd..84be3e5782fa5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -21,12 +21,16 @@
"types": [
"node",
"wp-globals",
+ "media-uploads",
"codemirror/addon/lint/lint",
"codemirror/addon/hint/show-hint"
]
},
"include": [],
"files": [
+ "src/js/_enqueues/admin/media-library-upload.js",
+ "src/js/_enqueues/admin/media-new-upload.js",
+ "src/js/_enqueues/admin/media-upload-pipeline.js",
"src/js/_enqueues/lib/codemirror/htmlhint-kses.js",
"src/js/_enqueues/lib/codemirror/javascript-lint.js",
"src/js/_enqueues/wp/code-editor.js",
diff --git a/typings/media-uploads/index.d.ts b/typings/media-uploads/index.d.ts
new file mode 100644
index 0000000000000..a05798e4ce988
--- /dev/null
+++ b/typings/media-uploads/index.d.ts
@@ -0,0 +1,103 @@
+/**
+ * Minimal typings for the admin upload globals: the bundled plupload library,
+ * the helpers plupload-handlers.js exposes, and the flags the client-side
+ * media upload scripts set on `window`.
+ *
+ * Only the surface those scripts rely on is described.
+ */
+
+declare namespace plupload {
+ /**
+ * A file queued in a plupload uploader.
+ */
+ interface File {
+ id: string;
+ name: string;
+ type: string;
+ size: number;
+ loaded: number;
+ percent: number;
+ status: number;
+
+ /**
+ * The underlying native File, or null when the active runtime (html4,
+ * say) cannot expose one.
+ */
+ getNative(): globalThis.File | null;
+ }
+
+ /**
+ * A plupload uploader instance.
+ */
+ interface Uploader {
+ settings?: {
+ multipart_params?: Record< string, string >;
+ [ setting: string ]: unknown;
+ };
+
+ bind(
+ name: 'FilesAdded',
+ callback: ( up: Uploader, files: File[] ) => unknown,
+ context?: unknown,
+ priority?: number
+ ): void;
+ bind(
+ name: string,
+ callback: ( ...args: any[] ) => unknown,
+ context?: unknown,
+ priority?: number
+ ): void;
+ removeFile( file: File ): void;
+ refresh(): void;
+
+ /**
+ * Admin upload scripts flag an uploader they have already intercepted.
+ */
+ [ property: string ]: unknown;
+ }
+
+ /**
+ * Status of a file that could not be queued.
+ */
+ const FAILED: number;
+}
+
+declare var pluploadL10n: Record< string, string >;
+
+/**
+ * The uploader plupload-handlers.js creates on wp-admin/media-new.php.
+ *
+ * Undefined when the browser uploader was not initialized.
+ */
+declare var uploader: plupload.Uploader | undefined;
+
+declare function fileQueued( file: plupload.File ): void;
+declare function uploadStart(): void;
+declare function uploadSuccess( file: plupload.File, serverId: string ): void;
+declare function uploadComplete(): void;
+
+interface Window {
+ /**
+ * Settings printed for the media-upload-pipeline script.
+ */
+ _wpMediaUploadPipelineSettings?: {
+ maxUploadFileSize?: number;
+ allowedMimeTypes?: Record< string, string > | null;
+ allImageSizes?: Record< string, unknown >;
+ bigImageSizeThreshold?: number | false;
+ imageStripMeta?: boolean;
+ imageMaxBitDepth?: number;
+ };
+
+ /**
+ * Set once the media-upload-pipeline script has configured the store, and
+ * read by the media-utils package.
+ */
+ __clientSideMediaProcessing?: boolean;
+
+ /**
+ * Guards against a screen upload script running twice.
+ */
+ __wpMediaLibraryUpload?: boolean;
+ __wpMediaNewUpload?: boolean;
+}