diff --git a/packages/media-utils/CHANGELOG.md b/packages/media-utils/CHANGELOG.md index 512082c77749bf..2216cc713e0bf8 100644 --- a/packages/media-utils/CHANGELOG.md +++ b/packages/media-utils/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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)). - Declare `react-dom` and `@types/react-dom` as peer dependencies, forwarding the peers of `@wordpress/element`, so strict package managers such as Yarn PnP can resolve them ([#83765](https://github.com/WordPress/gutenberg/pull/83765)). ## 5.56.0 (2026-09-23) diff --git a/packages/media-utils/src/components/media-upload/index.js b/packages/media-utils/src/components/media-upload/index.js index 4cfe286190704b..5af35ecdcaeeb1 100644 --- a/packages/media-utils/src/components/media-upload/index.js +++ b/packages/media-utils/src/components/media-upload/index.js @@ -3,6 +3,7 @@ import { __ } from '@wordpress/i18n'; import deprecated from '@wordpress/deprecated'; 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 = []; @@ -565,6 +566,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, featuredImageFlow, 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..64b675c06a9a49 --- /dev/null +++ b/packages/media-utils/src/utils/client-side-modal-uploads.ts @@ -0,0 +1,890 @@ +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 + * 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. + * + * 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 + */ + +declare global { + interface Window { + __clientSideMediaProcessing?: boolean; + /** Set by core's media-frame-upload script once it handles these uploads. */ + __wpMediaFrameUpload?: boolean; + plupload?: { FAILED: number }; + } +} + +/** + * Name of the upload-media store. + */ +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; +}; + +/** + * 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; + mediaSideload?: unknown; + mediaFinalize?: unknown; + allImageSizes?: Record< string, unknown >; + }; + getItems: () => UploadQueueItem[]; +}; + +type UploadStoreActions = { + addItems: ( args: { + files: File[]; + additionalData: Record< string, unknown >; + 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. + * + * @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. + */ +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 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. + */ +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: 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. */ + onError: ( error: unknown ) => 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 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; + +/** + * The upload-media package, where WordPress has printed it. + * + * @return The package's exports, or undefined where it is not loaded. + */ +function getUploadMedia(): UploadMediaGlobal | undefined { + return ( window as any ).wp?.uploadMedia; +} + +/** + * 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 && + getUploadMedia()?.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() && + getUploadMedia()?.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 - or on a screen where `@wordpress/upload-media` never + * 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 + * uploading server-side - the same trade-off `invalidateAttachmentResolutions` + * documents. + * + * @return True when the store is ready to accept files. + */ +function isPipelineReady(): boolean { + const settings = selectUploadStore()?.getSettings(); + + return Boolean( + settings?.mediaUpload && + settings?.mediaSideload && + settings?.mediaFinalize + ); +} + +/** + * 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. + * + * 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. 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 uploadable.every( ( file ) => { + 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: UploadQueueItem, 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( + selectUploadStore()?.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 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 ( ! entries.size ) { + return; + } + + const items = selectUploadStore()?.getItems() ?? []; + const liveIds = new Set< string >(); + const current = [ ...entries ]; + + items.forEach( ( item ) => { + if ( item.parentId ) { + return; + } + + liveIds.add( item.id ); + + const entry = + current.find( ( candidate ) => candidate.itemId === item.id ) ?? + current.find( + ( candidate ) => + ! candidate.itemId && candidate.token === item.onSuccess + ); + 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 ): boolean { + if ( entry.released ) { + return false; + } + entry.released = true; + entries.delete( entry ); + + if ( ! entries.size ) { + unsubscribe?.(); + unsubscribe = undefined; + } + + return true; +} + +/** + * 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: PipelineAttachment ) => void; + onError: ( error: unknown ) => void; + 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: PipelineAttachment[] ) => { + if ( release( entry ) ) { + callbacks.onSuccess( attachments[ 0 ] ); + } + }; + + const entry: UploadEntry = { + itemId: null, + token, + onProgress: callbacks.onProgress, + onError: ( error: unknown ) => { + if ( release( entry ) ) { + callbacks.onError( error ); + } + }, + lastPercent: -1, + totals: null, + released: false, + }; + + entries.add( entry ); + + unsubscribe = unsubscribe ?? subscribe( onStoreChange, UPLOAD_STORE ); + + dispatchUploadStore().addItems( { + files: [ file ], + additionalData, + onSuccess: token, + onError: ( error: unknown ) => entry.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.' ); +} + +/** + * Translates the pipeline's attachment into the attributes a `wp.media` + * attachment model expects. + * + * 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`. 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: PipelineAttachment +): Record< string, unknown > { + const [ type, subtype ] = String( attachment.mime_type || '' ).split( '/' ); + const details = attachment.media_details as + AttachmentMediaDetails | undefined; + const sizes = details?.sizes; + + return { + id: attachment.id, + title: attachment.title ?? '', + filename: details?.file?.split( '/' ).pop() ?? '', + url: attachment.url, + link: attachment.link, + alt: attachment.alt, + mime: attachment.mime_type, + type, + subtype, + width: details?.width, + height: details?.height, + sizes: sizes + ? Object.fromEntries( + Object.entries( 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. + * + * 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. + * + * @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: WpUploader, + model: AttachmentModel, + attachment: PipelineAttachment +): void { + const { wp } = window as any; + + // 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 ); + + // 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() + .fail( () => { + // 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(); + 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: WpUploader, + model: AttachmentModel, + 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: AttachmentModel ) => ! 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: WpUploader, + up: PluploadUploader, + files: PluploadFile[] +): boolean | undefined { + if ( + ! isAttachmentUploader( up ) || + ! ( 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: AttachmentModel = + 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. + * + * 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 || + window.__wpMediaFrameUpload || + ! 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: WpUploader, + ...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, 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 new file mode 100644 index 00000000000000..13edccca22aa03 --- /dev/null +++ b/packages/media-utils/src/utils/test/client-side-modal-uploads.jsdom.test.ts @@ -0,0 +1,570 @@ +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` +// 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 ); +const unsubscribe = vi.fn(); +let notifyStoreChange: () => void = () => {}; + +vi.mock( + import( '@wordpress/data' ), + () => + ( { + dispatch: () => ( { addItems } ), + select: () => ( { getSettings, getItems } ), + subscribe: ( listener: () => void ) => { + notifyStoreChange = listener; + return unsubscribe; + }, + } ) 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, + uploadMedia: { + detectClientSideMediaSupport: () => detectClientSideMediaSupport(), + isHeicCanvasSupported: () => isHeicCanvasSupported(), + }, + 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: { + url: '/wp-admin/async-upload.php', + 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(); + getItems.mockReturnValue( [] ); + getSettings.mockReturnValue( { ...CONFIGURED_SETTINGS } ); + notifyStoreChange = () => {}; + 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 () => { + 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( '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(); + + 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 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 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(); + + 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 ); + + // 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( '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 Mock< () => unknown >; + 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(); + + 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(); + + 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 () => { + 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..98dbb33d0db165 --- /dev/null +++ b/test/e2e/specs/editor/various/media-modal-client-side-upload.spec.js @@ -0,0 +1,197 @@ +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"]'; + +/** + * 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(); + } ); + + 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' } ); + + // 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.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. + 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' } ); + + const finalized = waitForFinalize( page ); + await fileInput.setInputFiles( TEST_AVIF_PATH ); + await finalized; + + 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. + await expect( modal.locator( '.upload-error' ) ).toHaveCount( 0 ); + expect( asyncUploads ).toEqual( [] ); + } ); +} );