From 5d095a5b40ce14788e7322fe676bda5093bb7281 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 4 Sep 2026 12:26:53 -0700 Subject: [PATCH 01/14] Media: Route media modal uploads through the client-side pipeline Uploads the editor starts itself go through the block editor's mediaUpload setting, which the provider swaps for the @wordpress/upload-media pipeline when client-side processing is available. The media modal is Backbone wp.media, and its uploader is core's wp.Uploader/plupload, which nothing intercepts, so it posts the original bytes to async-upload.php. The same HEIC file converts and uploads on an Image block but fails in the modal, and files uploaded there skip browser-generated sub-sizes, the big-image threshold and animated GIF handling. Bind a higher-priority FilesAdded handler on every wp.Uploader instance and hand the files to the same pipeline, mirroring plupload's placeholder attachments, progress and wp.Uploader.errors so the modal's UI works unchanged. The batch falls back to plupload whenever the pipeline is unavailable or cannot take every file in it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HMK7kaTqz41w6rTnjiP9KC --- package-lock.json | 1 + packages/media-utils/package.json | 1 + .../src/components/media-upload/index.js | 8 + .../src/utils/client-side-modal-uploads.ts | 615 ++++++++++++++++++ packages/media-utils/tsconfig.build.json | 1 + packages/media-utils/tsconfig.json | 1 + 6 files changed, 627 insertions(+) create mode 100644 packages/media-utils/src/utils/client-side-modal-uploads.ts diff --git a/package-lock.json b/package-lock.json index 3948d8faa9504f..7d9467591029a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53210,6 +53210,7 @@ "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", "@wordpress/ui": "file:../ui", + "@wordpress/upload-media": "file:../upload-media", "@wordpress/views": "file:../views", "clsx": "^2.1.1" }, diff --git a/packages/media-utils/package.json b/packages/media-utils/package.json index 0addb7824623fb..461c7945fa96a4 100644 --- a/packages/media-utils/package.json +++ b/packages/media-utils/package.json @@ -61,6 +61,7 @@ "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", "@wordpress/ui": "file:../ui", + "@wordpress/upload-media": "file:../upload-media", "@wordpress/views": "file:../views", "clsx": "^2.1.1" }, diff --git a/packages/media-utils/src/components/media-upload/index.js b/packages/media-utils/src/components/media-upload/index.js index 3b888346ad3284..f6ebf11b392594 100644 --- a/packages/media-utils/src/components/media-upload/index.js +++ b/packages/media-utils/src/components/media-upload/index.js @@ -2,6 +2,7 @@ import { Component } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { select, dispatch } from '@wordpress/data'; import { invalidateAttachmentResolutions } from '../../utils/invalidate-attachment-resolutions'; +import { installClientSideModalUploads } from '../../utils/client-side-modal-uploads'; const DEFAULT_EMPTY_GALLERY = []; @@ -563,6 +564,13 @@ class MediaUpload extends Component { } openModal() { + // Route the modal's own uploads through the client-side media + // pipeline when it is available, so a file uploaded here is processed + // the same way as one dropped on a block. Installed here rather than + // at import time because it patches `wp.Uploader`, which the media + // scripts only define once they have run. + installClientSideModalUploads(); + const { gallery = false, unstableFeaturedImageFlow = false, diff --git a/packages/media-utils/src/utils/client-side-modal-uploads.ts b/packages/media-utils/src/utils/client-side-modal-uploads.ts new file mode 100644 index 00000000000000..f16e47e43d2d57 --- /dev/null +++ b/packages/media-utils/src/utils/client-side-modal-uploads.ts @@ -0,0 +1,615 @@ +import { __ } from '@wordpress/i18n'; +import { dispatch, select, subscribe } from '@wordpress/data'; +import { + store as uploadStore, + detectClientSideMediaSupport, + isHeicCanvasSupported, +} from '@wordpress/upload-media'; + +/** + * Routes uploads started from the editor's media modal through the client-side + * media pipeline. + * + * Uploads the editor itself starts - dropping a file on a block, the inserter, + * the Upload button - go through the block editor's `mediaUpload` setting, + * which the block editor provider swaps for the `@wordpress/upload-media` + * pipeline wherever client-side processing is available. The media modal is + * Backbone `wp.media`, and its uploader is core's `wp.Uploader`/plupload, + * which nothing intercepts: it posts the original bytes to `async-upload.php`. + * A HEIC file that converts and uploads when dropped on an Image block + * therefore fails when dropped on the modal, and files uploaded there skip + * browser-generated sub-sizes, the big-image threshold, and animated GIF + * handling too. + * + * Binding a higher-priority `FilesAdded` handler on every `wp.Uploader` + * instance hands those files to the same pipeline instead. plupload's own + * placeholder attachments, progress, and `wp.Uploader.errors` are mirrored, so + * the modal's UI works unchanged. + * + * @see https://github.com/WordPress/gutenberg/issues/82409 + */ + +declare global { + interface Window { + __clientSideMediaProcessing?: boolean; + plupload?: { FAILED: number }; + } +} + +/** + * HEIC MIME types, the only ones routed through the pipeline when the browser + * supports canvas conversion but not full client-side processing. + */ +const HEIC_MIME_TYPES = [ 'image/heic', 'image/heif' ]; + +/** + * File names whose extension the modal can type before the upload finishes. + */ +const IMAGE_EXTENSION = /(?:jpe?g|png|gif|webp|avif|heic|heif)$/i; + +/** + * The parts of a plupload file this module relies on. + */ +type PluploadFile = { + status: number; + name: string; + size: number; + loaded: number; + percent: number; + type?: string; + getNative?: () => File | null; +}; + +/** + * The bookkeeping kept for one queued upload. + */ +type UploadEntry = { + /** Identity key of the file being uploaded. */ + key: string; + /** ID of the queue item it matched, once one is known. */ + itemId: string | null; + /** Called with an integer percentage whenever it changes. */ + onProgress: ( percent: number ) => void; + /** Last percentage reported. */ + lastPercent: number; + /** Operation counts the progress estimate is derived from. */ + totals: { total: number; remaining: number } | null; + /** Whether the upload has finished. */ + released: boolean; +}; + +// 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. +const pending = new Map< string, UploadEntry[] >(); +const active = new Map< string, UploadEntry >(); + +// Number of queued files that have not succeeded or failed yet. +let inFlight = 0; + +let isInstalled = false; +let unsubscribe: ( () => void ) | undefined; + +/** + * 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 The file to key. + * @return Identity key. + */ +function fileKey( file: File ): string { + return `${ file.name }::${ file.size }::${ file.lastModified }`; +} + +/** + * Whether the browser runs the full client-side pipeline on this page. + * + * @return True when every upload can be processed in the browser. + */ +function isFullPipelineActive(): boolean { + return Boolean( + window.__clientSideMediaProcessing && + detectClientSideMediaSupport?.()?.supported + ); +} + +/** + * Whether the browser converts HEIC files on a canvas but cannot run the full + * pipeline - Safari, notably. + * + * @return True when only HEIC files can be processed in the browser. + */ +function isHeicOnlyPipelineActive(): boolean { + return Boolean( + window.__clientSideMediaProcessing && + ! isFullPipelineActive() && + isHeicCanvasSupported?.() + ); +} + +/** + * Whether the pipeline has the settings it needs to accept files. + * + * The block editor provider writes them into the store as it mounts, so a file + * added before that has to stay on the classic path - a degradation, never + * data loss. + * + * @return True when the store is ready to accept files. + */ +function isPipelineReady(): boolean { + return Boolean( select( uploadStore ).getSettings()?.mediaUpload ); +} + +/** + * Whether a batch of files added to plupload can go through the pipeline. + * + * Suppressing plupload's built-in handler is all-or-nothing, so the whole batch + * stays on the classic path when any file cannot be handled: plupload exposes + * no native `File` for sources it cannot represent as one, and in HEIC-only + * mode everything but a HEIC file is still processed server-side. + * + * @param files Files added to the plupload queue. + * @return True when every file can go through the pipeline. + */ +function canHandleBatch( files: PluploadFile[] ): boolean { + const heicOnly = isHeicOnlyPipelineActive(); + + return files.every( ( file ) => { + if ( window.plupload?.FAILED === file.status ) { + return true; + } + if ( ! file.getNative?.() ) { + return false; + } + return ! heicOnly || HEIC_MIME_TYPES.includes( file.type || '' ); + } ); +} + +/** + * Builds the extra fields to send with an upload from plupload's multipart + * parameters. + * + * Anything a plugin added through the `plupload_default_params` filter or + * `wp.Uploader.param()` reached the classic upload as a `$_POST` field, 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. + * + * @param params Plupload's multipart parameters. + * @return Additional data for the upload. + */ +function additionalDataFromParams( + params: Record< string, string > +): Record< string, unknown > { + const additionalData: Record< string, unknown > = {}; + + Object.keys( params || {} ).forEach( ( key ) => { + if ( 'action' === key || '_wpnonce' === key || 'post_id' === key ) { + return; + } + additionalData[ key ] = params[ key ]; + } ); + + const postId = parseInt( params?.post_id, 10 ); + if ( postId ) { + additionalData.post = postId; + } + + return additionalData; +} + +/** + * Estimates the progress (0-100) of a queue item. + * + * The pipeline never reports a numeric `progress` on its queue items, 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. A real `progress` is preferred whenever one shows up. + * + * @param item The upload-media queue item. + * @param entry The bookkeeping for the upload. + * @return Estimated progress. + */ +function estimateProgress( item: any, entry: UploadEntry ): number { + if ( typeof item.progress === 'number' ) { + return item.progress; + } + + const remaining = item.operations?.length ?? 0; + let totals = entry.totals; + if ( ! totals ) { + totals = { total: 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 imageSizeCount = Object.keys( + select( uploadStore ).getSettings()?.allImageSizes || {} + ).length; + const completed = totals.total - remaining; + let fraction = 0; + if ( 'THUMBNAIL_GENERATION' === item.currentOperation && imageSizeCount ) { + fraction = Math.min( + 1, + ( item.subSizes?.length ?? 0 ) / 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 modal's + * progress bars. Progress holds at 99 until the upload's success callback has + * run, so no tile looks finished before the modal has synced the result. + */ +function onStoreChange(): void { + if ( inFlight === 0 ) { + return; + } + + select( uploadStore ) + .getItems() + .forEach( ( item: any ) => { + 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?.length ) { + return; + } + entry = list.shift() as UploadEntry; + if ( ! list.length ) { + pending.delete( key ); + } + entry.itemId = item.id; + active.set( item.id, entry ); + } + + 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 entry The bookkeeping for the upload. + */ +function release( entry: UploadEntry ): void { + 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 ); + } +} + +/** + * Queues a file for client-side processing and upload. + * + * @param file The file to upload. + * @param additionalData Extra fields to send with the attachment. + * @param callbacks Lifecycle callbacks. + * @param callbacks.onSuccess Called with the finalized attachment. + * @param callbacks.onError Called with the reason the upload failed. + * @param callbacks.onProgress Called with an integer percentage whenever it changes. + */ +function queueFile( + file: File, + additionalData: Record< string, unknown >, + callbacks: { + onSuccess: ( attachment: any ) => void; + onError: ( error: unknown ) => void; + onProgress: ( percent: number ) => void; + } +): void { + const entry: UploadEntry = { + key: fileKey( file ), + 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++; + + unsubscribe = unsubscribe ?? subscribe( onStoreChange, uploadStore ); + + void dispatch( uploadStore ).addItems( { + files: [ file ], + additionalData, + onSuccess: ( attachments: any[] ) => { + release( entry ); + callbacks.onSuccess( attachments[ 0 ] ); + }, + onError: ( error: unknown ) => { + release( entry ); + callbacks.onError( error ); + }, + } ); +} + +/** + * Builds the text shown for a failed upload. + * + * A pipeline error is not always an `Error`: the store reports a string for + * some failures and a REST rejection is a plain object. + * + * @param error The upload error. + * @return A human-readable message. + */ +function getErrorText( error: unknown ): string { + if ( typeof error === 'string' && error ) { + return error; + } + + const message = ( error as { message?: string } )?.message; + + return message || __( 'An error occurred while uploading the file.' ); +} + +/** + * Handles a finished upload by syncing the modal's tile with the server data. + * + * The pipeline returns a REST attachment, while the modal's tile is a + * `wp.media` attachment, so the model is refetched by ID rather than filled in + * from the response. + * + * @param wpUploader The `wp.Uploader` instance that queued the file. + * @param model The placeholder attachment model. + * @param attachment The finalized attachment. + * @param attachment.id ID of the finalized attachment. + */ +function handleSuccess( + wpUploader: any, + model: any, + attachment: { id: number } +): void { + const { wp } = window as any; + + 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 ); + + const clearUploadingState = () => { + [ 'file', 'loaded', 'size', 'percent' ].forEach( ( key ) => + model.unset( key, { silent: true } ) + ); + model.set( { uploading: false } ); + }; + + model + .fetch() + .done( clearUploadingState ) + .fail( () => { + // The fetch failed but the upload did not: clear the uploading + // state with what the pipeline returned so no tile is stuck. + model.set( attachment, { silent: true } ); + clearUploadingState(); + } ) + .always( () => { + maybeResetQueue(); + wpUploader.success( model ); + } ); +} + +/** + * Handles a failed upload by removing the tile and surfacing the message. + * + * The error goes into `wp.Uploader.errors` exactly like a classic upload + * error, so the modal's own error list renders and announces it. + * + * @param wpUploader The `wp.Uploader` instance that queued the file. + * @param model The placeholder attachment model. + * @param error The upload error. + * @param file The plupload file that failed. + */ +function handleError( + wpUploader: any, + model: any, + error: unknown, + file: PluploadFile +): void { + const { wp } = window as any; + const message = getErrorText( error ); + + model.destroy(); + + wp.Uploader.errors.unshift( { message, data: {}, file } ); + + maybeResetQueue(); + + wpUploader.error( message, {}, file ); +} + +/** + * Resets the upload queue once every attachment has finished uploading. + * + * Parity with wp-plupload.js, which flips the modal back to browse mode. + */ +function maybeResetQueue(): void { + const { wp } = window as any; + + const complete = wp.Uploader.queue.all( + ( attachment: any ) => ! attachment.get( 'uploading' ) + ); + + if ( complete ) { + wp.Uploader.queue.reset(); + } +} + +/** + * Intercepts files added to a plupload uploader. + * + * Returns `undefined` (not `false`) when the pipeline cannot take the batch so + * that the built-in handler runs and uploads server-side. 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 The `wp.Uploader` instance. + * @param up The plupload uploader instance. + * @param files Files added to the queue. + * @return False to suppress the built-in handler. + */ +function handleFilesAdded( + wpUploader: any, + up: any, + files: PluploadFile[] +): boolean | undefined { + if ( + ! ( isFullPipelineActive() || isHeicOnlyPipelineActive() ) || + ! isPipelineReady() || + ! canHandleBatch( files ) + ) { + return undefined; + } + + const { wp, plupload } = window as any; + const additionalData = additionalDataFromParams( + up.settings?.multipart_params + ); + + files.forEach( ( file ) => { + // Ignore failed uploads. + if ( plupload.FAILED === file.status ) { + return; + } + + // The same placeholder attributes wp-plupload.js builds, so the + // modal's uploading tiles and progress bars work unchanged. + const attributes: Record< string, unknown > = { + 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 pipeline accepts. + const image = IMAGE_EXTENSION.exec( file.name ); + if ( image ) { + const extension = image[ 0 ].toLowerCase(); + attributes.type = 'image'; + // `jpg` is not a valid subtype, so map it to `jpeg`. + attributes.subtype = 'jpg' === extension ? 'jpeg' : extension; + } + + const model = wp.media.model.Attachment.create( attributes ); + wp.Uploader.queue.add( model ); + wpUploader.added( model ); + + // canHandleBatch() established that every file has one. + const nativeFile = file.getNative?.() as File; + + // Remove the file from plupload so it is not uploaded twice. + up.removeFile( file ); + + queueFile( nativeFile, additionalData, { + onSuccess: ( attachment ) => + handleSuccess( wpUploader, model, attachment ), + onError: ( error ) => handleError( wpUploader, model, error, file ), + onProgress: ( percent ) => model.set( { percent } ), + } ); + } ); + + up.refresh(); + + return false; +} + +/** + * Binds the pipeline to every `wp.Uploader` instance created from now on. + * + * Safe to call repeatedly: the patch is applied once, and it decides per batch + * of files whether the pipeline can take them, so an uploader created before + * the block editor configured the pipeline still works. + */ +export function installClientSideModalUploads(): void { + const { wp, plupload } = window as any; + + if ( isInstalled || ! wp?.Uploader || ! wp?.media || ! plupload ) { + return; + } + + isInstalled = true; + + // wp.Uploader.prototype.init is an empty stub core calls once per + // instance, after plupload has been initialized. + const originalInit = wp.Uploader.prototype.init; + wp.Uploader.prototype.init = function ( this: any, ...args: unknown[] ) { + originalInit.apply( this, args ); + + const up = this.uploader; + if ( ! up || up.__clientSideUploadsBound ) { + return; + } + up.__clientSideUploadsBound = 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', + ( uploader: any, files: PluploadFile[] ) => + handleFilesAdded( this, uploader, files ), + this, + 100 + ); + }; +} diff --git a/packages/media-utils/tsconfig.build.json b/packages/media-utils/tsconfig.build.json index 8fc23684f829b1..309f78074e80e5 100644 --- a/packages/media-utils/tsconfig.build.json +++ b/packages/media-utils/tsconfig.build.json @@ -19,6 +19,7 @@ { "path": "../notices/tsconfig.build.json" }, { "path": "../private-apis" }, { "path": "../ui/tsconfig.build.json" }, + { "path": "../upload-media/tsconfig.build.json" }, { "path": "../views/tsconfig.build.json" } ] } diff --git a/packages/media-utils/tsconfig.json b/packages/media-utils/tsconfig.json index f9ac2c90333dcb..dcec5f98a973f6 100644 --- a/packages/media-utils/tsconfig.json +++ b/packages/media-utils/tsconfig.json @@ -20,6 +20,7 @@ { "path": "../notices/tsconfig.build.json" }, { "path": "../private-apis" }, { "path": "../ui/tsconfig.build.json" }, + { "path": "../upload-media/tsconfig.build.json" }, { "path": "../views/tsconfig.build.json" } ], // Listed files have pre-existing type errors; include them once fixed. From 6f088f1563763c226bd89cec2cf26950dd9dc77e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 4 Sep 2026 12:27:08 -0700 Subject: [PATCH 02/14] Media: Cover client-side uploads started from the media modal The e2e spec asserts the modal's upload reaches the REST API and never async-upload.php, and that a format the server may not be able to process uploads cleanly. The unit tests cover the plupload interception itself: priority, the placeholder attachment, the forwarded multipart params, the fallback cases, and how success and failure are reported back to the modal. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HMK7kaTqz41w6rTnjiP9KC --- .../client-side-modal-uploads.jsdom.test.ts | 321 ++++++++++++++++++ .../media-modal-client-side-upload.spec.js | 167 +++++++++ 2 files changed, 488 insertions(+) create mode 100644 packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts create mode 100644 test/e2e/specs/editor/various/media-modal-client-side-upload.spec.js diff --git a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts new file mode 100644 index 00000000000000..247155010d0e28 --- /dev/null +++ b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const addItems = vi.fn(); +const getSettings = vi.fn( () => ( { mediaUpload: () => {} } ) ); +const getItems = vi.fn( () => [] ); +const detectClientSideMediaSupport = vi.fn( () => ( { supported: true } ) ); +const isHeicCanvasSupported = vi.fn( () => false ); + +vi.mock( + import( '@wordpress/data' ), + () => + ( { + dispatch: () => ( { addItems } ), + select: () => ( { getSettings, getItems } ), + subscribe: () => () => {}, + } ) as any +); + +vi.mock( + import( '@wordpress/upload-media' ), + () => + ( { + store: { name: 'core/upload-media' }, + detectClientSideMediaSupport: () => detectClientSideMediaSupport(), + isHeicCanvasSupported: () => isHeicCanvasSupported(), + } ) as any +); + +/** + * A Backbone-ish attachment model with only the methods the module calls. + * + * @param attributes Initial model attributes. + */ +function createModel( attributes: Record< string, unknown > ) { + return { + attributes, + set: vi.fn( ( values ) => Object.assign( attributes, values ) ), + unset: vi.fn( ( key: string ) => delete attributes[ key ] ), + destroy: vi.fn(), + fetch: vi.fn( () => { + const deferred = { + done: ( fn: () => void ) => { + fn(); + return deferred; + }, + fail: () => deferred, + always: ( fn: () => void ) => { + fn(); + return deferred; + }, + }; + return deferred; + } ), + }; +} + +/** + * Installs the `wp` and `plupload` globals the module patches, and returns the + * handles a test needs to drive them. + */ +function setUpGlobals() { + const created: ReturnType< typeof createModel >[] = []; + const queue = { + add: vi.fn(), + all: vi.fn( () => true ), + reset: vi.fn(), + }; + const errors = { unshift: vi.fn() }; + + function Uploader() {} + Uploader.prototype.init = vi.fn(); + Uploader.queue = queue; + Uploader.errors = errors; + + ( window as any ).plupload = { FAILED: 4 }; + ( window as any ).wp = { + Uploader, + media: { + model: { + settings: { post: { id: 42 } }, + Attachment: { + create: vi.fn( ( attributes ) => { + const model = createModel( attributes ); + created.push( model ); + return model; + } ), + get: vi.fn(), + }, + }, + }, + }; + + return { Uploader, queue, errors, created }; +} + +/** + * Runs the patched `init` for one uploader and returns its FilesAdded handler + * alongside the plupload and wp.Uploader stand-ins it was bound with. + * + * @param Uploader The patched `wp.Uploader` constructor. + */ +function createUploader( Uploader: any ) { + const bindings: Array< { + event: string; + handler: Function; + priority: number; + } > = []; + const up = { + settings: { + multipart_params: { + action: 'upload-attachment', + _wpnonce: 'nonce', + post_id: '42', + custom_field: 'kept', + }, + }, + bind: vi.fn( + ( + event: string, + handler: Function, + scope: unknown, + priority: number + ) => bindings.push( { event, handler, priority } ) + ), + removeFile: vi.fn(), + refresh: vi.fn(), + }; + const wpUploader = { + uploader: up, + added: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }; + + Uploader.prototype.init.call( wpUploader ); + + return { up, wpUploader, bindings }; +} + +function createPluploadFile( name = 'test.jpeg' ) { + const native = new window.File( [ 'file' ], name, { type: 'image/jpeg' } ); + return { + status: 1, + name, + size: 4, + loaded: 0, + percent: 0, + type: 'image/jpeg', + getNative: () => native, + native, + }; +} + +async function loadModule() { + vi.resetModules(); + return await import( '../client-side-modal-uploads' ); +} + +describe( 'installClientSideModalUploads', () => { + beforeEach( () => { + ( window as any ).__clientSideMediaProcessing = true; + } ); + + afterEach( () => { + vi.clearAllMocks(); + delete ( window as any ).wp; + delete ( window as any ).plupload; + delete ( window as any ).__clientSideMediaProcessing; + } ); + + it( 'binds FilesAdded ahead of the built-in handler', async () => { + const { Uploader } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { bindings } = createUploader( Uploader ); + + expect( bindings ).toHaveLength( 1 ); + expect( bindings[ 0 ].event ).toBe( 'FilesAdded' ); + // plupload runs handlers in descending priority order, and the + // built-in one is bound at the default priority of 0. + expect( bindings[ 0 ].priority ).toBeGreaterThan( 0 ); + } ); + + it( 'does nothing without the wp.Uploader global', async () => { + const { installClientSideModalUploads } = await loadModule(); + + expect( () => installClientSideModalUploads() ).not.toThrow(); + } ); + + it( 'routes a file through the pipeline instead of plupload', async () => { + const { Uploader, queue, created } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, wpUploader, bindings } = createUploader( Uploader ); + const file = createPluploadFile(); + + const result = bindings[ 0 ].handler( up, [ file ] ); + + // Returning false suppresses plupload's own upload. + expect( result ).toBe( false ); + expect( up.removeFile ).toHaveBeenCalledWith( file ); + + // The modal's uploading tile is created as usual. + expect( queue.add ).toHaveBeenCalledWith( created[ 0 ] ); + expect( wpUploader.added ).toHaveBeenCalledWith( created[ 0 ] ); + expect( created[ 0 ].attributes ).toMatchObject( { + uploading: true, + filename: 'test.jpeg', + uploadedTo: 42, + type: 'image', + subtype: 'jpeg', + } ); + + // The file is queued with the multipart params plugins added, minus + // the fields that only mean something to async-upload.php. + expect( addItems ).toHaveBeenCalledWith( + expect.objectContaining( { + files: [ file.native ], + additionalData: { custom_field: 'kept', post: 42 }, + } ) + ); + } ); + + it( 'leaves the batch to plupload when the pipeline is unavailable', async () => { + delete ( window as any ).__clientSideMediaProcessing; + const { Uploader } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, bindings } = createUploader( Uploader ); + + // Returning undefined lets the built-in handler run. + expect( + bindings[ 0 ].handler( up, [ createPluploadFile() ] ) + ).toBeUndefined(); + expect( addItems ).not.toHaveBeenCalled(); + } ); + + it( 'leaves the batch to plupload when a file has no native File', async () => { + const { Uploader } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, bindings } = createUploader( Uploader ); + const file = { ...createPluploadFile(), getNative: () => null }; + + expect( bindings[ 0 ].handler( up, [ file ] ) ).toBeUndefined(); + expect( addItems ).not.toHaveBeenCalled(); + } ); + + it( 'only takes HEIC files when the browser converts HEIC alone', async () => { + detectClientSideMediaSupport.mockReturnValue( { supported: false } ); + isHeicCanvasSupported.mockReturnValue( true ); + + const { Uploader } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, bindings } = createUploader( Uploader ); + + expect( + bindings[ 0 ].handler( up, [ createPluploadFile() ] ) + ).toBeUndefined(); + + const heic = { + ...createPluploadFile( 'test.heic' ), + type: 'image/heic', + }; + expect( bindings[ 0 ].handler( up, [ heic ] ) ).toBe( false ); + + detectClientSideMediaSupport.mockReturnValue( { supported: true } ); + isHeicCanvasSupported.mockReturnValue( false ); + } ); + + it( 'syncs the tile with the finished attachment', async () => { + const { Uploader, created } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, wpUploader, bindings } = createUploader( Uploader ); + + bindings[ 0 ].handler( up, [ createPluploadFile() ] ); + addItems.mock.calls[ 0 ][ 0 ].onSuccess( [ { id: 99 } ] ); + + const model = created[ 0 ]; + expect( model.attributes ).toMatchObject( { + id: 99, + uploading: false, + } ); + expect( model.attributes.percent ).toBeUndefined(); + expect( wpUploader.success ).toHaveBeenCalledWith( model ); + } ); + + it( 'reports a failure through wp.Uploader.errors', async () => { + const { Uploader, errors, created } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, wpUploader, bindings } = createUploader( Uploader ); + const file = createPluploadFile(); + + bindings[ 0 ].handler( up, [ file ] ); + addItems.mock.calls[ 0 ][ 0 ].onError( { + message: 'The server cannot process HEIC images.', + } ); + + expect( created[ 0 ].destroy ).toHaveBeenCalled(); + expect( errors.unshift ).toHaveBeenCalledWith( { + message: 'The server cannot process HEIC images.', + data: {}, + file, + } ); + expect( wpUploader.error ).toHaveBeenCalledWith( + 'The server cannot process HEIC images.', + {}, + file + ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/media-modal-client-side-upload.spec.js b/test/e2e/specs/editor/various/media-modal-client-side-upload.spec.js new file mode 100644 index 00000000000000..a69a802f7d18dd --- /dev/null +++ b/test/e2e/specs/editor/various/media-modal-client-side-upload.spec.js @@ -0,0 +1,167 @@ +const path = require( 'path' ); +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { + skipIfClientSideMediaInactive, +} = require( './client-side-media-utils' ); + +// A 1024x768 JPEG: larger than the thumbnail and medium sub-sizes, so the +// pipeline generates and sideloads them. +const TEST_IMAGE_PATH = path.join( + __dirname, + '..', + '..', + '..', + 'assets', + '1024x768_e2e_test_image_size.jpeg' +); + +// An AVIF the server cannot process without an image editor that supports it, +// which is what made the modal's server-side uploads fail where the block +// editor's own client-side uploads succeeded. +const TEST_AVIF_PATH = path.join( + __dirname, + '..', + '..', + '..', + 'assets', + '200x150_e2e_test_image_decode.avif' +); + +// The plupload HTML5 runtime creates this hidden file input over the modal's +// "Select Files" button; setting files on it triggers FilesAdded, the same +// event a file dropped on the modal fires. +const FILE_INPUT_SELECTOR = '.media-modal .moxie-shim-html5 input[type="file"]'; + +test.describe( 'Media modal client-side uploads', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); + + test( 'processes an upload started from the media modal in the browser', async ( { + editor, + page, + requestUtils, + } ) => { + await skipIfClientSideMediaInactive( page, test ); + + // 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++; + } + } ); + + await editor.insertBlock( { name: 'core/image' } ); + await editor.canvas + .getByRole( 'button', { name: 'Media Library' } ) + .click(); + + const modal = page.locator( '.media-modal' ); + await expect( modal ).toBeVisible(); + + await modal + .locator( '.media-router' ) + .getByText( 'Upload files' ) + .click(); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached' } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + // The finalized attachment resolves to a normal (non-uploading) tile. + await expect( + modal.locator( 'li.attachment:not(.uploading)' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + // The original upload and every sub-size 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 attachment carries the browser-generated sub-sizes. + const [ attachment ] = await requestUtils.rest( { + path: '/wp/v2/media', + params: { per_page: 1 }, + } ); + expect( Object.keys( attachment.media_details.sizes || {} ) ).toEqual( + expect.arrayContaining( [ 'thumbnail', 'medium' ] ) + ); + + // The modal still hands the finished upload back to the block. + await modal + .getByRole( 'button', { name: 'Select', exact: true } ) + .click(); + await expect( + editor.canvas.locator( 'figure.wp-block-image img' ) + ).toHaveAttribute( 'src', attachment.source_url ); + } ); + + test( 'uploads a format the server may not be able to process', async ( { + editor, + page, + } ) => { + await skipIfClientSideMediaInactive( page, test ); + + const asyncUploads = []; + page.on( 'request', ( request ) => { + if ( + request.method() === 'POST' && + request.url().includes( '/async-upload.php' ) + ) { + asyncUploads.push( request.url() ); + } + } ); + + await editor.insertBlock( { name: 'core/image' } ); + await editor.canvas + .getByRole( 'button', { name: 'Media Library' } ) + .click(); + + const modal = page.locator( '.media-modal' ); + await expect( modal ).toBeVisible(); + await modal + .locator( '.media-router' ) + .getByText( 'Upload files' ) + .click(); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached' } ); + await fileInput.setInputFiles( TEST_AVIF_PATH ); + + await expect( + modal.locator( 'li.attachment:not(.uploading)' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + // No upload error is reported and nothing reached the classic + // endpoint that would have rejected the file. + await expect( modal.locator( '.upload-error' ) ).toHaveCount( 0 ); + expect( asyncUploads ).toEqual( [] ); + } ); +} ); From 7da1c63b151522ab35f6898c7fbe4777d5cecd5f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 4 Sep 2026 12:34:04 -0700 Subject: [PATCH 03/14] Media: Add changelog entry for media modal client-side uploads Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HMK7kaTqz41w6rTnjiP9KC --- packages/media-utils/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/media-utils/CHANGELOG.md b/packages/media-utils/CHANGELOG.md index ade914bbbc7256..f193ea45ee65b9 100644 --- a/packages/media-utils/CHANGELOG.md +++ b/packages/media-utils/CHANGELOG.md @@ -5,6 +5,7 @@ ### Bug Fixes - Preserve array-valued fields in multipart form data so grouped image-size sideload requests reach the REST API as arrays ([#82353](https://github.com/WordPress/gutenberg/pull/82353)). +- Route uploads started from the editor's media modal through the client-side media pipeline instead of `async-upload.php`, so they are processed the same way as a file dropped on a block ([#82473](https://github.com/WordPress/gutenberg/pull/82473)). ## 5.54.0 (2026-08-26) From 0a3001e44cf96661db82d0c108618b39168dc293 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 4 Sep 2026 15:09:35 -0700 Subject: [PATCH 04/14] Media: Address the upload-media store by name in the modal upload patch `@wordpress/fields` is a bundled package and reaches `@wordpress/media-utils` through its media-edit component. Importing the `store` descriptor from `@wordpress/upload-media` pulled the store, its lock-unlock module, and `@wordpress/private-apis` into that bundle, failing the private API check. Address the store by name instead and keep only the feature-detection imports, which tree-shake cleanly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HMK7kaTqz41w6rTnjiP9KC --- .../src/utils/client-side-modal-uploads.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/media-utils/src/utils/client-side-modal-uploads.ts b/packages/media-utils/src/utils/client-side-modal-uploads.ts index f16e47e43d2d57..7198a46ec63b07 100644 --- a/packages/media-utils/src/utils/client-side-modal-uploads.ts +++ b/packages/media-utils/src/utils/client-side-modal-uploads.ts @@ -1,7 +1,6 @@ import { __ } from '@wordpress/i18n'; import { dispatch, select, subscribe } from '@wordpress/data'; import { - store as uploadStore, detectClientSideMediaSupport, isHeicCanvasSupported, } from '@wordpress/upload-media'; @@ -36,6 +35,16 @@ declare global { } } +/** + * Name of the upload-media store. + * + * The store is addressed by name rather than through the `store` descriptor + * `@wordpress/upload-media` exports: importing that descriptor would pull the + * store - and with it `@wordpress/private-apis` - into the module graph of + * every bundled package that imports `@wordpress/media-utils`. + */ +const UPLOAD_STORE = 'core/upload-media'; + /** * HEIC MIME types, the only ones routed through the pipeline when the browser * supports canvas conversion but not full client-side processing. @@ -136,13 +145,14 @@ function isHeicOnlyPipelineActive(): boolean { * Whether the pipeline has the settings it needs to accept files. * * The block editor provider writes them into the store as it mounts, so a file - * added before that has to stay on the classic path - a degradation, never + * added before that - or on a screen where `@wordpress/upload-media` never + * registered its store - has to stay on the classic path: a degradation, never * data loss. * * @return True when the store is ready to accept files. */ function isPipelineReady(): boolean { - return Boolean( select( uploadStore ).getSettings()?.mediaUpload ); + return Boolean( select( UPLOAD_STORE )?.getSettings()?.mediaUpload ); } /** @@ -238,7 +248,7 @@ function estimateProgress( item: any, entry: UploadEntry ): number { } const imageSizeCount = Object.keys( - select( uploadStore ).getSettings()?.allImageSizes || {} + select( UPLOAD_STORE ).getSettings()?.allImageSizes || {} ).length; const completed = totals.total - remaining; let fraction = 0; @@ -265,7 +275,7 @@ function onStoreChange(): void { return; } - select( uploadStore ) + select( UPLOAD_STORE ) .getItems() .forEach( ( item: any ) => { if ( item.parentId || ! item.sourceFile ) { @@ -362,9 +372,9 @@ function queueFile( } inFlight++; - unsubscribe = unsubscribe ?? subscribe( onStoreChange, uploadStore ); + unsubscribe = unsubscribe ?? subscribe( onStoreChange, UPLOAD_STORE ); - void dispatch( uploadStore ).addItems( { + void dispatch( UPLOAD_STORE ).addItems( { files: [ file ], additionalData, onSuccess: ( attachments: any[] ) => { From 3f85d4a2f5e9c0fec7f67584bcadb79e512b3268 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 4 Sep 2026 15:21:46 -0700 Subject: [PATCH 05/14] Media: Address review feedback on the modal upload patch - Reach the pipeline through the `wp.uploadMedia` global instead of importing `@wordpress/upload-media`, so the `wp-media-utils` handle does not drag the processing stack onto every screen that enqueues it - and so no bundled package importing media-utils reaches private APIs. - Match a queue item to its upload by the identity of the `onSuccess` callback rather than by file name/size/mtime, so an item the block editor queued for the same file cannot claim the modal's tile. - Report an upload's outcome once: the store can call `onSuccess` twice for a parent item, and a cancel can be followed by a late success. - Fail a tile whose queue item leaves the store without reporting, which `cancelItem()` does silently. It used to stick at 99% and keep the modal from returning to browse mode. - Set the attachment id non-silently so `Attachments` re-keys the model from its cid to its id; without it a library refetch duplicated the tile. - Map the REST attachment to `wp.media` attributes in the refetch-failure fallback, instead of writing REST field names onto the model. - Leave a batch of already-failed files to the built-in handler, which is the only caller of `up.start()`. - Unsubscribe from the store once nothing is in flight. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HMK7kaTqz41w6rTnjiP9KC --- package-lock.json | 1 - packages/media-utils/package.json | 1 - .../src/utils/client-side-modal-uploads.ts | 277 +++++++++++------- .../client-side-modal-uploads.jsdom.test.ts | 118 +++++++- packages/media-utils/tsconfig.build.json | 1 - packages/media-utils/tsconfig.json | 1 - 6 files changed, 282 insertions(+), 117 deletions(-) diff --git a/package-lock.json b/package-lock.json index ea2e4581c2ff7c..fa65b39ad5346a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53211,7 +53211,6 @@ "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", "@wordpress/ui": "file:../ui", - "@wordpress/upload-media": "file:../upload-media", "@wordpress/views": "file:../views", "clsx": "^2.1.1" }, diff --git a/packages/media-utils/package.json b/packages/media-utils/package.json index 461c7945fa96a4..0addb7824623fb 100644 --- a/packages/media-utils/package.json +++ b/packages/media-utils/package.json @@ -61,7 +61,6 @@ "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", "@wordpress/ui": "file:../ui", - "@wordpress/upload-media": "file:../upload-media", "@wordpress/views": "file:../views", "clsx": "^2.1.1" }, diff --git a/packages/media-utils/src/utils/client-side-modal-uploads.ts b/packages/media-utils/src/utils/client-side-modal-uploads.ts index 7198a46ec63b07..a4b2db0866753e 100644 --- a/packages/media-utils/src/utils/client-side-modal-uploads.ts +++ b/packages/media-utils/src/utils/client-side-modal-uploads.ts @@ -1,9 +1,5 @@ import { __ } from '@wordpress/i18n'; import { dispatch, select, subscribe } from '@wordpress/data'; -import { - detectClientSideMediaSupport, - isHeicCanvasSupported, -} from '@wordpress/upload-media'; /** * Routes uploads started from the editor's media modal through the client-side @@ -25,6 +21,12 @@ import { * placeholder attachments, progress, and `wp.Uploader.errors` are mirrored, so * the modal's UI works unchanged. * + * The pipeline is reached through the globals WordPress already prints - the + * `core/upload-media` store and `wp.uploadMedia` - rather than by importing + * `@wordpress/upload-media`. This module only does anything where `wp.media` + * exists at all, and a static import would put the whole processing stack + * behind the `wp-media-utils` handle on every screen that enqueues it. + * * @see https://github.com/WordPress/gutenberg/issues/82409 */ @@ -37,14 +39,17 @@ declare global { /** * Name of the upload-media store. - * - * The store is addressed by name rather than through the `store` descriptor - * `@wordpress/upload-media` exports: importing that descriptor would pull the - * store - and with it `@wordpress/private-apis` - into the module graph of - * every bundled package that imports `@wordpress/media-utils`. */ const UPLOAD_STORE = 'core/upload-media'; +/** + * The parts of `@wordpress/upload-media` this module reads off `wp`. + */ +type UploadMediaGlobal = { + detectClientSideMediaSupport?: () => { supported?: boolean } | undefined; + isHeicCanvasSupported?: () => boolean; +}; + /** * HEIC MIME types, the only ones routed through the pipeline when the browser * supports canvas conversion but not full client-side processing. @@ -73,12 +78,14 @@ type PluploadFile = { * The bookkeeping kept for one queued upload. */ type UploadEntry = { - /** Identity key of the file being uploaded. */ - key: string; /** ID of the queue item it matched, once one is known. */ itemId: string | null; + /** The `onSuccess` handed to the store, which identifies its queue item. */ + token: ( attachments: any[] ) => void; /** Called with an integer percentage whenever it changes. */ onProgress: ( percent: number ) => void; + /** Called when the upload fails or is dropped from the queue. */ + onError: ( error: unknown ) => void; /** Last percentage reported. */ lastPercent: number; /** Operation counts the progress estimate is derived from. */ @@ -87,32 +94,22 @@ type UploadEntry = { released: boolean; }; -// 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. -const pending = new Map< string, UploadEntry[] >(); -const active = new Map< string, UploadEntry >(); - -// Number of queued files that have not succeeded or failed yet. -let inFlight = 0; +// Uploads that have not succeeded or failed yet. A queue item is matched to +// its entry by the identity of the `onSuccess` callback the entry handed the +// store, so two uploads of the same file - or an upload the block editor +// queued into the same store - can never be mistaken for one another. +const entries = new Set< UploadEntry >(); let isInstalled = false; let unsubscribe: ( () => void ) | undefined; /** - * Builds a stable identity key for a file. + * The upload-media package, where WordPress has printed it. * - * 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 The file to key. - * @return Identity key. + * @return The package's exports, or undefined where it is not loaded. */ -function fileKey( file: File ): string { - return `${ file.name }::${ file.size }::${ file.lastModified }`; +function getUploadMedia(): UploadMediaGlobal | undefined { + return ( window as any ).wp?.uploadMedia; } /** @@ -123,7 +120,7 @@ function fileKey( file: File ): string { function isFullPipelineActive(): boolean { return Boolean( window.__clientSideMediaProcessing && - detectClientSideMediaSupport?.()?.supported + getUploadMedia()?.detectClientSideMediaSupport?.()?.supported ); } @@ -137,7 +134,7 @@ function isHeicOnlyPipelineActive(): boolean { return Boolean( window.__clientSideMediaProcessing && ! isFullPipelineActive() && - isHeicCanvasSupported?.() + getUploadMedia()?.isHeicCanvasSupported?.() ); } @@ -149,6 +146,12 @@ function isHeicOnlyPipelineActive(): boolean { * registered its store - has to stay on the classic path: a degradation, never * data loss. * + * The store is read from the default registry, which is the one the block + * editor provider writes to unless it was given a registry of its own. Under a + * custom `RegistryProvider` this check simply fails and the modal keeps + * uploading server-side - the same trade-off `invalidateAttachmentResolutions` + * documents. + * * @return True when the store is ready to accept files. */ function isPipelineReady(): boolean { @@ -161,18 +164,25 @@ function isPipelineReady(): boolean { * Suppressing plupload's built-in handler is all-or-nothing, so the whole batch * stays on the classic path when any file cannot be handled: plupload exposes * no native `File` for sources it cannot represent as one, and in HEIC-only - * mode everything but a HEIC file is still processed server-side. + * mode everything but a HEIC file is still processed server-side. A batch with + * nothing to take - only files plupload has already failed - is left alone too, + * so the built-in handler still gets to start whatever it has queued. * * @param files Files added to the plupload queue. * @return True when every file can go through the pipeline. */ function canHandleBatch( files: PluploadFile[] ): boolean { + const uploadable = files.filter( + ( file ) => window.plupload?.FAILED !== file.status + ); + + if ( ! uploadable.length ) { + return false; + } + const heicOnly = isHeicOnlyPipelineActive(); - return files.every( ( file ) => { - if ( window.plupload?.FAILED === file.status ) { - return true; - } + return uploadable.every( ( file ) => { if ( ! file.getNative?.() ) { return false; } @@ -248,7 +258,7 @@ function estimateProgress( item: any, entry: UploadEntry ): number { } const imageSizeCount = Object.keys( - select( UPLOAD_STORE ).getSettings()?.allImageSizes || {} + select( UPLOAD_STORE )?.getSettings()?.allImageSizes || {} ).length; const completed = totals.total - remaining; let fraction = 0; @@ -266,74 +276,85 @@ function estimateProgress( item: any, entry: UploadEntry ): number { * 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 modal's + * parent's callbacks and are skipped; only top-level items drive the modal's * progress bars. Progress holds at 99 until the upload's success callback has * run, so no tile looks finished before the modal has synced the result. + * + * An item that leaves the queue without reporting anything - `cancelItem()` + * can remove one silently, and a finished item that produced no attachment is + * dropped the same way - would otherwise strand its tile at 99% and keep the + * modal from returning to browse mode, so its entry is failed here instead. */ function onStoreChange(): void { - if ( inFlight === 0 ) { + if ( ! entries.size ) { return; } - select( UPLOAD_STORE ) - .getItems() - .forEach( ( item: any ) => { - if ( item.parentId || ! item.sourceFile ) { - return; - } + const items: any[] = select( UPLOAD_STORE )?.getItems() ?? []; + const liveIds = new Set< string >(); + const current = [ ...entries ]; - let entry = active.get( item.id ); - if ( ! entry ) { - const key = fileKey( item.sourceFile ); - const list = pending.get( key ); - if ( ! list?.length ) { - return; - } - entry = list.shift() as UploadEntry; - if ( ! list.length ) { - pending.delete( key ); - } - entry.itemId = item.id; - active.set( item.id, entry ); - } + items.forEach( ( item ) => { + if ( item.parentId ) { + return; + } + + liveIds.add( item.id ); - const percent = Math.min( - 99, - Math.round( estimateProgress( item, entry ) ) + const entry = + current.find( ( candidate ) => candidate.itemId === item.id ) ?? + current.find( + ( candidate ) => + ! candidate.itemId && candidate.token === item.onSuccess ); - if ( percent !== entry.lastPercent ) { - entry.lastPercent = percent; - entry.onProgress( percent ); - } - } ); + if ( ! entry ) { + return; + } + entry.itemId = item.id; + + const percent = Math.min( + 99, + Math.round( estimateProgress( item, entry ) ) + ); + if ( percent !== entry.lastPercent ) { + entry.lastPercent = percent; + entry.onProgress( percent ); + } + } ); + + current.forEach( ( entry ) => { + if ( entry.itemId && ! liveIds.has( entry.itemId ) ) { + entry.onError( + new Error( + __( + 'The upload stopped before it finished. Please try again.' + ) + ) + ); + } + } ); } /** * Stops tracking an upload once it has succeeded or failed. * * @param entry The bookkeeping for the upload. + * @return True when the caller still owns the outcome, false when something + * else has already reported it. */ -function release( entry: UploadEntry ): void { +function release( entry: UploadEntry ): boolean { if ( entry.released ) { - return; + return false; } entry.released = true; - inFlight--; + entries.delete( entry ); - 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 ( ! entries.size ) { + unsubscribe?.(); + unsubscribe = undefined; } - if ( entry.itemId ) { - active.delete( entry.itemId ); - } + return true; } /** @@ -355,36 +376,39 @@ function queueFile( onProgress: ( percent: number ) => void; } ): void { + // The store keeps this function on the queue item it creates, which is how + // `onStoreChange` tells this upload's item apart from every other one. The + // store can report an outcome more than once, so `release()` decides which + // call owns it. + const token = ( attachments: any[] ) => { + if ( release( entry ) ) { + callbacks.onSuccess( attachments[ 0 ] ); + } + }; + const entry: UploadEntry = { - key: fileKey( file ), itemId: null, + token, onProgress: callbacks.onProgress, + onError: ( error: unknown ) => { + if ( release( entry ) ) { + callbacks.onError( error ); + } + }, lastPercent: -1, totals: null, released: false, }; - const list = pending.get( entry.key ); - if ( list ) { - list.push( entry ); - } else { - pending.set( entry.key, [ entry ] ); - } - inFlight++; + entries.add( entry ); unsubscribe = unsubscribe ?? subscribe( onStoreChange, UPLOAD_STORE ); void dispatch( UPLOAD_STORE ).addItems( { files: [ file ], additionalData, - onSuccess: ( attachments: any[] ) => { - release( entry ); - callbacks.onSuccess( attachments[ 0 ] ); - }, - onError: ( error: unknown ) => { - release( entry ); - callbacks.onError( error ); - }, + onSuccess: token, + onError: ( error: unknown ) => entry.onError( error ), } ); } @@ -407,6 +431,54 @@ function getErrorText( error: unknown ): string { return message || __( 'An error occurred while uploading the file.' ); } +/** + * Translates a REST attachment into the attributes a `wp.media` attachment + * model expects. + * + * The two shapes disagree on nearly every field a tile renders: `source_url` + * against `url`, `media_details.sizes` against `sizes`, and a raw/rendered + * object against a plain string title. Only used as a fallback, so it fills in + * what the grid reads and leaves the rest to the next refetch. + * + * @param attachment The finalized REST attachment. + * @return Attributes for a `wp.media` attachment model. + */ +function toModelAttributes( attachment: any ): Record< string, unknown > { + const [ type, subtype ] = String( attachment.mime_type || '' ).split( '/' ); + const details = attachment.media_details; + const sizes = details?.sizes; + + return { + id: attachment.id, + title: attachment.title?.raw ?? attachment.title?.rendered ?? '', + filename: details?.file?.split( '/' ).pop() ?? '', + url: attachment.source_url, + link: attachment.link, + alt: attachment.alt_text, + mime: attachment.mime_type, + type, + subtype, + width: details?.width, + height: details?.height, + sizes: sizes + ? Object.fromEntries( + Object.entries< any >( sizes ).map( ( [ name, size ] ) => [ + name, + { + url: size.source_url, + width: size.width, + height: size.height, + orientation: + size.height > size.width + ? 'portrait' + : 'landscape', + }, + ] ) + ) + : undefined, + }; +} + /** * Handles a finished upload by syncing the modal's tile with the server data. * @@ -426,7 +498,10 @@ function handleSuccess( ): void { const { wp } = window as any; - model.set( { id: attachment.id }, { silent: true } ); + // Not silent: `Attachments` re-keys a model from its cid to its id when it + // sees the `change` event, and without that the collection cannot find the + // attachment by id, so refetching the library duplicates its tile. + model.set( { id: attachment.id } ); // Register the model in Attachments.all (parity with wp-plupload.js). wp.media.model.Attachment.get( attachment.id, model ); @@ -444,7 +519,7 @@ function handleSuccess( .fail( () => { // The fetch failed but the upload did not: clear the uploading // state with what the pipeline returned so no tile is stuck. - model.set( attachment, { silent: true } ); + model.set( toModelAttributes( attachment ), { silent: true } ); clearUploadingState(); } ) .always( () => { diff --git a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts index 247155010d0e28..32322a98cdbdaf 100644 --- a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts +++ b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const addItems = vi.fn(); const getSettings = vi.fn( () => ( { mediaUpload: () => {} } ) ); -const getItems = vi.fn( () => [] ); +const getItems = vi.fn( (): any[] => [] ); const detectClientSideMediaSupport = vi.fn( () => ( { supported: true } ) ); const isHeicCanvasSupported = vi.fn( () => false ); +const unsubscribe = vi.fn(); +let notifyStoreChange: () => void = () => {}; vi.mock( import( '@wordpress/data' ), @@ -12,17 +14,10 @@ vi.mock( ( { dispatch: () => ( { addItems } ), select: () => ( { getSettings, getItems } ), - subscribe: () => () => {}, - } ) as any -); - -vi.mock( - import( '@wordpress/upload-media' ), - () => - ( { - store: { name: 'core/upload-media' }, - detectClientSideMediaSupport: () => detectClientSideMediaSupport(), - isHeicCanvasSupported: () => isHeicCanvasSupported(), + subscribe: ( listener: () => void ) => { + notifyStoreChange = listener; + return unsubscribe; + }, } ) as any ); @@ -75,6 +70,10 @@ function setUpGlobals() { ( window as any ).plupload = { FAILED: 4 }; ( window as any ).wp = { Uploader, + uploadMedia: { + detectClientSideMediaSupport: () => detectClientSideMediaSupport(), + isHeicCanvasSupported: () => isHeicCanvasSupported(), + }, media: { model: { settings: { post: { id: 42 } }, @@ -163,6 +162,8 @@ describe( 'installClientSideModalUploads', () => { afterEach( () => { vi.clearAllMocks(); + getItems.mockReturnValue( [] ); + notifyStoreChange = () => {}; delete ( window as any ).wp; delete ( window as any ).plupload; delete ( window as any ).__clientSideMediaProcessing; @@ -291,6 +292,99 @@ describe( 'installClientSideModalUploads', () => { } ); expect( model.attributes.percent ).toBeUndefined(); expect( wpUploader.success ).toHaveBeenCalledWith( model ); + + // The id must not be set silently: Attachments re-keys the model from + // its cid to its id on the change event, and a silent set skips it. + expect( model.set ).toHaveBeenCalledWith( { id: 99 } ); + } ); + + it( 'reports the outcome of an upload only once', async () => { + const { Uploader, created } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, wpUploader, bindings } = createUploader( Uploader ); + + bindings[ 0 ].handler( up, [ createPluploadFile() ] ); + const { onSuccess, onError } = addItems.mock.calls[ 0 ][ 0 ]; + + onError( new Error( 'Upload cancelled' ) ); + // The store can report a parent item twice, and a cancel can be + // followed by a late success. Neither may revive a destroyed tile. + onSuccess( [ { id: 99 } ] ); + onSuccess( [ { id: 99 } ] ); + + expect( created[ 0 ].destroy ).toHaveBeenCalledTimes( 1 ); + expect( created[ 0 ].fetch ).not.toHaveBeenCalled(); + expect( wpUploader.success ).not.toHaveBeenCalled(); + } ); + + it( 'tracks progress for its own queue item only', async () => { + const { Uploader, created } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, bindings } = createUploader( Uploader ); + + bindings[ 0 ].handler( up, [ createPluploadFile() ] ); + const { onSuccess } = addItems.mock.calls[ 0 ][ 0 ]; + + // An item the block editor queued for the very same file must not + // claim this upload's tile. + getItems.mockReturnValue( [ + { id: 'other', onSuccess: () => {}, operations: [ 'a', 'b' ] }, + { id: 'mine', onSuccess, operations: [ 'a', 'b', 'c', 'd' ] }, + ] ); + notifyStoreChange(); + + expect( created[ 0 ].attributes.percent ).toBe( 0 ); + + getItems.mockReturnValue( [ + { id: 'other', onSuccess: () => {}, operations: [] }, + { id: 'mine', onSuccess, operations: [ 'c', 'd' ] }, + ] ); + notifyStoreChange(); + + expect( created[ 0 ].attributes.percent ).toBe( 50 ); + } ); + + it( 'fails a tile whose queue item disappears without reporting', async () => { + const { Uploader, errors, created } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, bindings } = createUploader( Uploader ); + + bindings[ 0 ].handler( up, [ createPluploadFile() ] ); + const { onSuccess } = addItems.mock.calls[ 0 ][ 0 ]; + + getItems.mockReturnValue( [ + { id: 'mine', onSuccess, operations: [] }, + ] ); + notifyStoreChange(); + + // cancelItem() can remove an item silently, without calling back. + getItems.mockReturnValue( [] ); + notifyStoreChange(); + + expect( created[ 0 ].destroy ).toHaveBeenCalled(); + expect( errors.unshift ).toHaveBeenCalled(); + // Nothing left to watch. + expect( unsubscribe ).toHaveBeenCalled(); + } ); + + it( 'leaves a batch of files plupload already failed alone', async () => { + const { Uploader } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, bindings } = createUploader( Uploader ); + const failed = { ...createPluploadFile(), status: 4 }; + + // Suppressing the built-in handler here would leave plupload's queue + // unstarted, because that handler is the only caller of up.start(). + expect( bindings[ 0 ].handler( up, [ failed ] ) ).toBeUndefined(); + expect( addItems ).not.toHaveBeenCalled(); } ); it( 'reports a failure through wp.Uploader.errors', async () => { diff --git a/packages/media-utils/tsconfig.build.json b/packages/media-utils/tsconfig.build.json index 309f78074e80e5..8fc23684f829b1 100644 --- a/packages/media-utils/tsconfig.build.json +++ b/packages/media-utils/tsconfig.build.json @@ -19,7 +19,6 @@ { "path": "../notices/tsconfig.build.json" }, { "path": "../private-apis" }, { "path": "../ui/tsconfig.build.json" }, - { "path": "../upload-media/tsconfig.build.json" }, { "path": "../views/tsconfig.build.json" } ] } diff --git a/packages/media-utils/tsconfig.json b/packages/media-utils/tsconfig.json index dcec5f98a973f6..f9ac2c90333dcb 100644 --- a/packages/media-utils/tsconfig.json +++ b/packages/media-utils/tsconfig.json @@ -20,7 +20,6 @@ { "path": "../notices/tsconfig.build.json" }, { "path": "../private-apis" }, { "path": "../ui/tsconfig.build.json" }, - { "path": "../upload-media/tsconfig.build.json" }, { "path": "../views/tsconfig.build.json" } ], // Listed files have pre-existing type errors; include them once fixed. From f676409bc98c1e76ccb815bb57a47d3041288aab Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 4 Sep 2026 15:29:01 -0700 Subject: [PATCH 06/14] Media: Type the upload-media store accessors Addressing a store by name gives `unknown` from `dispatch()`, so `dispatch( 'core/upload-media' ).addItems()` failed the type check. Declare the selectors and action creators this module uses and route every store access through a typed accessor. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HMK7kaTqz41w6rTnjiP9KC --- .../src/utils/client-side-modal-uploads.ts | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/media-utils/src/utils/client-side-modal-uploads.ts b/packages/media-utils/src/utils/client-side-modal-uploads.ts index a4b2db0866753e..abb009d122da5f 100644 --- a/packages/media-utils/src/utils/client-side-modal-uploads.ts +++ b/packages/media-utils/src/utils/client-side-modal-uploads.ts @@ -50,6 +50,47 @@ type UploadMediaGlobal = { isHeicCanvasSupported?: () => boolean; }; +/** + * The parts of the upload-media store this module uses. Addressing a store by + * name gives `unknown`, so the shape is declared here instead. + */ +type UploadStoreSelectors = { + getSettings: () => { + mediaUpload?: unknown; + allImageSizes?: Record< string, unknown >; + }; + getItems: () => any[]; +}; + +type UploadStoreActions = { + addItems: ( args: { + files: File[]; + additionalData: Record< string, unknown >; + onSuccess: ( attachments: any[] ) => void; + onError: ( error: unknown ) => void; + } ) => void; +}; + +/** + * The upload-media store's selectors, where its store is registered. + * + * @return The selectors, or undefined when the store is not registered. + */ +function selectUploadStore(): UploadStoreSelectors | undefined { + return select( UPLOAD_STORE ) as unknown as + | UploadStoreSelectors + | undefined; +} + +/** + * The upload-media store's action creators. + * + * @return The action creators. + */ +function dispatchUploadStore(): UploadStoreActions { + return dispatch( UPLOAD_STORE ) as unknown as UploadStoreActions; +} + /** * HEIC MIME types, the only ones routed through the pipeline when the browser * supports canvas conversion but not full client-side processing. @@ -155,7 +196,7 @@ function isHeicOnlyPipelineActive(): boolean { * @return True when the store is ready to accept files. */ function isPipelineReady(): boolean { - return Boolean( select( UPLOAD_STORE )?.getSettings()?.mediaUpload ); + return Boolean( selectUploadStore()?.getSettings()?.mediaUpload ); } /** @@ -258,7 +299,7 @@ function estimateProgress( item: any, entry: UploadEntry ): number { } const imageSizeCount = Object.keys( - select( UPLOAD_STORE )?.getSettings()?.allImageSizes || {} + selectUploadStore()?.getSettings()?.allImageSizes || {} ).length; const completed = totals.total - remaining; let fraction = 0; @@ -290,7 +331,7 @@ function onStoreChange(): void { return; } - const items: any[] = select( UPLOAD_STORE )?.getItems() ?? []; + const items: any[] = selectUploadStore()?.getItems() ?? []; const liveIds = new Set< string >(); const current = [ ...entries ]; @@ -404,7 +445,7 @@ function queueFile( unsubscribe = unsubscribe ?? subscribe( onStoreChange, UPLOAD_STORE ); - void dispatch( UPLOAD_STORE ).addItems( { + dispatchUploadStore().addItems( { files: [ file ], additionalData, onSuccess: token, From 3939d5564acdb3245b8d2a557b7774018e62724d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 18 Sep 2026 11:19:04 -0700 Subject: [PATCH 07/14] Media: Only take a modal upload where the pipeline is configured `isPipelineReady()` accepted any truthy `mediaUpload` in the upload-media store, but that setting defaults to a no-op: it takes a file and never calls back. On a screen that carries the media modal without a block editor behind it - the site editor's page list, where "Set featured image" opens the modal from a DataViews quick edit - the store is registered but never configured, so every file handed to it stranded the modal's tile at "uploading" and left the Select button disabled. Require `mediaSideload` and `mediaFinalize` as well. Neither has a default, and the provider writes all three in one dispatch, so their presence is what tells a configured store from an untouched one. An unconfigured store now falls back to the classic server-side upload. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XymJGmRuNKwf8srrtsrcLR --- .../src/utils/client-side-modal-uploads.ts | 30 +++++++++++++----- .../client-side-modal-uploads.jsdom.test.ts | 31 ++++++++++++++++++- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/media-utils/src/utils/client-side-modal-uploads.ts b/packages/media-utils/src/utils/client-side-modal-uploads.ts index abb009d122da5f..3cfb53100e8df1 100644 --- a/packages/media-utils/src/utils/client-side-modal-uploads.ts +++ b/packages/media-utils/src/utils/client-side-modal-uploads.ts @@ -57,6 +57,8 @@ type UploadMediaGlobal = { type UploadStoreSelectors = { getSettings: () => { mediaUpload?: unknown; + mediaSideload?: unknown; + mediaFinalize?: unknown; allImageSizes?: Record< string, unknown >; }; getItems: () => any[]; @@ -78,8 +80,7 @@ type UploadStoreActions = { */ function selectUploadStore(): UploadStoreSelectors | undefined { return select( UPLOAD_STORE ) as unknown as - | UploadStoreSelectors - | undefined; + UploadStoreSelectors | undefined; } /** @@ -161,7 +162,7 @@ function getUploadMedia(): UploadMediaGlobal | undefined { function isFullPipelineActive(): boolean { return Boolean( window.__clientSideMediaProcessing && - getUploadMedia()?.detectClientSideMediaSupport?.()?.supported + getUploadMedia()?.detectClientSideMediaSupport?.()?.supported ); } @@ -174,8 +175,8 @@ function isFullPipelineActive(): boolean { function isHeicOnlyPipelineActive(): boolean { return Boolean( window.__clientSideMediaProcessing && - ! isFullPipelineActive() && - getUploadMedia()?.isHeicCanvasSupported?.() + ! isFullPipelineActive() && + getUploadMedia()?.isHeicCanvasSupported?.() ); } @@ -187,6 +188,15 @@ function isHeicOnlyPipelineActive(): boolean { * registered its store - has to stay on the classic path: a degradation, never * data loss. * + * `mediaUpload` alone does not prove the store was configured: it defaults to + * a no-op that accepts a file and never calls back, so handing it one strands + * the modal's tile at "uploading" forever. That is what a screen carrying the + * media modal but no block editor looks like - the site editor's page list, + * where "Set featured image" opens the modal from a DataViews quick edit. + * `mediaSideload` and `mediaFinalize` have no defaults and are written by the + * same provider, in the same dispatch, as the real `mediaUpload`, so requiring + * them is what tells a configured store from an untouched one. + * * The store is read from the default registry, which is the one the block * editor provider writes to unless it was given a registry of its own. Under a * custom `RegistryProvider` this check simply fails and the modal keeps @@ -196,7 +206,13 @@ function isHeicOnlyPipelineActive(): boolean { * @return True when the store is ready to accept files. */ function isPipelineReady(): boolean { - return Boolean( selectUploadStore()?.getSettings()?.mediaUpload ); + const settings = selectUploadStore()?.getSettings(); + + return Boolean( + settings?.mediaUpload && + settings?.mediaSideload && + settings?.mediaFinalize + ); } /** @@ -515,7 +531,7 @@ function toModelAttributes( attachment: any ): Record< string, unknown > { : 'landscape', }, ] ) - ) + ) : undefined, }; } diff --git a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts index 32322a98cdbdaf..5a684322a25dd2 100644 --- a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts +++ b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts @@ -1,7 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const addItems = vi.fn(); -const getSettings = vi.fn( () => ( { mediaUpload: () => {} } ) ); +// The settings a block editor provider writes into the store. `mediaUpload` +// alone is what an untouched store already reports, so the module only treats +// the pipeline as usable once the whole set is there. +const CONFIGURED_SETTINGS = { + mediaUpload: () => {}, + mediaSideload: () => {}, + mediaFinalize: () => {}, +}; +const getSettings = vi.fn( (): Record< string, unknown > => ( { + ...CONFIGURED_SETTINGS, +} ) ); const getItems = vi.fn( (): any[] => [] ); const detectClientSideMediaSupport = vi.fn( () => ( { supported: true } ) ); const isHeicCanvasSupported = vi.fn( () => false ); @@ -163,6 +173,7 @@ describe( 'installClientSideModalUploads', () => { afterEach( () => { vi.clearAllMocks(); getItems.mockReturnValue( [] ); + getSettings.mockReturnValue( { ...CONFIGURED_SETTINGS } ); notifyStoreChange = () => {}; delete ( window as any ).wp; delete ( window as any ).plupload; @@ -239,6 +250,24 @@ describe( 'installClientSideModalUploads', () => { expect( addItems ).not.toHaveBeenCalled(); } ); + it( 'leaves the batch to plupload when only the default mediaUpload is set', async () => { + // An untouched store reports a no-op `mediaUpload` that accepts a file + // and never calls back, which would strand the modal's tile at + // "uploading" - the state of any screen carrying the media modal + // without a block editor behind it. + getSettings.mockReturnValue( { mediaUpload: () => {} } ); + const { Uploader } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, bindings } = createUploader( Uploader ); + + expect( + bindings[ 0 ].handler( up, [ createPluploadFile() ] ) + ).toBeUndefined(); + expect( addItems ).not.toHaveBeenCalled(); + } ); + it( 'leaves the batch to plupload when a file has no native File', async () => { const { Uploader } = setUpGlobals(); const { installClientSideModalUploads } = await loadModule(); From 1e61ec249376712a70f93a07ed76a53f2963e551 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 18 Sep 2026 11:19:15 -0700 Subject: [PATCH 08/14] Media: Wait on the pipeline's own request in the modal upload e2e Both specs waited for a tile without the `uploading` class to appear, which the modal can satisfy without this upload having done anything: queuing the file flips the frame from the upload tab to the library grid, so any attachment an earlier spec in the shard left behind renders a settled tile at once. The first spec then read zero `/wp/v2/media` requests and failed; the second passed in 1.5s without ever checking the upload it was meant to exercise. Wait for the pipeline's finalize request - the last one it makes for a file - then assert no uploading tile is left. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XymJGmRuNKwf8srrtsrcLR --- .../media-modal-client-side-upload.spec.js | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/test/e2e/specs/editor/various/media-modal-client-side-upload.spec.js b/test/e2e/specs/editor/various/media-modal-client-side-upload.spec.js index a69a802f7d18dd..98dbb33d0db165 100644 --- a/test/e2e/specs/editor/various/media-modal-client-side-upload.spec.js +++ b/test/e2e/specs/editor/various/media-modal-client-side-upload.spec.js @@ -32,6 +32,24 @@ const TEST_AVIF_PATH = path.join( // event a file dropped on the modal fires. const FILE_INPUT_SELECTOR = '.media-modal .moxie-shim-html5 input[type="file"]'; +/** + * Resolves once the client-side pipeline finalizes an upload, which is the + * last request it makes for a file. + * + * @param {import('@playwright/test').Page} page The page under test. + * @return {Promise} A promise for the finalize request. + */ +function waitForFinalize( page ) { + return page.waitForRequest( + ( request ) => + request.method() === 'POST' && + /\/wp\/v2\/media\/\d+\/finalize/.test( + decodeURIComponent( request.url() ) + ), + { timeout: 60_000 } + ); +} + test.describe( 'Media modal client-side uploads', () => { test.beforeEach( async ( { admin } ) => { await admin.createNewPost(); @@ -89,12 +107,20 @@ test.describe( 'Media modal client-side uploads', () => { const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); await fileInput.waitFor( { state: 'attached' } ); + + // A settled tile is not on its own proof that this upload finished: + // the modal leaves the upload tab for the library grid as soon as the + // file is queued, so anything already in the library renders one + // straight away. Wait for the pipeline's own last request instead. + const finalized = waitForFinalize( page ); await fileInput.setInputFiles( TEST_IMAGE_PATH ); + await finalized; // The finalized attachment resolves to a normal (non-uploading) tile. - await expect( - modal.locator( 'li.attachment:not(.uploading)' ).first() - ).toBeVisible( { timeout: 60_000 } ); + await expect( modal.locator( 'li.attachment.uploading' ) ).toHaveCount( + 0, + { timeout: 60_000 } + ); // The original upload and every sub-size go through the REST API, and // the upload is finalized exactly once. @@ -153,11 +179,15 @@ test.describe( 'Media modal client-side uploads', () => { const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); await fileInput.waitFor( { state: 'attached' } ); + + const finalized = waitForFinalize( page ); await fileInput.setInputFiles( TEST_AVIF_PATH ); + await finalized; - await expect( - modal.locator( 'li.attachment:not(.uploading)' ).first() - ).toBeVisible( { timeout: 60_000 } ); + await expect( modal.locator( 'li.attachment.uploading' ) ).toHaveCount( + 0, + { timeout: 60_000 } + ); // No upload error is reported and nothing reached the classic // endpoint that would have rejected the file. From 899de1605b996a8d5db897342cb6303df36b14a6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 18 Sep 2026 11:22:40 -0700 Subject: [PATCH 09/14] Media Utils: Move this PR's changelog entry back under Unreleased The 5.55.0 release on 2026-09-10 renamed the `## Unreleased` heading the entry was written under, stranding it in a published version. The new changelog structure validator rejects that. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XymJGmRuNKwf8srrtsrcLR --- packages/media-utils/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/media-utils/CHANGELOG.md b/packages/media-utils/CHANGELOG.md index abc33eded3ff75..57110eb3203800 100644 --- a/packages/media-utils/CHANGELOG.md +++ b/packages/media-utils/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +- Route uploads started from the editor's media modal through the client-side media pipeline instead of `async-upload.php`, so they are processed the same way as a file dropped on a block ([#82473](https://github.com/WordPress/gutenberg/pull/82473)). + ## 5.55.0 (2026-09-10) ### New Features @@ -11,7 +15,6 @@ ### Bug Fixes - Preserve array-valued fields in multipart form data so grouped image-size sideload requests reach the REST API as arrays ([#82353](https://github.com/WordPress/gutenberg/pull/82353)). -- Route uploads started from the editor's media modal through the client-side media pipeline instead of `async-upload.php`, so they are processed the same way as a file dropped on a block ([#82473](https://github.com/WordPress/gutenberg/pull/82473)). ## 5.54.0 (2026-08-26) From 824241a3debd31f2de410a7878804513858ab0dd Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 23 Sep 2026 10:28:59 -0700 Subject: [PATCH 10/14] Read the pipeline's attachment shape in the modal tile fallback The pipeline hands onSuccess the attachment after transformAttachment(), which replaces source_url and alt_text with url and alt and flattens the title to a string. When the tile's refetch failed, the fallback read the raw REST fields and left a finished tile with no URL, alt or title. Read the transformed fields first, keep the REST ones as a fallback, and cover the refetch-failure path with a test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FsWX1VRwNrAQQtytWXzTQ9 --- .../src/utils/client-side-modal-uploads.ts | 26 +++++---- .../client-side-modal-uploads.jsdom.test.ts | 53 +++++++++++++++++++ 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/packages/media-utils/src/utils/client-side-modal-uploads.ts b/packages/media-utils/src/utils/client-side-modal-uploads.ts index 3cfb53100e8df1..9b5be6d1d9c597 100644 --- a/packages/media-utils/src/utils/client-side-modal-uploads.ts +++ b/packages/media-utils/src/utils/client-side-modal-uploads.ts @@ -489,15 +489,16 @@ function getErrorText( error: unknown ): string { } /** - * Translates a REST attachment into the attributes a `wp.media` attachment - * model expects. + * Translates the pipeline's attachment into the attributes a `wp.media` + * attachment model expects. * - * The two shapes disagree on nearly every field a tile renders: `source_url` - * against `url`, `media_details.sizes` against `sizes`, and a raw/rendered - * object against a plain string title. Only used as a fallback, so it fills in - * what the grid reads and leaves the rest to the next refetch. + * The pipeline returns the attachment after `transformAttachment()`, which + * already maps `source_url`, `alt_text` and the title, but keeps the REST + * `media_details.sizes` that the model reads as `sizes`. The raw REST fields + * are still read as a fallback. Only used when the refetch fails, so it fills + * in what the grid reads and leaves the rest to the next refetch. * - * @param attachment The finalized REST attachment. + * @param attachment The finalized attachment. * @return Attributes for a `wp.media` attachment model. */ function toModelAttributes( attachment: any ): Record< string, unknown > { @@ -507,11 +508,14 @@ function toModelAttributes( attachment: any ): Record< string, unknown > { return { id: attachment.id, - title: attachment.title?.raw ?? attachment.title?.rendered ?? '', + title: + typeof attachment.title === 'string' + ? attachment.title + : ( attachment.title?.raw ?? attachment.title?.rendered ?? '' ), filename: details?.file?.split( '/' ).pop() ?? '', - url: attachment.source_url, + url: attachment.url ?? attachment.source_url, link: attachment.link, - alt: attachment.alt_text, + alt: attachment.alt ?? attachment.alt_text, mime: attachment.mime_type, type, subtype, @@ -539,7 +543,7 @@ function toModelAttributes( attachment: any ): Record< string, unknown > { /** * Handles a finished upload by syncing the modal's tile with the server data. * - * The pipeline returns a REST attachment, while the modal's tile is a + * The pipeline returns a block editor attachment, while the modal's tile is a * `wp.media` attachment, so the model is refetched by ID rather than filled in * from the response. * diff --git a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts index 5a684322a25dd2..0890081810775e 100644 --- a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts +++ b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts @@ -327,6 +327,59 @@ describe( 'installClientSideModalUploads', () => { expect( model.set ).toHaveBeenCalledWith( { id: 99 } ); } ); + it( 'fills the tile from the pipeline attachment when the refetch fails', async () => { + const { Uploader, created } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, wpUploader, bindings } = createUploader( Uploader ); + + bindings[ 0 ].handler( up, [ createPluploadFile() ] ); + + const model = created[ 0 ]; + ( model.fetch as ReturnType< typeof vi.fn > ).mockImplementation( + () => { + const deferred = { + done: () => deferred, + fail: ( fn: () => void ) => { + fn(); + return deferred; + }, + always: ( fn: () => void ) => { + fn(); + return deferred; + }, + }; + return deferred; + } + ); + + // The pipeline hands back the attachment after `transformAttachment()`, + // so it carries `url`, `alt` and a string title, not the REST fields. + addItems.mock.calls[ 0 ][ 0 ].onSuccess( [ + { + id: 99, + url: 'https://example.com/test.jpeg', + alt: 'Alt text', + title: 'test', + mime_type: 'image/jpeg', + media_details: { file: '2026/09/test.jpeg' }, + }, + ] ); + + expect( model.attributes ).toMatchObject( { + id: 99, + url: 'https://example.com/test.jpeg', + alt: 'Alt text', + title: 'test', + filename: 'test.jpeg', + type: 'image', + subtype: 'jpeg', + uploading: false, + } ); + expect( wpUploader.success ).toHaveBeenCalledWith( model ); + } ); + it( 'reports the outcome of an upload only once', async () => { const { Uploader, created } = setUpGlobals(); const { installClientSideModalUploads } = await loadModule(); From 291acb6893f5d08ba5ca79acb8ff99f40f847873 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 24 Sep 2026 18:25:09 -0700 Subject: [PATCH 11/14] Clear the modal tile's uploading state before the refetch The attachment details sidebar does not re-render on every model change, only on a title change. The refetch brought the title while the model was still marked as uploading, and uploading: false arrived afterwards, so the sidebar kept showing a progress bar for a finished upload. wp-plupload.js avoids this by setting the response and uploading: false in one call. Clear the uploading state silently before the refetch so the render the title change triggers already sees a finished attachment. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vnh6X3n9GDEZjAbXSYvQNX --- .../src/utils/client-side-modal-uploads.ts | 22 ++++++++-------- .../client-side-modal-uploads.jsdom.test.ts | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/packages/media-utils/src/utils/client-side-modal-uploads.ts b/packages/media-utils/src/utils/client-side-modal-uploads.ts index 9b5be6d1d9c597..64d547fa94026f 100644 --- a/packages/media-utils/src/utils/client-side-modal-uploads.ts +++ b/packages/media-utils/src/utils/client-side-modal-uploads.ts @@ -567,21 +567,21 @@ function handleSuccess( // Register the model in Attachments.all (parity with wp-plupload.js). wp.media.model.Attachment.get( attachment.id, model ); - const clearUploadingState = () => { - [ 'file', 'loaded', 'size', 'percent' ].forEach( ( key ) => - model.unset( key, { silent: true } ) - ); - model.set( { uploading: false } ); - }; + // Clear the uploading state before the refetch lands, not after. The + // details sidebar only re-renders when the title changes, so it has to see + // `uploading: false` by then or it keeps showing a progress bar. The + // classic uploader sets both in one call for the same reason. + [ 'file', 'loaded', 'size', 'percent' ].forEach( ( key ) => + model.unset( key, { silent: true } ) + ); + model.set( { uploading: false }, { silent: true } ); model .fetch() - .done( clearUploadingState ) .fail( () => { - // The fetch failed but the upload did not: clear the uploading - // state with what the pipeline returned so no tile is stuck. - model.set( toModelAttributes( attachment ), { silent: true } ); - clearUploadingState(); + // The fetch failed but the upload did not: fill the tile with what + // the pipeline returned so it is not left empty. + model.set( toModelAttributes( attachment ) ); } ) .always( () => { maybeResetQueue(); diff --git a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts index 0890081810775e..78c43007cc0cf5 100644 --- a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts +++ b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts @@ -327,6 +327,31 @@ describe( 'installClientSideModalUploads', () => { expect( model.set ).toHaveBeenCalledWith( { id: 99 } ); } ); + it( 'clears the uploading state before the refetch updates the tile', async () => { + const { Uploader, created } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, bindings } = createUploader( Uploader ); + + bindings[ 0 ].handler( up, [ createPluploadFile() ] ); + + // The details sidebar re-renders only on the title change the refetch + // brings, so the model must no longer be uploading at that point. + const model = created[ 0 ]; + const fetch = model.fetch as ReturnType< typeof vi.fn >; + const fetchImplementation = fetch.getMockImplementation()!; + let uploadingWhenFetched: unknown; + fetch.mockImplementation( () => { + uploadingWhenFetched = model.attributes.uploading; + return fetchImplementation(); + } ); + + addItems.mock.calls[ 0 ][ 0 ].onSuccess( [ { id: 99 } ] ); + + expect( uploadingWhenFetched ).toBe( false ); + } ); + it( 'fills the tile from the pipeline attachment when the refetch fails', async () => { const { Uploader, created } = setUpGlobals(); const { installClientSideModalUploads } = await loadModule(); From 42291f021e00964612e4a7a40170c16a1c2ac177 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 24 Sep 2026 22:05:28 -0700 Subject: [PATCH 12/14] Type the refetch mock so its implementation is callable getMockImplementation() on an untyped vi.fn() returns a union that includes a constructor type, which fails the TS2349 typecheck in CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vnh6X3n9GDEZjAbXSYvQNX --- .../src/utils/test/client-side-modal-uploads.jsdom.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts index 78c43007cc0cf5..3cd593367eea27 100644 --- a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts +++ b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; const addItems = vi.fn(); // The settings a block editor provider writes into the store. `mediaUpload` @@ -339,7 +340,7 @@ describe( 'installClientSideModalUploads', () => { // The details sidebar re-renders only on the title change the refetch // brings, so the model must no longer be uploading at that point. const model = created[ 0 ]; - const fetch = model.fetch as ReturnType< typeof vi.fn >; + const fetch = model.fetch as Mock< () => unknown >; const fetchImplementation = fetch.getMockImplementation()!; let uploadingWhenFetched: unknown; fetch.mockImplementation( () => { From 76a26346bae9fed5d1b0d62538eff6c29bbd9975 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 15:23:27 -0700 Subject: [PATCH 13/14] Media modal uploads: only intercept attachment uploaders and type the module The FilesAdded patch reaches every wp.Uploader built after the modal opens, including one a plugin builds from the block editor for its own endpoint. Check core's defaults (async-upload.php with the upload-attachment action) before taking a batch, so anything pointed elsewhere keeps its own upload. Replace the module's `any` parameters with the package's Attachment type for what the pipeline returns and narrow local shapes for the plupload, wp.Uploader and Backbone model objects, and drop the raw REST field fallbacks the typed shape makes unreachable. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015q272S5jKgWCuQYrbz2SKb --- .../src/utils/client-side-modal-uploads.ts | 185 ++++++++++++++---- .../client-side-modal-uploads.jsdom.test.ts | 32 +++ 2 files changed, 183 insertions(+), 34 deletions(-) diff --git a/packages/media-utils/src/utils/client-side-modal-uploads.ts b/packages/media-utils/src/utils/client-side-modal-uploads.ts index 64d547fa94026f..dd7268bdca00e8 100644 --- a/packages/media-utils/src/utils/client-side-modal-uploads.ts +++ b/packages/media-utils/src/utils/client-side-modal-uploads.ts @@ -1,5 +1,6 @@ import { __ } from '@wordpress/i18n'; import { dispatch, select, subscribe } from '@wordpress/data'; +import type { Attachment } from './types'; /** * Routes uploads started from the editor's media modal through the client-side @@ -61,18 +62,50 @@ type UploadStoreSelectors = { mediaFinalize?: unknown; allImageSizes?: Record< string, unknown >; }; - getItems: () => any[]; + getItems: () => UploadQueueItem[]; }; type UploadStoreActions = { addItems: ( args: { files: File[]; additionalData: Record< string, unknown >; - onSuccess: ( attachments: any[] ) => void; + onSuccess: ( attachments: PipelineAttachment[] ) => void; onError: ( error: unknown ) => void; } ) => void; }; +/** + * The attachment the pipeline hands to `onSuccess`: the REST attachment after + * `transformAttachment()`, and only the fields the store chose to carry. + */ +type PipelineAttachment = Partial< Attachment >; + +/** + * The parts of `media_details` the modal tile reads. + */ +type AttachmentMediaDetails = { + file?: string; + width?: number; + height?: number; + sizes?: Record< + string, + { source_url: string; width: number; height: number } + >; +}; + +/** + * The parts of an upload-media queue item this module reads. + */ +type UploadQueueItem = { + id: string; + parentId?: string; + progress?: number; + operations?: unknown[]; + currentOperation?: string; + subSizes?: unknown[]; + onSuccess?: ( attachments: PipelineAttachment[] ) => void; +}; + /** * The upload-media store's selectors, where its store is registered. * @@ -116,6 +149,64 @@ type PluploadFile = { getNative?: () => File | null; }; +/** + * The parts of a plupload uploader this module relies on. + */ +type PluploadUploader = { + settings?: { + url?: string; + multipart_params?: Record< string, string >; + }; + bind: ( + event: string, + handler: ( + up: PluploadUploader, + files: PluploadFile[] + ) => boolean | undefined, + scope: unknown, + priority: number + ) => void; + removeFile: ( file: PluploadFile ) => void; + refresh: () => void; + __clientSideUploadsBound?: boolean; +}; + +/** + * The chainable jQuery deferred `Backbone.Model.fetch()` returns. + */ +type JQueryLikeDeferred = { + fail: ( callback: () => void ) => JQueryLikeDeferred; + always: ( callback: () => void ) => JQueryLikeDeferred; +}; + +/** + * The parts of a `wp.media` attachment model this module touches. + */ +type AttachmentModel = { + get: ( key: string ) => unknown; + set: ( + attributes: Record< string, unknown >, + options?: { silent?: boolean } + ) => void; + unset: ( key: string, options?: { silent?: boolean } ) => void; + fetch: () => JQueryLikeDeferred; + destroy: () => void; +}; + +/** + * The parts of a `wp.Uploader` instance this module relies on. + */ +type WpUploader = { + uploader?: PluploadUploader; + added: ( model: AttachmentModel ) => void; + success: ( model: AttachmentModel ) => void; + error: ( + message: string, + data: Record< string, unknown >, + file: PluploadFile + ) => void; +}; + /** * The bookkeeping kept for one queued upload. */ @@ -123,7 +214,7 @@ type UploadEntry = { /** ID of the queue item it matched, once one is known. */ itemId: string | null; /** The `onSuccess` handed to the store, which identifies its queue item. */ - token: ( attachments: any[] ) => void; + token: ( attachments: PipelineAttachment[] ) => void; /** Called with an integer percentage whenever it changes. */ onProgress: ( percent: number ) => void; /** Called when the upload fails or is dropped from the queue. */ @@ -215,6 +306,28 @@ function isPipelineReady(): boolean { ); } +/** + * Whether a plupload uploader posts attachments the way `wp.Uploader` does by + * default. + * + * The patch reaches every `wp.Uploader` built after the modal opens, and a + * plugin can build one from the block editor for its own purposes. Core's + * defaults tell an attachment upload apart: it posts to `async-upload.php` + * with the `upload-attachment` action. Anything a plugin pointed elsewhere, or + * gave another action, is uploading something else and is left alone. + * + * @param up The plupload uploader instance. + * @return True when the uploader posts attachments. + */ +function isAttachmentUploader( up: PluploadUploader ): boolean { + const url = String( up.settings?.url ?? '' ).split( '?' )[ 0 ]; + + return ( + 'upload-attachment' === up.settings?.multipart_params?.action && + url.endsWith( 'async-upload.php' ) + ); +} + /** * Whether a batch of files added to plupload can go through the pipeline. * @@ -261,18 +374,18 @@ function canHandleBatch( files: PluploadFile[] ): boolean { * @return Additional data for the upload. */ function additionalDataFromParams( - params: Record< string, string > + params: Record< string, string > = {} ): Record< string, unknown > { const additionalData: Record< string, unknown > = {}; - Object.keys( params || {} ).forEach( ( key ) => { + Object.keys( params ).forEach( ( key ) => { if ( 'action' === key || '_wpnonce' === key || 'post_id' === key ) { return; } additionalData[ key ] = params[ key ]; } ); - const postId = parseInt( params?.post_id, 10 ); + const postId = parseInt( params.post_id, 10 ); if ( postId ) { additionalData.post = postId; } @@ -293,7 +406,7 @@ function additionalDataFromParams( * @param entry The bookkeeping for the upload. * @return Estimated progress. */ -function estimateProgress( item: any, entry: UploadEntry ): number { +function estimateProgress( item: UploadQueueItem, entry: UploadEntry ): number { if ( typeof item.progress === 'number' ) { return item.progress; } @@ -347,7 +460,7 @@ function onStoreChange(): void { return; } - const items: any[] = selectUploadStore()?.getItems() ?? []; + const items = selectUploadStore()?.getItems() ?? []; const liveIds = new Set< string >(); const current = [ ...entries ]; @@ -428,7 +541,7 @@ function queueFile( file: File, additionalData: Record< string, unknown >, callbacks: { - onSuccess: ( attachment: any ) => void; + onSuccess: ( attachment: PipelineAttachment ) => void; onError: ( error: unknown ) => void; onProgress: ( percent: number ) => void; } @@ -437,7 +550,7 @@ function queueFile( // `onStoreChange` tells this upload's item apart from every other one. The // store can report an outcome more than once, so `release()` decides which // call owns it. - const token = ( attachments: any[] ) => { + const token = ( attachments: PipelineAttachment[] ) => { if ( release( entry ) ) { callbacks.onSuccess( attachments[ 0 ] ); } @@ -494,28 +607,28 @@ function getErrorText( error: unknown ): string { * * The pipeline returns the attachment after `transformAttachment()`, which * already maps `source_url`, `alt_text` and the title, but keeps the REST - * `media_details.sizes` that the model reads as `sizes`. The raw REST fields - * are still read as a fallback. Only used when the refetch fails, so it fills - * in what the grid reads and leaves the rest to the next refetch. + * `media_details.sizes` that the model reads as `sizes`. Only used when the + * refetch fails, so it fills in what the grid reads and leaves the rest to the + * next refetch. * * @param attachment The finalized attachment. * @return Attributes for a `wp.media` attachment model. */ -function toModelAttributes( attachment: any ): Record< string, unknown > { +function toModelAttributes( + attachment: PipelineAttachment +): Record< string, unknown > { const [ type, subtype ] = String( attachment.mime_type || '' ).split( '/' ); - const details = attachment.media_details; + const details = attachment.media_details as + AttachmentMediaDetails | undefined; const sizes = details?.sizes; return { id: attachment.id, - title: - typeof attachment.title === 'string' - ? attachment.title - : ( attachment.title?.raw ?? attachment.title?.rendered ?? '' ), + title: attachment.title ?? '', filename: details?.file?.split( '/' ).pop() ?? '', - url: attachment.url ?? attachment.source_url, + url: attachment.url, link: attachment.link, - alt: attachment.alt ?? attachment.alt_text, + alt: attachment.alt, mime: attachment.mime_type, type, subtype, @@ -523,7 +636,7 @@ function toModelAttributes( attachment: any ): Record< string, unknown > { height: details?.height, sizes: sizes ? Object.fromEntries( - Object.entries< any >( sizes ).map( ( [ name, size ] ) => [ + Object.entries( sizes ).map( ( [ name, size ] ) => [ name, { url: size.source_url, @@ -553,9 +666,9 @@ function toModelAttributes( attachment: any ): Record< string, unknown > { * @param attachment.id ID of the finalized attachment. */ function handleSuccess( - wpUploader: any, - model: any, - attachment: { id: number } + wpUploader: WpUploader, + model: AttachmentModel, + attachment: PipelineAttachment ): void { const { wp } = window as any; @@ -601,8 +714,8 @@ function handleSuccess( * @param file The plupload file that failed. */ function handleError( - wpUploader: any, - model: any, + wpUploader: WpUploader, + model: AttachmentModel, error: unknown, file: PluploadFile ): void { @@ -627,7 +740,7 @@ function maybeResetQueue(): void { const { wp } = window as any; const complete = wp.Uploader.queue.all( - ( attachment: any ) => ! attachment.get( 'uploading' ) + ( attachment: AttachmentModel ) => ! attachment.get( 'uploading' ) ); if ( complete ) { @@ -649,11 +762,12 @@ function maybeResetQueue(): void { * @return False to suppress the built-in handler. */ function handleFilesAdded( - wpUploader: any, - up: any, + wpUploader: WpUploader, + up: PluploadUploader, files: PluploadFile[] ): boolean | undefined { if ( + ! isAttachmentUploader( up ) || ! ( isFullPipelineActive() || isHeicOnlyPipelineActive() ) || ! isPipelineReady() || ! canHandleBatch( files ) @@ -696,7 +810,8 @@ function handleFilesAdded( attributes.subtype = 'jpg' === extension ? 'jpeg' : extension; } - const model = wp.media.model.Attachment.create( attributes ); + const model: AttachmentModel = + wp.media.model.Attachment.create( attributes ); wp.Uploader.queue.add( model ); wpUploader.added( model ); @@ -738,7 +853,10 @@ export function installClientSideModalUploads(): void { // wp.Uploader.prototype.init is an empty stub core calls once per // instance, after plupload has been initialized. const originalInit = wp.Uploader.prototype.init; - wp.Uploader.prototype.init = function ( this: any, ...args: unknown[] ) { + wp.Uploader.prototype.init = function ( + this: WpUploader, + ...args: unknown[] + ) { originalInit.apply( this, args ); const up = this.uploader; @@ -752,8 +870,7 @@ export function installClientSideModalUploads(): void { // the built-in FilesAdded handler. up.bind( 'FilesAdded', - ( uploader: any, files: PluploadFile[] ) => - handleFilesAdded( this, uploader, files ), + ( uploader, files ) => handleFilesAdded( this, uploader, files ), this, 100 ); diff --git a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts index 3cd593367eea27..36b067a9a90b48 100644 --- a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts +++ b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts @@ -117,6 +117,7 @@ function createUploader( Uploader: any ) { } > = []; const up = { settings: { + url: '/wp-admin/async-upload.php', multipart_params: { action: 'upload-attachment', _wpnonce: 'nonce', @@ -269,6 +270,37 @@ describe( 'installClientSideModalUploads', () => { expect( addItems ).not.toHaveBeenCalled(); } ); + it( 'leaves a plugin uploader with its own action alone', async () => { + // A plugin can build its own `wp.Uploader` from the block editor after + // the modal has opened. One that posts something other than an + // attachment is not the modal's, so the pipeline must not take it. + const { Uploader } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, bindings } = createUploader( Uploader ); + up.settings.multipart_params.action = 'my_plugin_import'; + + expect( + bindings[ 0 ].handler( up, [ createPluploadFile() ] ) + ).toBeUndefined(); + expect( addItems ).not.toHaveBeenCalled(); + } ); + + it( 'leaves a plugin uploader with its own endpoint alone', async () => { + const { Uploader } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, bindings } = createUploader( Uploader ); + up.settings.url = 'https://example.com/wp-json/my-plugin/v1/import'; + + expect( + bindings[ 0 ].handler( up, [ createPluploadFile() ] ) + ).toBeUndefined(); + expect( addItems ).not.toHaveBeenCalled(); + } ); + it( 'leaves the batch to plupload when a file has no native File', async () => { const { Uploader } = setUpGlobals(); const { installClientSideModalUploads } = await loadModule(); From cea684f52bfd339b25d5c0c840a3761755e42a50 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 16:53:44 -0700 Subject: [PATCH 14/14] Media modal uploads: yield to core's media frame integration Core's media-frame-upload script (wordpress-develop #13875) binds the same FilesAdded handler at the same priority and sets window.__wpMediaFrameUpload once it has. Skip installing this module's handler when that flag is present, so the first `false` return is core's by design rather than by load order. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015q272S5jKgWCuQYrbz2SKb --- .../src/utils/client-side-modal-uploads.ts | 14 +++++++++++++- .../test/client-side-modal-uploads.jsdom.test.ts | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/media-utils/src/utils/client-side-modal-uploads.ts b/packages/media-utils/src/utils/client-side-modal-uploads.ts index dd7268bdca00e8..64b675c06a9a49 100644 --- a/packages/media-utils/src/utils/client-side-modal-uploads.ts +++ b/packages/media-utils/src/utils/client-side-modal-uploads.ts @@ -34,6 +34,8 @@ import type { Attachment } from './types'; declare global { interface Window { __clientSideMediaProcessing?: boolean; + /** Set by core's media-frame-upload script once it handles these uploads. */ + __wpMediaFrameUpload?: boolean; plupload?: { FAILED: number }; } } @@ -840,11 +842,21 @@ function handleFilesAdded( * Safe to call repeatedly: the patch is applied once, and it decides per batch * of files whether the pipeline can take them, so an uploader created before * the block editor configured the pipeline still works. + * + * Does nothing where core's own media frame integration has already bound + * its handler: both would bind at the same priority and the first to return + * `false` would silence the other, with load order deciding which. */ export function installClientSideModalUploads(): void { const { wp, plupload } = window as any; - if ( isInstalled || ! wp?.Uploader || ! wp?.media || ! plupload ) { + if ( + isInstalled || + window.__wpMediaFrameUpload || + ! wp?.Uploader || + ! wp?.media || + ! plupload + ) { return; } diff --git a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts index 36b067a9a90b48..13edccca22aa03 100644 --- a/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts +++ b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts @@ -180,6 +180,7 @@ describe( 'installClientSideModalUploads', () => { delete ( window as any ).wp; delete ( window as any ).plupload; delete ( window as any ).__clientSideMediaProcessing; + delete ( window as any ).__wpMediaFrameUpload; } ); it( 'binds FilesAdded ahead of the built-in handler', async () => { @@ -202,6 +203,20 @@ describe( 'installClientSideModalUploads', () => { expect( () => installClientSideModalUploads() ).not.toThrow(); } ); + it( 'leaves the uploader alone when core already handles media frame uploads', async () => { + // Core's media-frame-upload script sets this flag once it has bound + // its own handler, so a second one here would only race it. + ( window as any ).__wpMediaFrameUpload = true; + const { Uploader } = setUpGlobals(); + const { installClientSideModalUploads } = await loadModule(); + + installClientSideModalUploads(); + const { up, bindings } = createUploader( Uploader ); + + expect( bindings ).toEqual( [] ); + expect( up.bind ).not.toHaveBeenCalled(); + } ); + it( 'routes a file through the pipeline instead of plupload', async () => { const { Uploader, queue, created } = setUpGlobals(); const { installClientSideModalUploads } = await loadModule();