diff --git a/Gruntfile.js b/Gruntfile.js
index 61f18481e23a8..87fc4dc4cd1e0 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -527,6 +527,8 @@ 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.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' ],
@@ -1270,6 +1272,8 @@ 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.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..ac43ae448cd11
--- /dev/null
+++ b/src/js/_enqueues/admin/media-library-upload.js
@@ -0,0 +1,548 @@
+/**
+ * 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 routes files through
+ * @wordpress/upload-media instead: the original image is uploaded via the
+ * REST API and thumbnails are generated in the browser (wasm-vips), then
+ * sideloaded and finalized.
+ *
+ * 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 */
+
+( function () {
+ // Guard against double execution (e.g. duplicate enqueues).
+ if ( window.__wpMediaLibraryUpload ) {
+ return;
+ }
+
+ // Require every dependency the integration relies on.
+ if (
+ typeof wp === 'undefined' ||
+ typeof plupload === 'undefined' ||
+ ! wp.Uploader ||
+ ! wp.uploadMedia ||
+ ! wp.mediaUtils ||
+ ! wp.data ||
+ ! wp.element ||
+ ! wp.apiFetch ||
+ ! wp.media
+ ) {
+ return;
+ }
+
+ // 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 (
+ ! wp.uploadMedia.detectClientSideMediaSupport ||
+ ! wp.uploadMedia.detectClientSideMediaSupport().supported
+ ) {
+ return;
+ }
+
+ window.__wpMediaLibraryUpload = true;
+
+ /*
+ * @wordpress/media-utils 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;
+
+ var __ = wp.i18n.__;
+ var settings = window._wpMediaLibraryUploadSettings || {};
+ var uploadStore = wp.uploadMedia.store;
+
+ // Map from a file identity key to the placeholder Attachment models
+ // (an array: concurrent uploads of an identical file share a key), used
+ // to reflect pipeline progress back onto the grid tiles.
+ var progressModels = new Map();
+
+ /**
+ * 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.
+ * Two in-flight uploads of the same file collide on this key, so keys
+ * map to arrays of models and progress is mirrored to all of them.
+ *
+ * @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 @wordpress/media-utils.
+ *
+ * @param {FormData} formData The form data to append to.
+ * @param {string} key The key to append under.
+ * @param {string|Object} 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
+ * @wordpress/media-utils as a thin apiFetch wrapper.
+ *
+ * @param {Object} args The sideload arguments.
+ */
+ function mediaSideload( args ) {
+ var file = args.file;
+ var additionalData = args.additionalData || {};
+
+ var 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 ( subSize ) {
+ if ( args.onSuccess ) {
+ args.onSuccess( subSize );
+ }
+ } )
+ .catch( function ( error ) {
+ if ( args.onError ) {
+ var normalized = error;
+ if ( ! ( error instanceof Error ) ) {
+ normalized = new Error(
+ error && error.message ? error.message : String( error )
+ );
+ }
+ args.onError( 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 {Array} 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 ( 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',
+ } );
+ }
+
+ /**
+ * 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 {Error} 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 ) {
+ var errorCodes = wp.uploadMedia.ErrorCode || {};
+ var code = error && error.code;
+ var 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.' )
+ );
+ }
+
+ // Configure the default-registry upload-media store 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).
+ var pipelineSettings = {
+ 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,
+ };
+
+ wp.element
+ .createRoot( document.createElement( 'div' ) )
+ .render(
+ wp.element.createElement( wp.uploadMedia.MediaUploadProvider, {
+ settings: pipelineSettings,
+ useSubRegistry: false,
+ } )
+ );
+
+ /**
+ * Resets the upload queue once every attachment has finished uploading.
+ *
+ * Parity with wp-plupload.js so browse mode flips back when done.
+ */
+ function maybeResetQueue() {
+ var complete = wp.Uploader.queue.all( function ( attachment ) {
+ return ! attachment.get( 'uploading' );
+ } );
+
+ if ( complete ) {
+ wp.Uploader.queue.reset();
+ }
+ }
+
+ /**
+ * Removes a model from the progress map.
+ *
+ * @param {Object} model The Attachment model to stop tracking.
+ */
+ function stopTrackingProgress( model ) {
+ progressModels.forEach( function ( models, key ) {
+ var index = models.indexOf( model );
+ if ( index !== -1 ) {
+ models.splice( index, 1 );
+ }
+ if ( models.length === 0 ) {
+ progressModels.delete( key );
+ }
+ } );
+ }
+
+ /**
+ * Handles a completed upload by syncing the grid tile with the server data.
+ *
+ * @param {Object} wpUploader The wp.Uploader instance that queued the file.
+ * @param {Object} model The placeholder Attachment model.
+ * @param {Object} 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 () {
+ stopTrackingProgress( model );
+ 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.
+ *
+ * @param {Object} wpUploader The wp.Uploader instance that queued the file.
+ * @param {Object} model The placeholder Attachment model.
+ * @param {Error} error The upload error.
+ * @param {File} nativeFile The original file (for the error label).
+ */
+ function handleError( wpUploader, model, error, nativeFile ) {
+ var message = getErrorText( error, nativeFile.name );
+ var file = { name: nativeFile.name };
+
+ model.destroy();
+
+ wp.Uploader.errors.unshift( {
+ message: message,
+ data: {},
+ file: file,
+ } );
+
+ stopTrackingProgress( model );
+ maybeResetQueue();
+
+ // Parity with wp-plupload.js, which exposes this callback so other
+ // code can react to a failed upload.
+ wpUploader.error( message, {}, file );
+ }
+
+ /**
+ * Dispatches a single file into the client-side pipeline.
+ *
+ * @param {Object} wpUploader The wp.Uploader instance that queued the file.
+ * @param {File} nativeFile The original file to upload.
+ * @param {Object} model The placeholder Attachment model.
+ * @param {Object} additionalData Extra fields to send with the attachment.
+ */
+ function uploadFile( wpUploader, nativeFile, model, additionalData ) {
+ wp.data.dispatch( uploadStore ).addItems( {
+ files: [ nativeFile ],
+ additionalData: additionalData,
+ onSuccess: function ( attachments ) {
+ handleSuccess( wpUploader, model, attachments[ 0 ] );
+ },
+ onError: function ( error ) {
+ handleError( wpUploader, model, error, nativeFile );
+ },
+ } );
+ }
+
+ /**
+ * Intercepts files added to a plupload uploader.
+ *
+ * Returns undefined (not false) when the store is not yet configured 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 {Object} wpUploader The wp.Uploader instance.
+ * @param {Object} up The plupload uploader instance.
+ * @param {Array} files Files added to the queue.
+ * @return {boolean|undefined} False to suppress the built-in handler.
+ */
+ function handleFilesAdded( wpUploader, up, files ) {
+ var storeSettings = wp.data.select( uploadStore ).getSettings();
+
+ // Safety valve: if settings never landed, defer to classic plupload.
+ if ( ! storeSettings || ! storeSettings.mediaUpload ) {
+ return;
+ }
+
+ // The pipeline works on native File objects, and plupload returns
+ // null for sources it cannot expose as one (the html4 runtime, say).
+ // Hand the whole batch back to classic plupload rather than strand
+ // part of it: suppressing the built-in handler is all-or-nothing.
+ var unusable = files.some( function ( file ) {
+ return (
+ plupload.FAILED !== file.status &&
+ ( ! file.getNative || ! file.getNative() )
+ );
+ } );
+
+ if ( unusable ) {
+ return;
+ }
+
+ // The classic flow attaches uploads to the post the uploader was
+ // opened for by posting `post_id` to async-upload.php; the REST API
+ // spells the same thing `post`.
+ var params = ( up.settings && up.settings.multipart_params ) || {};
+ var parentPostId = parseInt( params.post_id, 10 ) || 0;
+ var additionalData = parentPostId ? { post: parentPostId } : {};
+
+ 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.
+ var 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,
+ };
+
+ var image = /(?:jpe?g|png|gif)$/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 ] ? 'jpeg' : image[ 0 ];
+ }
+
+ var model = wp.media.model.Attachment.create( attributes );
+ wp.Uploader.queue.add( model );
+ wpUploader.added( model );
+
+ var nativeFile = file.getNative();
+ var key = fileKey( nativeFile );
+ var models = progressModels.get( key );
+ if ( models ) {
+ models.push( model );
+ } else {
+ progressModels.set( key, [ model ] );
+ }
+
+ // Remove the file from plupload so it is not uploaded twice.
+ up.removeFile( file );
+
+ uploadFile( wpUploader, nativeFile, model, additionalData );
+ } );
+
+ 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.
+ var originalInit = wp.Uploader.prototype.init;
+ wp.Uploader.prototype.init = function () {
+ originalInit.apply( this, arguments );
+
+ var wpUploader = this;
+ var up = 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
+ );
+ };
+
+ // Warn before leaving while pipeline 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. Models stay in the progress map from interception
+ // until success or error, so the map doubles as the in-flight signal.
+ window.addEventListener( 'beforeunload', function ( event ) {
+ if ( progressModels.size > 0 ) {
+ event.preventDefault();
+ // Some Chromium versions only show the prompt for returnValue.
+ event.returnValue = '';
+ }
+ } );
+
+ // Reflect pipeline progress onto the placeholder tiles. Progress is
+ // reported 0-100; hold at 99 until the model is marked done so the tile
+ // does not appear finished before the sync completes.
+ wp.data.subscribe( function () {
+ if ( progressModels.size === 0 ) {
+ return;
+ }
+
+ var items = wp.data.select( uploadStore ).getItems();
+ items.forEach( function ( item ) {
+ if ( ! item.sourceFile ) {
+ return;
+ }
+
+ var models = progressModels.get( fileKey( item.sourceFile ) );
+ if ( ! models ) {
+ return;
+ }
+
+ if ( typeof item.progress === 'number' ) {
+ var percent = Math.min( 99, Math.round( item.progress ) );
+ models.forEach( function ( model ) {
+ model.set( { percent: percent } );
+ } );
+ }
+ } );
+ } );
+} )();
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..96251f9976e21
--- /dev/null
+++ b/src/js/_enqueues/admin/media-new-upload.js
@@ -0,0 +1,475 @@
+/**
+ * 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 routes files
+ * through @wordpress/upload-media 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),
+ * itemAjaxError() surfaces per-file errors, and uploadComplete() runs when
+ * the queue drains. The screen therefore 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, uploader, fileQueued, uploadStart, uploadSuccess, itemAjaxError, uploadComplete */
+
+( function () {
+ // Guard against double execution (e.g. duplicate enqueues).
+ if ( window.__wpMediaNewUpload ) {
+ return;
+ }
+
+ // Require every dependency the integration relies on.
+ if (
+ typeof wp === 'undefined' ||
+ typeof plupload === 'undefined' ||
+ typeof jQuery === 'undefined' ||
+ ! wp.uploadMedia ||
+ ! wp.mediaUtils ||
+ ! wp.data ||
+ ! wp.element ||
+ ! wp.apiFetch
+ ) {
+ return;
+ }
+
+ // 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 (
+ ! wp.uploadMedia.detectClientSideMediaSupport ||
+ ! wp.uploadMedia.detectClientSideMediaSupport().supported
+ ) {
+ return;
+ }
+
+ window.__wpMediaNewUpload = true;
+
+ /*
+ * @wordpress/media-utils 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;
+
+ var __ = wp.i18n.__;
+ var settings = window._wpMediaNewUploadSettings || {};
+ var uploadStore = wp.uploadMedia.store;
+
+ // Number of pipeline uploads currently in flight, used for the
+ // beforeunload guard and to fire uploadComplete() when the queue drains.
+ var inFlightCount = 0;
+
+ // Map from a file identity key to the plupload file IDs sharing it
+ // (an array: concurrent uploads of an identical file share a key), used
+ // to reflect pipeline progress onto the screen's progress bars.
+ var progressIds = new Map();
+
+ /**
+ * 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.
+ * Two in-flight uploads of the same file collide on this key, so keys
+ * map to arrays of IDs and progress is mirrored to all of them.
+ *
+ * @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 @wordpress/media-utils.
+ *
+ * @param {FormData} formData The form data to append to.
+ * @param {string} key The key to append under.
+ * @param {string|Object} 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
+ * @wordpress/media-utils as a thin apiFetch wrapper.
+ *
+ * @param {Object} args The sideload arguments.
+ */
+ function mediaSideload( args ) {
+ var file = args.file;
+ var additionalData = args.additionalData || {};
+
+ var 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 ( subSize ) {
+ if ( args.onSuccess ) {
+ args.onSuccess( subSize );
+ }
+ } )
+ .catch( function ( error ) {
+ if ( args.onError ) {
+ var normalized = error;
+ if ( ! ( error instanceof Error ) ) {
+ normalized = new Error(
+ error && error.message ? error.message : String( error )
+ );
+ }
+ args.onError( 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 {Array} 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 ( 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',
+ } );
+ }
+
+ /**
+ * 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 {Error} 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 ) {
+ var errorCodes = wp.uploadMedia.ErrorCode || {};
+ var code = error && error.code;
+ var 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.' )
+ );
+ }
+
+ // Configure the default-registry upload-media store 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).
+ var pipelineSettings = {
+ 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,
+ };
+
+ wp.element
+ .createRoot( document.createElement( 'div' ) )
+ .render(
+ wp.element.createElement( wp.uploadMedia.MediaUploadProvider, {
+ settings: pipelineSettings,
+ useSubRegistry: false,
+ } )
+ );
+
+ /**
+ * Removes a plupload file ID from the progress map.
+ *
+ * @param {string} fileId The plupload file ID to stop tracking.
+ */
+ function stopTrackingProgress( fileId ) {
+ progressIds.forEach( function ( ids, key ) {
+ var index = ids.indexOf( fileId );
+ if ( index !== -1 ) {
+ ids.splice( index, 1 );
+ }
+ if ( ids.length === 0 ) {
+ progressIds.delete( key );
+ }
+ } );
+ }
+
+ /**
+ * 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();
+ }
+ }
+
+ /**
+ * Intercepts files added to the plupload uploader.
+ *
+ * Returns undefined (not false) when the store is not yet configured 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 {Object} up The plupload uploader instance.
+ * @param {Array} files Files added to the queue.
+ * @return {boolean|undefined} False to suppress the built-in handler.
+ */
+ function handleFilesAdded( up, files ) {
+ var storeSettings = wp.data.select( uploadStore ).getSettings();
+
+ // Safety valve: if settings never landed, defer to classic plupload.
+ if ( ! storeSettings || ! storeSettings.mediaUpload ) {
+ return;
+ }
+
+ // The pipeline works on native File objects, and plupload returns
+ // null for sources it cannot expose as one (the html4 runtime, say).
+ // Hand the whole batch back to classic plupload rather than strand
+ // part of it: suppressing the built-in handler is all-or-nothing.
+ var unusable = files.some( function ( file ) {
+ return (
+ plupload.FAILED !== file.status &&
+ ( ! file.getNative || ! file.getNative() )
+ );
+ } );
+
+ if ( unusable ) {
+ 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 attaches uploads to the post named by `post_id` in
+ // the query string by posting it to async-upload.php; the REST API
+ // spells the same thing `post`. Without it a file uploaded from
+ // media-new.php?post_id=N lands unattached even though the screen
+ // counts it against that post.
+ var params = ( up.settings && up.settings.multipart_params ) || {};
+ var parentPostId = parseInt( params.post_id, 10 ) || 0;
+ var additionalData = parentPostId ? { post: parentPostId } : {};
+
+ files.forEach( function ( file ) {
+ // Ignore failed uploads.
+ if ( plupload.FAILED === file.status ) {
+ return;
+ }
+
+ // Build the screen's progress item for this file.
+ fileQueued( file );
+
+ var nativeFile = file.getNative();
+ var key = fileKey( nativeFile );
+ var ids = progressIds.get( key );
+ if ( ids ) {
+ ids.push( file.id );
+ } else {
+ progressIds.set( key, [ file.id ] );
+ }
+
+ // Remove the file from plupload so it is not uploaded twice.
+ up.removeFile( file );
+
+ inFlightCount++;
+
+ wp.data.dispatch( uploadStore ).addItems( {
+ files: [ nativeFile ],
+ additionalData: additionalData,
+ onSuccess: function ( attachments ) {
+ // 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( attachments[ 0 ].id ) );
+ stopTrackingProgress( file.id );
+ finishUpload();
+ },
+ onError: function ( error ) {
+ /*
+ * itemAjaxError() writes the message straight into the
+ * item's HTML, and the message carries the file name, so
+ * escape it here rather than hand a user-supplied name to
+ * an HTML sink.
+ */
+ var message = jQuery( '
' )
+ .text( getErrorText( error, nativeFile.name ) )
+ .html();
+
+ itemAjaxError( file.id, message );
+ stopTrackingProgress( file.id );
+ finishUpload();
+ },
+ } );
+ } );
+
+ 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
+ );
+ } );
+
+ // Warn before leaving while pipeline 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 ( inFlightCount > 0 ) {
+ event.preventDefault();
+ // Some Chromium versions only show the prompt for returnValue.
+ event.returnValue = '';
+ }
+ } );
+
+ // Reflect pipeline progress onto the screen's progress bars. Progress is
+ // reported 0-100; hold at 99 until the finished row is rendered so the
+ // bar does not appear done before the markup fetch completes. The bar is
+ // 200px wide at 100%, matching uploadProgress() in plupload-handlers.
+ wp.data.subscribe( function () {
+ if ( progressIds.size === 0 ) {
+ return;
+ }
+
+ var items = wp.data.select( uploadStore ).getItems();
+ items.forEach( function ( item ) {
+ if ( ! item.sourceFile || typeof item.progress !== 'number' ) {
+ return;
+ }
+
+ var ids = progressIds.get( fileKey( item.sourceFile ) );
+ if ( ! ids ) {
+ return;
+ }
+
+ var percent = Math.min( 99, Math.round( item.progress ) );
+ ids.forEach( function ( id ) {
+ var mediaItem = jQuery( '#media-item-' + id );
+ mediaItem.find( '.bar' ).width( 2 * percent );
+ mediaItem.find( '.percent' ).html( percent + '%' );
+ } );
+ } );
+ } );
+} )();
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 ea6fee0dab3ad..a5e335028afbe 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -699,6 +699,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_media_library_cross_origin_isolation' );
+add_action( 'load-media-new.php', 'wp_set_up_media_new_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 a31e77b00e26c..9fab554d7be8d 100644
--- a/src/wp-includes/media.php
+++ b/src/wp-includes/media.php
@@ -6691,6 +6691,188 @@ 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 only when no mode is saved, and renders
+ * grid mode only for the exact value 'grid'. A saved value outside the two
+ * known modes - one a plugin stored, say - therefore renders list mode there
+ * and is returned verbatim 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() );
+
+ return ( is_string( $mode ) && '' !== $mode ) ? $mode : 'grid';
+}
+
+/**
+ * Enables cross-origin isolation in the Media Library grid.
+ *
+ * Required for enabling SharedArrayBuffer for WebAssembly-based
+ * media processing when uploading via the Media Library grid.
+ * List mode has no client-side pipeline integration and is not
+ * isolated.
+ *
+ * @since 7.2.0
+ */
+function wp_set_up_media_library_cross_origin_isolation(): void {
+ if ( ! wp_is_client_side_media_processing_enabled() ) {
+ return;
+ }
+
+ if ( 'grid' !== wp_get_media_library_mode() ) {
+ return;
+ }
+
+ // Cross-origin isolation is not needed if users can't upload files anyway.
+ if ( ! current_user_can( 'upload_files' ) ) {
+ return;
+ }
+
+ wp_start_cross_origin_isolation_output_buffer();
+}
+
+/**
+ * Enables cross-origin isolation on the "Add New Media File" screen.
+ *
+ * Required for enabling SharedArrayBuffer for WebAssembly-based
+ * media processing when uploading via wp-admin/media-new.php.
+ *
+ * @since 7.2.0
+ */
+function wp_set_up_media_new_cross_origin_isolation(): void {
+ if ( ! wp_is_client_side_media_processing_enabled() ) {
+ return;
+ }
+
+ // Cross-origin isolation is not needed if users can't upload files anyway.
+ if ( ! current_user_can( 'upload_files' ) ) {
+ return;
+ }
+
+ wp_start_cross_origin_isolation_output_buffer();
+}
+
+/**
+ * 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 the script that routes Media Library grid uploads through
+ * the client-side media processing pipeline.
+ *
+ * The script self-guards: when the browser is not cross-origin isolated
+ * or lacks client-side media support, it no-ops and the classic plupload
+ * flow keeps handling uploads.
+ *
+ * @since 7.2.0
+ */
+function wp_enqueue_media_library_upload(): void {
+ if ( ! wp_is_client_side_media_processing_enabled() ) {
+ return;
+ }
+
+ $chromium_version = wp_get_chromium_major_version();
+ if ( null === $chromium_version || $chromium_version < 137 ) {
+ return;
+ }
+
+ wp_enqueue_script( 'media-library-upload' );
+
+ wp_add_inline_script(
+ 'media-library-upload',
+ 'window._wpMediaLibraryUploadSettings = ' . wp_json_encode( wp_get_media_library_upload_settings() ) . ';',
+ 'before'
+ );
+}
+
+/**
+ * Enqueues the script that routes "Add New Media File" screen uploads
+ * through the client-side media processing pipeline.
+ *
+ * The script self-guards: when the browser is not cross-origin isolated
+ * or lacks client-side media support, it no-ops and the classic plupload
+ * flow keeps handling uploads.
+ *
+ * @since 7.2.0
+ */
+function wp_enqueue_media_new_upload(): void {
+ if ( ! wp_is_client_side_media_processing_enabled() ) {
+ return;
+ }
+
+ /*
+ * Cross-origin isolation relies on Document-Isolation-Policy, which only
+ * Chromium 137+ honors. Without it the page is never isolated, the script
+ * no-ops, and the whole wp-upload-media dependency chain would be loaded
+ * for nothing.
+ */
+ $chromium_version = wp_get_chromium_major_version();
+ if ( null === $chromium_version || $chromium_version < 137 ) {
+ return;
+ }
+
+ wp_enqueue_script( 'media-new-upload' );
+
+ wp_add_inline_script(
+ 'media-new-upload',
+ 'window._wpMediaNewUploadSettings = ' . wp_json_encode( wp_get_media_library_upload_settings() ) . ';',
+ 'before'
+ );
+}
+
/**
* Sends the Document-Isolation-Policy header for cross-origin isolation.
*
diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php
index b5df9291ef354..7fc53ec4ca958 100644
--- a/src/wp-includes/script-loader.php
+++ b/src/wp-includes/script-loader.php
@@ -1515,6 +1515,12 @@ 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-library-upload', "/wp-admin/js/media-library-upload$suffix.js", array( 'media-views', 'wp-upload-media', 'wp-media-utils', 'wp-api-fetch', 'wp-data', 'wp-element', 'wp-i18n' ), false, 1 );
+ $scripts->set_translations( 'media-library-upload' );
+
+ $scripts->add( 'media-new-upload', "/wp-admin/js/media-new-upload$suffix.js", array( 'plupload-handlers', 'wp-upload-media', 'wp-media-utils', 'wp-api-fetch', 'wp-data', 'wp-element', '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-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..baa64ccfd95c0
--- /dev/null
+++ b/tests/e2e/specs/media-library-client-side-upload.test.js
@@ -0,0 +1,244 @@
+/**
+ * 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' );
+
+// 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"]';
+
+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();
+ } );
+
+ 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 reason has to be readable: getErrorMessage() returns an object,
+ // so handing it straight to the UI renders "[object Object]". The
+ // message span holds only the message, not the file-name heading.
+ const message = await page
+ .locator( '.upload-error-message' )
+ .first()
+ .innerText();
+ expect( message ).not.toContain( '[object Object]' );
+ expect( message ).toContain( 'disallowed.xyz' );
+ } );
+} );
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..914154bebcdff
--- /dev/null
+++ b/tests/e2e/specs/media-new-client-side-upload.test.js
@@ -0,0 +1,273 @@
+/**
+ * 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' );
+
+// 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"]';
+
+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 );
+ } );
+} );
diff --git a/tests/e2e/specs/media-upload.test.js b/tests/e2e/specs/media-upload.test.js
index 885dee92c915d..69d048a53d796 100644
--- a/tests/e2e/specs/media-upload.test.js
+++ b/tests/e2e/specs/media-upload.test.js
@@ -27,8 +27,11 @@ test( 'Test dismissing failed upload works correctly', async ({ page, admin, req
page.getByText('“sample.svg” has failed to upload.')
).toBeVisible();
- // Ensure the error message is dismissed.
- await page.getByRole('button', { name: 'Dismiss' }).click();
+ // Ensure the error message is dismissed. The dismiss control is a button
+ // in the server-rendered async-upload.php error notice and a link in the
+ // standard per-file error UI, which client-side rejections (including the
+ // client-side media pipeline) render.
+ await page.locator('.media-item .dismiss').click();
await expect(
page.getByText('“sample.svg” has failed to upload.')
).not.toBeVisible();
diff --git a/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php b/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php
new file mode 100644
index 0000000000000..cdecf5ef06cc2
--- /dev/null
+++ b/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php
@@ -0,0 +1,251 @@
+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 wp-upload-media, 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( 'wp-upload-media', $script->deps );
+ $this->assertNotContains( 'wp-block-editor', $script->deps );
+ }
+
+ /**
+ * @ticket 65661
+ */
+ public function test_inline_settings_expose_all_keys() {
+ wp_enqueue_media_library_upload();
+
+ $before = wp_scripts()->get_data( 'media-library-upload', 'before' );
+ $inline = implode( "\n", (array) $before );
+
+ $this->assertStringContainsString( 'window._wpMediaLibraryUploadSettings', $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-library-upload', '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..7b8c2e7b71594
--- /dev/null
+++ b/tests/phpunit/tests/media/wpEnqueueMediaNewUpload.php
@@ -0,0 +1,178 @@
+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 wp-upload-media, 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( 'wp-upload-media', $script->deps );
+ $this->assertNotContains( 'media-views', $script->deps );
+ $this->assertNotContains( 'wp-block-editor', $script->deps );
+ }
+
+ /**
+ * @ticket 65662
+ */
+ public function test_inline_settings_expose_all_keys() {
+ wp_enqueue_media_new_upload();
+
+ $before = wp_scripts()->get_data( 'media-new-upload', 'before' );
+ $inline = implode( "\n", (array) $before );
+
+ $this->assertStringContainsString( 'window._wpMediaNewUploadSettings', $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-new-upload', '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/wpMediaLibraryCrossOriginIsolation.php b/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php
new file mode 100644
index 0000000000000..b1b49d475e228
--- /dev/null
+++ b/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php
@@ -0,0 +1,276 @@
+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' );
+ 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_media_library_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() );
+ }
+
+ /**
+ * @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();
+ wp_set_up_media_library_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();
+ wp_set_up_media_library_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();
+ wp_set_up_media_library_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();
+ wp_set_up_media_library_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();
+ wp_set_up_media_library_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();
+ wp_set_up_media_library_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();
+ wp_set_up_media_library_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..c44278c2ccb01
--- /dev/null
+++ b/tests/phpunit/tests/media/wpMediaNewCrossOriginIsolation.php
@@ -0,0 +1,152 @@
+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' );
+ 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_media_new_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();
+ wp_set_up_media_new_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();
+ wp_set_up_media_new_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();
+ wp_set_up_media_new_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();
+ wp_set_up_media_new_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();
+ wp_set_up_media_new_cross_origin_isolation();
+ $level_after = ob_get_level();
+
+ $this->assertSame( $level_before, $level_after, 'Output buffer should not be started for non-Chromium browsers.' );
+ }
+}