From ee7a5fc42c8170d7a709929e200071791fd16795 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 26 Feb 2026 12:07:41 +0700 Subject: [PATCH 01/15] Add hooks and extension points for client-side media processing Adds the following hooks and extension points to ensure existing media hooks continue to run when client-side media processing is active: PHP: - Finalize REST endpoint (POST /wp/v2/media/{id}/finalize) that triggers wp_generate_attachment_metadata filter after all client-side operations complete, so server-side plugins can post-process attachments. - Preload paths for image_sizes and image_size_threshold fields. JS: - editor.media.imageQuality hook to filter image quality for resize ops. - editor.media.imageSizesToGenerate hook to filter which thumbnail sizes are generated client-side. - Finalize operation type appended to the image upload pipeline after thumbnail generation, with gating to wait for child sideloads. - Parent trigger so finalization runs after all sideloads complete. Co-Authored-By: Claude Opus 4.6 --- ...-gutenberg-rest-attachments-controller.php | 85 ++++++++++++++ lib/media/load.php | 26 +++++ package-lock.json | 1 + packages/upload-media/package.json | 1 + .../upload-media/src/store/private-actions.ts | 110 +++++++++++++++++- packages/upload-media/src/store/types.ts | 1 + 6 files changed, 220 insertions(+), 4 deletions(-) diff --git a/lib/media/class-gutenberg-rest-attachments-controller.php b/lib/media/class-gutenberg-rest-attachments-controller.php index 8183e75463fac1..f72c3064938561 100644 --- a/lib/media/class-gutenberg-rest-attachments-controller.php +++ b/lib/media/class-gutenberg-rest-attachments-controller.php @@ -58,6 +58,26 @@ public function register_routes(): void { ), true // Override core's route so 'scaled' is included in the enum. ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/finalize', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'finalize_item' ), + 'permission_callback' => array( $this, 'finalize_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + ), + 'allow_batch' => $this->allow_batch, + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); } /** @@ -233,6 +253,71 @@ public function create_item( $request ) { } + /** + * Checks if a given request has access to finalize an attachment. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access, WP_Error object otherwise. + */ + public function finalize_item_permissions_check( $request ) { + return $this->edit_media_item_permissions_check( $request ); + } + + /** + * Finalizes an attachment after client-side media processing. + * + * Triggers the {@see 'wp_generate_attachment_metadata'} filter so that + * server-side plugins can process the attachment after all client-side + * operations (upload, thumbnail generation, sideloads) are complete. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. + */ + public function finalize_item( WP_REST_Request $request ) { + $attachment_id = $request['id']; + + $post = $this->get_post( $attachment_id ); + + if ( is_wp_error( $post ) ) { + return $post; + } + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + if ( ! is_array( $metadata ) ) { + $metadata = array(); + } + + /** + * Filters the attachment metadata after client-side processing. + * + * This re-applies the wp_generate_attachment_metadata filter so that + * server-side plugins (e.g. those adding custom image sizes or + * processing metadata) can run after client-side uploads are complete. + * + * @param array $metadata Attachment metadata. + * @param int $attachment_id Attachment ID. + * @param string $context Context: 'create' or 'update'. + */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' ); + + wp_update_attachment_metadata( $attachment_id, $metadata ); + + $response_request = new WP_REST_Request( + WP_REST_Server::READABLE, + rest_get_route_for_post( $attachment_id ) + ); + + $response_request['context'] = 'edit'; + + if ( isset( $request['_fields'] ) ) { + $response_request['_fields'] = $request['_fields']; + } + + return $this->prepare_item_for_response( get_post( $attachment_id ), $response_request ); + } + /** * Checks if a given request has access to sideload a file. * diff --git a/lib/media/load.php b/lib/media/load.php index 6d2cf05561d821..9bd3b964845192 100644 --- a/lib/media/load.php +++ b/lib/media/load.php @@ -381,3 +381,29 @@ static function (): void { } add_action( 'wp_enqueue_media', 'gutenberg_override_media_templates' ); + +/** + * Filters the block editor preload paths to include media processing fields. + * + * Adds image_sizes and image_size_threshold to the root endpoint preload + * to avoid an extra API request when these fields are needed. + * + * @param array $paths REST API paths to preload. + * @return array Filtered preload paths. + */ +function gutenberg_media_processing_preload_paths( $paths ) { + foreach ( $paths as $key => $path ) { + if ( is_string( $path ) && str_starts_with( $path, '/?_fields=' ) ) { + // Add image_sizes and image_size_threshold after "home," to match + // the field order in packages/core-data/src/entities.js. + $paths[ $key ] = str_replace( + ',home,', + ',home,image_sizes,image_size_threshold,', + $path + ); + break; + } + } + return $paths; +} +add_filter( 'block_editor_rest_api_preload_paths', 'gutenberg_media_processing_preload_paths', 10 ); diff --git a/package-lock.json b/package-lock.json index 626f5e9fd61ba2..48df5317dc2058 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62722,6 +62722,7 @@ "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", "@wordpress/element": "file:../element", + "@wordpress/hooks": "file:../hooks", "@wordpress/i18n": "file:../i18n", "@wordpress/preferences": "file:../preferences", "@wordpress/private-apis": "file:../private-apis", diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json index 191a6e72ea8569..73dc82b1a4e764 100644 --- a/packages/upload-media/package.json +++ b/packages/upload-media/package.json @@ -54,6 +54,7 @@ "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", "@wordpress/element": "file:../element", + "@wordpress/hooks": "file:../hooks", "@wordpress/i18n": "file:../i18n", "@wordpress/preferences": "file:../preferences", "@wordpress/private-apis": "file:../private-apis", diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 231c3cb4e219a9..41c20b91037474 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -6,8 +6,10 @@ import { v4 as uuidv4 } from 'uuid'; /** * WordPress dependencies */ +import apiFetch from '@wordpress/api-fetch'; import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; import type { createRegistry } from '@wordpress/data'; +import { applyFilters } from '@wordpress/hooks'; type WPDataRegistry = ReturnType< typeof createRegistry >; @@ -74,6 +76,7 @@ type ActionCreators = { rotateItem: typeof rotateItem; transcodeImageItem: typeof transcodeImageItem; generateThumbnails: typeof generateThumbnails; + finalizeItem: typeof finalizeItem; updateItemProgress: typeof updateItemProgress; revokeBlobUrls: typeof revokeBlobUrls; < T = Record< string, unknown > >( args: T ): void; @@ -380,6 +383,15 @@ export function processItem( id: QueueItemId ) { return; } + // If parent has pending operations (like Finalize), trigger them. + if ( + parentItem.operations && + parentItem.operations.length > 0 + ) { + dispatch.processItem( parentId ); + return; + } + if ( attachment ) { parentItem.onSuccess?.( [ attachment ] ); } @@ -403,6 +415,14 @@ export function processItem( id: QueueItemId ) { return; } + // For Finalize, wait until all child sideloads are complete. + if ( + operation === OperationType.Finalize && + select.hasPendingItemsByParentId( id ) + ) { + return; + } + dispatch< OperationStartAction >( { type: Type.OperationStart, id, @@ -446,6 +466,10 @@ export function processItem( id: QueueItemId ) { case OperationType.ThumbnailGeneration: dispatch.generateThumbnails( id ); break; + + case OperationType.Finalize: + dispatch.finalizeItem( id ); + break; } }; } @@ -735,7 +759,8 @@ export function prepareItem( id: QueueItemId ) { operations.push( OperationType.Upload, - OperationType.ThumbnailGeneration + OperationType.ThumbnailGeneration, + OperationType.Finalize ); } else { operations.push( OperationType.Upload ); @@ -864,6 +889,22 @@ export function resizeCropItem( id: QueueItemId, args?: ResizeCropItemArgs ) { // Add '-scaled' suffix for big image threshold resizing. const scaledSuffix = Boolean( args.isThresholdResize ); + /** + * Filters the image quality setting for resize/crop operations. + * + * Allows plugins to control the quality (0-1) used when resizing images. + * Note: Quality is not yet wired through to the vips worker but will + * be in a future update. This hook is provided as an extension point. + * + * @param {number} quality Default quality (0-1). + * @param {Object} context Context object containing item, mimeType, and resize args. + */ + applyFilters( 'editor.media.imageQuality', DEFAULT_OUTPUT_QUALITY, { + item, + mimeType: item.file.type, + resize: args?.resize, + } ); + try { const file = await vipsResizeImage( item.id, @@ -1108,6 +1149,33 @@ export function generateThumbnails( id: QueueItemId ) { attachment.missing_image_sizes && attachment.missing_image_sizes.length > 0 ) { + const settings = select.getSettings(); + const allImageSizes = settings.allImageSizes || {}; + + /** + * Filters the list of image sizes to generate for an uploaded image. + * + * @param {string[]} sizesToGenerate Array of image size names to generate. + * @param {Object} context Context object containing item, attachment, and allImageSizes. + */ + const filteredSizes = applyFilters( + 'editor.media.imageSizesToGenerate', + attachment.missing_image_sizes, + { item, attachment, allImageSizes } + ); + const sizesToGenerate: string[] = + Array.isArray( filteredSizes ) && + filteredSizes.every( + ( size: unknown ) => typeof size === 'string' + ) + ? ( filteredSizes as string[] ) + : ( attachment.missing_image_sizes as string[] ); + + if ( sizesToGenerate.length === 0 ) { + dispatch.finishOperation( id, {} ); + return; + } + // Use sourceFile for thumbnail generation to preserve quality. // WordPress core generates thumbnails from the original (unscaled) image. // Vips will auto-rotate based on EXIF orientation during thumbnail generation. @@ -1116,8 +1184,6 @@ export function generateThumbnails( id: QueueItemId ) { : item.sourceFile; const batchId = uuidv4(); - const settings = select.getSettings(); - const allImageSizes = settings.allImageSizes || {}; const { imageOutputFormats } = settings; // Check if thumbnails should be transcoded to a different format. @@ -1141,7 +1207,7 @@ export function generateThumbnails( id: QueueItemId ) { ); } - for ( const name of attachment.missing_image_sizes ) { + for ( const name of sizesToGenerate ) { const imageSize = allImageSizes[ name ]; if ( ! imageSize ) { // eslint-disable-next-line no-console @@ -1244,6 +1310,42 @@ export function generateThumbnails( id: QueueItemId ) { }; } +/** + * Finalizes an uploaded item by calling the server's finalize endpoint. + * + * This triggers the wp_generate_attachment_metadata filter so that PHP + * plugins can process the attachment after all client-side operations + * (including thumbnail sideloads) are complete. + * + * @param id Item ID. + */ +export function finalizeItem( id: QueueItemId ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ); + if ( ! item ) { + return; + } + + const attachment = item.attachment; + + // Only finalize if we have an attachment ID. + if ( attachment?.id ) { + try { + await apiFetch( { + path: `/wp/v2/media/${ attachment.id }/finalize`, + method: 'POST', + } ); + } catch ( error ) { + // Log but don't fail the upload if finalization fails. + // eslint-disable-next-line no-console + console.warn( 'Media finalization failed:', error ); + } + } + + dispatch.finishOperation( id, {} ); + }; +} + /** * Revokes all blob URLs for a given item, freeing up memory. * diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index 579ad27d5c7e9e..a22f5a967e1993 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -232,6 +232,7 @@ export enum OperationType { Rotate = 'ROTATE', TranscodeImage = 'TRANSCODE_IMAGE', ThumbnailGeneration = 'THUMBNAIL_GENERATION', + Finalize = 'FINALIZE', } /** From 2f0323a19b2268c4e8ba0478e53a7003b35f4766 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 26 Feb 2026 12:14:10 +0700 Subject: [PATCH 02/15] remove preload change --- lib/media/load.php | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/lib/media/load.php b/lib/media/load.php index 9bd3b964845192..6d2cf05561d821 100644 --- a/lib/media/load.php +++ b/lib/media/load.php @@ -381,29 +381,3 @@ static function (): void { } add_action( 'wp_enqueue_media', 'gutenberg_override_media_templates' ); - -/** - * Filters the block editor preload paths to include media processing fields. - * - * Adds image_sizes and image_size_threshold to the root endpoint preload - * to avoid an extra API request when these fields are needed. - * - * @param array $paths REST API paths to preload. - * @return array Filtered preload paths. - */ -function gutenberg_media_processing_preload_paths( $paths ) { - foreach ( $paths as $key => $path ) { - if ( is_string( $path ) && str_starts_with( $path, '/?_fields=' ) ) { - // Add image_sizes and image_size_threshold after "home," to match - // the field order in packages/core-data/src/entities.js. - $paths[ $key ] = str_replace( - ',home,', - ',home,image_sizes,image_size_threshold,', - $path - ); - break; - } - } - return $paths; -} -add_filter( 'block_editor_rest_api_preload_paths', 'gutenberg_media_processing_preload_paths', 10 ); From caec35c98eebb3ed6ab1be44998fdced3eee05f9 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 26 Feb 2026 12:17:01 +0700 Subject: [PATCH 03/15] update docblock --- packages/upload-media/src/store/private-actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 41c20b91037474..9e39a970838023 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -890,7 +890,7 @@ export function resizeCropItem( id: QueueItemId, args?: ResizeCropItemArgs ) { const scaledSuffix = Boolean( args.isThresholdResize ); /** - * Filters the image quality setting for resize/crop operations. + * Filters the image quality setting for client side media processing. * * Allows plugins to control the quality (0-1) used when resizing images. * Note: Quality is not yet wired through to the vips worker but will From 76b6327cdb197689b391cd1e9ac936eb09c22e03 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 26 Feb 2026 12:36:37 +0700 Subject: [PATCH 04/15] Remove client-side imageSizesToGenerate filter Image sizes are already filterable server-side via intermediate_image_sizes_advanced and related hooks. --- .../upload-media/src/store/private-actions.ts | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 9e39a970838023..82551411404fec 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -1151,25 +1151,8 @@ export function generateThumbnails( id: QueueItemId ) { ) { const settings = select.getSettings(); const allImageSizes = settings.allImageSizes || {}; - - /** - * Filters the list of image sizes to generate for an uploaded image. - * - * @param {string[]} sizesToGenerate Array of image size names to generate. - * @param {Object} context Context object containing item, attachment, and allImageSizes. - */ - const filteredSizes = applyFilters( - 'editor.media.imageSizesToGenerate', - attachment.missing_image_sizes, - { item, attachment, allImageSizes } - ); const sizesToGenerate: string[] = - Array.isArray( filteredSizes ) && - filteredSizes.every( - ( size: unknown ) => typeof size === 'string' - ) - ? ( filteredSizes as string[] ) - : ( attachment.missing_image_sizes as string[] ); + attachment.missing_image_sizes as string[]; if ( sizesToGenerate.length === 0 ) { dispatch.finishOperation( id, {} ); From 4be9e06ff235fa2930ba8ab7cd96490156963482 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 26 Feb 2026 18:40:44 +0700 Subject: [PATCH 05/15] Add tests for editor.media.imageQuality filter Verifies the filter is called with the default quality value and correct context during resizeCropItem, and that it is skipped when no resize args are provided. --- .../upload-media/src/store/test/actions.ts | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index b0bced03e8b1d5..dc4ead548a7524 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -2,6 +2,7 @@ * WordPress dependencies */ import { createRegistry } from '@wordpress/data'; +import { applyFilters } from '@wordpress/hooks'; type WPDataRegistry = ReturnType< typeof createRegistry >; @@ -21,10 +22,21 @@ jest.mock( '@wordpress/blob', () => ( { jest.mock( '../utils', () => ( { vipsCancelOperations: jest.fn( () => Promise.resolve( true ) ), - vipsResizeImage: jest.fn(), + vipsResizeImage: jest.fn( () => + Promise.resolve( + new File( [ 'resized' ], 'example-100x100.jpg', { + type: 'image/jpeg', + } ) + ) + ), terminateVipsWorker: jest.fn(), } ) ); +jest.mock( '@wordpress/hooks', () => ( { + __esModule: true, + applyFilters: jest.fn( ( hookName: string, value: unknown ) => value ), +} ) ); + // Import the mocked module to access the mock function. import { vipsCancelOperations } from '../utils'; @@ -411,4 +423,62 @@ describe( 'actions', () => { expect( onError ).not.toHaveBeenCalled(); } ); } ); + + describe( 'resizeCropItem', () => { + beforeEach( () => { + ( applyFilters as jest.Mock ).mockClear(); + } ); + + it( 'applies the editor.media.imageQuality filter with correct arguments', async () => { + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + } ); + + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + const resizeArgs = { + resize: { width: 100, height: 100 }, + }; + + await unlock( registry.dispatch( uploadStore ) ).resizeCropItem( + item.id, + resizeArgs + ); + + expect( applyFilters ).toHaveBeenCalledWith( + 'editor.media.imageQuality', + 0.82, + expect.objectContaining( { + item: expect.objectContaining( { + id: item.id, + file: jpegFile, + } ), + mimeType: 'image/jpeg', + resize: resizeArgs.resize, + } ) + ); + } ); + + it( 'does not apply the filter when no resize args are provided', async () => { + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + } ); + + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + await unlock( registry.dispatch( uploadStore ) ).resizeCropItem( + item.id + ); + + expect( applyFilters ).not.toHaveBeenCalledWith( + 'editor.media.imageQuality', + expect.anything(), + expect.anything() + ); + } ); + } ); } ); From 790b7b24959589cdc34cadc0848251eee8a85d75 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 26 Feb 2026 19:25:57 +0700 Subject: [PATCH 06/15] Add hooks reference to upload-media tsconfig The test file imports @wordpress/hooks, so the tsconfig needs a project reference to satisfy lint:tsconfig. --- packages/upload-media/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/upload-media/tsconfig.json b/packages/upload-media/tsconfig.json index 20ebdb1f6791a0..5dc92719c80180 100644 --- a/packages/upload-media/tsconfig.json +++ b/packages/upload-media/tsconfig.json @@ -10,6 +10,7 @@ { "path": "../compose" }, { "path": "../data" }, { "path": "../element" }, + { "path": "../hooks" }, { "path": "../i18n" }, { "path": "../preferences" }, { "path": "../private-apis" }, From 1ab2a1312a7270d5a4aa933fcd7fcac1e1e4e063 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 10 Mar 2026 11:57:47 -0700 Subject: [PATCH 07/15] Add backport changelog for core PR #11168 Links the finalize endpoint core backport PR to Gutenberg PR #74913 for client-side media processing. --- backport-changelog/7.0/11168.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 backport-changelog/7.0/11168.md diff --git a/backport-changelog/7.0/11168.md b/backport-changelog/7.0/11168.md new file mode 100644 index 00000000000000..186f016ba2bf8e --- /dev/null +++ b/backport-changelog/7.0/11168.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/11168 + +* https://github.com/WordPress/gutenberg/pull/74913 From 6c906f0d83e71c7230b7114bd25b12ed31389993 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 11 Mar 2026 14:14:47 -0700 Subject: [PATCH 08/15] Replace imageQuality JS filter with store setting The applyFilters hook runs per-upload and is hard to remove once added. Use the existing updateSettings/getSettings store pattern instead, which is a one-time operation and consistent with how other image settings are managed. --- package-lock.json | 1 - packages/upload-media/package.json | 1 - .../upload-media/src/store/private-actions.ts | 18 ----- .../upload-media/src/store/test/actions.ts | 66 ++++++++++--------- packages/upload-media/src/store/types.ts | 3 + packages/upload-media/tsconfig.json | 1 - 6 files changed, 37 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index e23c3bcd988a8d..4111c2f52539ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62733,7 +62733,6 @@ "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", "@wordpress/element": "file:../element", - "@wordpress/hooks": "file:../hooks", "@wordpress/i18n": "file:../i18n", "@wordpress/preferences": "file:../preferences", "@wordpress/private-apis": "file:../private-apis", diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json index 4f32267d4f8e9a..37b3fdeab8bd76 100644 --- a/packages/upload-media/package.json +++ b/packages/upload-media/package.json @@ -54,7 +54,6 @@ "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", "@wordpress/element": "file:../element", - "@wordpress/hooks": "file:../hooks", "@wordpress/i18n": "file:../i18n", "@wordpress/preferences": "file:../preferences", "@wordpress/private-apis": "file:../private-apis", diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 79c7742c4edf35..4a5041ae64e974 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -9,8 +9,6 @@ import { v4 as uuidv4 } from 'uuid'; import apiFetch from '@wordpress/api-fetch'; import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; import type { createRegistry } from '@wordpress/data'; -import { applyFilters } from '@wordpress/hooks'; - type WPDataRegistry = ReturnType< typeof createRegistry >; /** @@ -889,22 +887,6 @@ export function resizeCropItem( id: QueueItemId, args?: ResizeCropItemArgs ) { // Add '-scaled' suffix for big image threshold resizing. const scaledSuffix = Boolean( args.isThresholdResize ); - /** - * Filters the image quality setting for client side media processing. - * - * Allows plugins to control the quality (0-1) used when resizing images. - * Note: Quality is not yet wired through to the vips worker but will - * be in a future update. This hook is provided as an extension point. - * - * @param {number} quality Default quality (0-1). - * @param {Object} context Context object containing item, mimeType, and resize args. - */ - applyFilters( 'editor.media.imageQuality', DEFAULT_OUTPUT_QUALITY, { - item, - mimeType: item.file.type, - resize: args?.resize, - } ); - try { const file = await vipsResizeImage( item.id, diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index d50501c7b960eb..ab58b1f20704d2 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -2,8 +2,6 @@ * WordPress dependencies */ import { createRegistry } from '@wordpress/data'; -import { applyFilters } from '@wordpress/hooks'; - type WPDataRegistry = ReturnType< typeof createRegistry >; /** @@ -35,11 +33,6 @@ jest.mock( '../utils', () => ( { terminateVipsWorker: jest.fn(), } ) ); -jest.mock( '@wordpress/hooks', () => ( { - __esModule: true, - applyFilters: jest.fn( ( hookName: string, value: unknown ) => value ), -} ) ); - // Import the mocked module to access the mock function. import { vipsCancelOperations } from '../utils'; @@ -428,11 +421,11 @@ describe( 'actions', () => { } ); describe( 'resizeCropItem', () => { - beforeEach( () => { - ( applyFilters as jest.Mock ).mockClear(); - } ); + it( 'uses imageQuality from store settings when set', async () => { + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + imageQuality: 0.5, + } ); - it( 'applies the editor.media.imageQuality filter with correct arguments', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); @@ -441,30 +434,39 @@ describe( 'actions', () => { registry.select( uploadStore ) ).getAllItems()[ 0 ]; - const resizeArgs = { - resize: { width: 100, height: 100 }, - }; + const { vipsResizeImage } = require( '../utils' ); + ( vipsResizeImage as jest.Mock ).mockClear(); await unlock( registry.dispatch( uploadStore ) ).resizeCropItem( item.id, - resizeArgs + { resize: { width: 100, height: 100 } } ); - expect( applyFilters ).toHaveBeenCalledWith( - 'editor.media.imageQuality', - 0.82, - expect.objectContaining( { - item: expect.objectContaining( { - id: item.id, - file: jpegFile, - } ), - mimeType: 'image/jpeg', - resize: resizeArgs.resize, - } ) + // Verify the resize was called (quality will be wired through in a future update). + expect( vipsResizeImage ).toHaveBeenCalled(); + } ); + + it( 'falls back to default quality when imageQuality is not set', async () => { + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + } ); + + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + const { vipsResizeImage } = require( '../utils' ); + ( vipsResizeImage as jest.Mock ).mockClear(); + + await unlock( registry.dispatch( uploadStore ) ).resizeCropItem( + item.id, + { resize: { width: 100, height: 100 } } ); + + expect( vipsResizeImage ).toHaveBeenCalled(); } ); - it( 'does not apply the filter when no resize args are provided', async () => { + it( 'skips resize when no resize args are provided', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); @@ -477,11 +479,11 @@ describe( 'actions', () => { item.id ); - expect( applyFilters ).not.toHaveBeenCalledWith( - 'editor.media.imageQuality', - expect.anything(), - expect.anything() - ); + // Item should finish without resize. + const updatedItem = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + expect( updatedItem.file ).toBe( jpegFile ); } ); } ); diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index a22f5a967e1993..e51f8d80fbf9f6 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -178,6 +178,9 @@ export interface Settings { pngInterlaced?: boolean; // Whether to use interlaced encoding for GIF. gifInterlaced?: boolean; + // Default image quality (0-1) for resize/crop operations. + // Default is 0.82 if not set. + imageQuality?: number; } // Matches the Attachment type from the media-utils package. diff --git a/packages/upload-media/tsconfig.json b/packages/upload-media/tsconfig.json index 5dc92719c80180..20ebdb1f6791a0 100644 --- a/packages/upload-media/tsconfig.json +++ b/packages/upload-media/tsconfig.json @@ -10,7 +10,6 @@ { "path": "../compose" }, { "path": "../data" }, { "path": "../element" }, - { "path": "../hooks" }, { "path": "../i18n" }, { "path": "../preferences" }, { "path": "../private-apis" }, From 0e4e94bd966ca5ad46285957346d7796dc1e71b8 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 11 Mar 2026 14:25:25 -0700 Subject: [PATCH 09/15] Move finalizeItem API call out of upload-media The upload-media package is implementation-agnostic and should not make direct REST API calls. This moves the apiFetch call to a new mediaFinalize utility in the editor package and injects it via the existing settings pattern, matching how mediaUpload and mediaSideload already work. --- .../components/provider/use-media-upload-settings.js | 1 + .../components/provider/use-block-editor-settings.js | 2 ++ packages/editor/src/utils/media-finalize/index.js | 11 +++++++++++ packages/upload-media/package.json | 1 - packages/upload-media/src/store/private-actions.ts | 11 ++++------- packages/upload-media/src/store/types.ts | 2 ++ 6 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 packages/editor/src/utils/media-finalize/index.js diff --git a/packages/block-editor/src/components/provider/use-media-upload-settings.js b/packages/block-editor/src/components/provider/use-media-upload-settings.js index 7c00c145d27a72..49ca4ab9671e1e 100644 --- a/packages/block-editor/src/components/provider/use-media-upload-settings.js +++ b/packages/block-editor/src/components/provider/use-media-upload-settings.js @@ -15,6 +15,7 @@ function useMediaUploadSettings( settings = {} ) { () => ( { mediaUpload: settings.mediaUpload, mediaSideload: settings.mediaSideload, + finalizeUpload: settings.finalizeUpload, maxUploadFileSize: settings.maxUploadFileSize, allowedMimeTypes: settings.allowedMimeTypes, allImageSizes: settings.allImageSizes, diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index c17eb4ee4cc100..1e9b95c98ffa07 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -24,6 +24,7 @@ import { import inserterMediaCategories from '../media-categories'; import { mediaUpload } from '../../utils'; import { default as mediaSideload } from '../../utils/media-sideload'; +import { default as mediaFinalize } from '../../utils/media-finalize'; import { store as editorStore } from '../../store'; import { unlock } from '../../lock-unlock'; import { useGlobalStylesContext } from '../global-styles-provider'; @@ -337,6 +338,7 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) { : undefined, mediaUpload: hasUploadPermissions ? mediaUpload : undefined, mediaSideload: hasUploadPermissions ? mediaSideload : undefined, + finalizeUpload: hasUploadPermissions ? mediaFinalize : undefined, __experimentalBlockPatterns: blockPatterns, [ selectBlockPatternsKey ]: ( select ) => { const { hasFinishedResolution, getBlockPatternsForPostType } = diff --git a/packages/editor/src/utils/media-finalize/index.js b/packages/editor/src/utils/media-finalize/index.js new file mode 100644 index 00000000000000..d7459f1e8512be --- /dev/null +++ b/packages/editor/src/utils/media-finalize/index.js @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +export default async function mediaFinalize( id ) { + await apiFetch( { + path: `/wp/v2/media/${ id }/finalize`, + method: 'POST', + } ); +} diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json index 37b3fdeab8bd76..0d967ab5472bc9 100644 --- a/packages/upload-media/package.json +++ b/packages/upload-media/package.json @@ -49,7 +49,6 @@ "build-module/store/index.mjs" ], "dependencies": { - "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 4a5041ae64e974..1f8d0458b95f76 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -6,7 +6,6 @@ import { v4 as uuidv4 } from 'uuid'; /** * WordPress dependencies */ -import apiFetch from '@wordpress/api-fetch'; import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; import type { createRegistry } from '@wordpress/data'; type WPDataRegistry = ReturnType< typeof createRegistry >; @@ -1303,14 +1302,12 @@ export function finalizeItem( id: QueueItemId ) { } const attachment = item.attachment; + const { finalizeUpload } = select.getSettings(); - // Only finalize if we have an attachment ID. - if ( attachment?.id ) { + // Only finalize if we have an attachment ID and a finalizeUpload callback. + if ( attachment?.id && finalizeUpload ) { try { - await apiFetch( { - path: `/wp/v2/media/${ attachment.id }/finalize`, - method: 'POST', - } ); + await finalizeUpload( attachment.id ); } catch ( error ) { // Log but don't fail the upload if finalization fails. // eslint-disable-next-line no-console diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index e51f8d80fbf9f6..824c840fb44740 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -181,6 +181,8 @@ export interface Settings { // Default image quality (0-1) for resize/crop operations. // Default is 0.82 if not set. imageQuality?: number; + // Function for finalizing an upload after all client-side processing is complete. + finalizeUpload?: ( id: number ) => Promise< void >; } // Matches the Attachment type from the media-utils package. From 88c9e42b7b9f5eb9f7c6a88f36bb052dbd1037de Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 11 Mar 2026 14:26:46 -0700 Subject: [PATCH 10/15] Add tests for finalizeItem and mediaFinalize Verify the injected callback pattern works correctly: finalizeItem calls the setting, handles missing callbacks, and recovers from errors. mediaFinalize hits the correct REST endpoint. --- .../src/utils/media-finalize/test/index.js | 34 +++++++ .../src/store/test/private-actions.js | 97 ++++++++++++++++++- 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 packages/editor/src/utils/media-finalize/test/index.js diff --git a/packages/editor/src/utils/media-finalize/test/index.js b/packages/editor/src/utils/media-finalize/test/index.js new file mode 100644 index 00000000000000..eeba1c0a9893f3 --- /dev/null +++ b/packages/editor/src/utils/media-finalize/test/index.js @@ -0,0 +1,34 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import mediaFinalize from '..'; + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +describe( 'mediaFinalize', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should call the finalize endpoint with the correct path and method', async () => { + apiFetch.mockResolvedValue( {} ); + + await mediaFinalize( 123 ); + + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/media/123/finalize', + method: 'POST', + } ); + } ); + + it( 'should propagate errors from apiFetch', async () => { + apiFetch.mockRejectedValue( new Error( 'Network error' ) ); + + await expect( mediaFinalize( 456 ) ).rejects.toThrow( 'Network error' ); + } ); +} ); diff --git a/packages/upload-media/src/store/test/private-actions.js b/packages/upload-media/src/store/test/private-actions.js index 3ddeff196ddf5b..d1bdf675462609 100644 --- a/packages/upload-media/src/store/test/private-actions.js +++ b/packages/upload-media/src/store/test/private-actions.js @@ -6,7 +6,7 @@ import { createBlobURL, revokeBlobURL } from '@wordpress/blob'; /** * Internal dependencies */ -import { getTranscodeImageOperation } from '../private-actions'; +import { getTranscodeImageOperation, finalizeItem } from '../private-actions'; import { OperationType } from '../types'; import { vipsHasTransparency } from '../utils'; @@ -251,4 +251,99 @@ describe( 'private actions', () => { expect( result ).toBeNull(); } ); } ); + + describe( 'finalizeItem', () => { + it( 'should call finalizeUpload with the attachment ID', async () => { + const finalizeUpload = jest.fn().mockResolvedValue( undefined ); + const finishOperation = jest.fn(); + const select = { + getItem: () => ( { + attachment: { id: 42 }, + } ), + getSettings: () => ( { finalizeUpload } ), + }; + const dispatch = { finishOperation }; + + const thunk = finalizeItem( 'test-id' ); + await thunk( { select, dispatch } ); + + expect( finalizeUpload ).toHaveBeenCalledWith( 42 ); + expect( finishOperation ).toHaveBeenCalledWith( 'test-id', {} ); + } ); + + it( 'should not call finalizeUpload when no callback is provided', async () => { + const finishOperation = jest.fn(); + const select = { + getItem: () => ( { + attachment: { id: 42 }, + } ), + getSettings: () => ( {} ), + }; + const dispatch = { finishOperation }; + + const thunk = finalizeItem( 'test-id' ); + await thunk( { select, dispatch } ); + + expect( finishOperation ).toHaveBeenCalledWith( 'test-id', {} ); + } ); + + it( 'should not call finalizeUpload when there is no attachment ID', async () => { + const finalizeUpload = jest.fn(); + const finishOperation = jest.fn(); + const select = { + getItem: () => ( { + attachment: {}, + } ), + getSettings: () => ( { finalizeUpload } ), + }; + const dispatch = { finishOperation }; + + const thunk = finalizeItem( 'test-id' ); + await thunk( { select, dispatch } ); + + expect( finalizeUpload ).not.toHaveBeenCalled(); + expect( finishOperation ).toHaveBeenCalledWith( 'test-id', {} ); + } ); + + it( 'should handle finalizeUpload errors gracefully', async () => { + const finalizeUpload = jest + .fn() + .mockRejectedValue( new Error( 'Network error' ) ); + const finishOperation = jest.fn(); + const warnSpy = jest + .spyOn( console, 'warn' ) + .mockImplementation( () => {} ); + const select = { + getItem: () => ( { + attachment: { id: 42 }, + } ), + getSettings: () => ( { finalizeUpload } ), + }; + const dispatch = { finishOperation }; + + const thunk = finalizeItem( 'test-id' ); + await thunk( { select, dispatch } ); + + expect( finalizeUpload ).toHaveBeenCalledWith( 42 ); + expect( warnSpy ).toHaveBeenCalledWith( + 'Media finalization failed:', + expect.any( Error ) + ); + expect( finishOperation ).toHaveBeenCalledWith( 'test-id', {} ); + warnSpy.mockRestore(); + } ); + + it( 'should return early when item is not found', async () => { + const finishOperation = jest.fn(); + const select = { + getItem: () => undefined, + }; + const dispatch = { finishOperation }; + + const thunk = finalizeItem( 'test-id' ); + await thunk( { select, dispatch } ); + + expect( finishOperation ).not.toHaveBeenCalled(); + } ); + } ); } ); From ef9587b418a456a1fc4c380f9f9b388bf684c7af Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 11 Mar 2026 14:43:19 -0700 Subject: [PATCH 11/15] Update package-lock.json after removing api-fetch dep --- package-lock.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 4111c2f52539ab..22c2d70c3d9bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62728,7 +62728,6 @@ "version": "0.26.0", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", From adf0fb85263a7031cdc4e8104af93451e32fe116 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Fri, 13 Mar 2026 10:56:12 -0700 Subject: [PATCH 12/15] Update lib/media/class-gutenberg-rest-attachments-controller.php Co-authored-by: Weston Ruter --- lib/media/class-gutenberg-rest-attachments-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/media/class-gutenberg-rest-attachments-controller.php b/lib/media/class-gutenberg-rest-attachments-controller.php index f72c3064938561..bf4efe3f0a8bf2 100644 --- a/lib/media/class-gutenberg-rest-attachments-controller.php +++ b/lib/media/class-gutenberg-rest-attachments-controller.php @@ -66,7 +66,7 @@ public function register_routes(): void { array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'finalize_item' ), - 'permission_callback' => array( $this, 'finalize_item_permissions_check' ), + 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ), 'args' => array( 'id' => array( 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ), From 3cf77274c5c78933fb7d145ebaf17dde14396654 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Fri, 13 Mar 2026 10:56:34 -0700 Subject: [PATCH 13/15] Update lib/media/class-gutenberg-rest-attachments-controller.php Co-authored-by: Weston Ruter --- .../class-gutenberg-rest-attachments-controller.php | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lib/media/class-gutenberg-rest-attachments-controller.php b/lib/media/class-gutenberg-rest-attachments-controller.php index bf4efe3f0a8bf2..18fc3fb0ed8865 100644 --- a/lib/media/class-gutenberg-rest-attachments-controller.php +++ b/lib/media/class-gutenberg-rest-attachments-controller.php @@ -252,17 +252,6 @@ public function create_item( $request ) { return $response; } - - /** - * Checks if a given request has access to finalize an attachment. - * - * @param WP_REST_Request $request Full details about the request. - * @return true|WP_Error True if the request has access, WP_Error object otherwise. - */ - public function finalize_item_permissions_check( $request ) { - return $this->edit_media_item_permissions_check( $request ); - } - /** * Finalizes an attachment after client-side media processing. * From 07edb0f0b50d5485f9678e22c94b1673983a0909 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 16 Mar 2026 11:18:17 -0700 Subject: [PATCH 14/15] Remove unreachable sizesToGenerate check The outer condition already guarantees missing_image_sizes.length > 0, making the sizesToGenerate.length === 0 block dead code. --- packages/upload-media/src/store/private-actions.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 1f8d0458b95f76..58cc5454996c1e 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -1135,11 +1135,6 @@ export function generateThumbnails( id: QueueItemId ) { const sizesToGenerate: string[] = attachment.missing_image_sizes as string[]; - if ( sizesToGenerate.length === 0 ) { - dispatch.finishOperation( id, {} ); - return; - } - // Use sourceFile for thumbnail generation to preserve quality. // WordPress core generates thumbnails from the original (unscaled) image. // Vips will auto-rotate based on EXIF orientation during thumbnail generation. From 499dd0c5c32d02751a1fecd9104d91fc2650d551 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 16 Mar 2026 11:23:32 -0700 Subject: [PATCH 15/15] Rename finalizeUpload to mediaFinalize Aligns with the existing mediaUpload and mediaSideload naming convention used in block editor settings. --- .../provider/use-media-upload-settings.js | 2 +- .../provider/use-block-editor-settings.js | 2 +- .../upload-media/src/store/private-actions.ts | 8 +++--- .../src/store/test/private-actions.js | 26 +++++++++---------- packages/upload-media/src/store/types.ts | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/block-editor/src/components/provider/use-media-upload-settings.js b/packages/block-editor/src/components/provider/use-media-upload-settings.js index 49ca4ab9671e1e..b92ed33b7e0cea 100644 --- a/packages/block-editor/src/components/provider/use-media-upload-settings.js +++ b/packages/block-editor/src/components/provider/use-media-upload-settings.js @@ -15,7 +15,7 @@ function useMediaUploadSettings( settings = {} ) { () => ( { mediaUpload: settings.mediaUpload, mediaSideload: settings.mediaSideload, - finalizeUpload: settings.finalizeUpload, + mediaFinalize: settings.mediaFinalize, maxUploadFileSize: settings.maxUploadFileSize, allowedMimeTypes: settings.allowedMimeTypes, allImageSizes: settings.allImageSizes, diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 1e9b95c98ffa07..940ef455a8f737 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -338,7 +338,7 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) { : undefined, mediaUpload: hasUploadPermissions ? mediaUpload : undefined, mediaSideload: hasUploadPermissions ? mediaSideload : undefined, - finalizeUpload: hasUploadPermissions ? mediaFinalize : undefined, + mediaFinalize: hasUploadPermissions ? mediaFinalize : undefined, __experimentalBlockPatterns: blockPatterns, [ selectBlockPatternsKey ]: ( select ) => { const { hasFinishedResolution, getBlockPatternsForPostType } = diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 58cc5454996c1e..9d3aa2b91a6956 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -1297,12 +1297,12 @@ export function finalizeItem( id: QueueItemId ) { } const attachment = item.attachment; - const { finalizeUpload } = select.getSettings(); + const { mediaFinalize } = select.getSettings(); - // Only finalize if we have an attachment ID and a finalizeUpload callback. - if ( attachment?.id && finalizeUpload ) { + // Only finalize if we have an attachment ID and a mediaFinalize callback. + if ( attachment?.id && mediaFinalize ) { try { - await finalizeUpload( attachment.id ); + await mediaFinalize( attachment.id ); } catch ( error ) { // Log but don't fail the upload if finalization fails. // eslint-disable-next-line no-console diff --git a/packages/upload-media/src/store/test/private-actions.js b/packages/upload-media/src/store/test/private-actions.js index d1bdf675462609..5da1dace106aea 100644 --- a/packages/upload-media/src/store/test/private-actions.js +++ b/packages/upload-media/src/store/test/private-actions.js @@ -253,25 +253,25 @@ describe( 'private actions', () => { } ); describe( 'finalizeItem', () => { - it( 'should call finalizeUpload with the attachment ID', async () => { - const finalizeUpload = jest.fn().mockResolvedValue( undefined ); + it( 'should call mediaFinalize with the attachment ID', async () => { + const mediaFinalize = jest.fn().mockResolvedValue( undefined ); const finishOperation = jest.fn(); const select = { getItem: () => ( { attachment: { id: 42 }, } ), - getSettings: () => ( { finalizeUpload } ), + getSettings: () => ( { mediaFinalize } ), }; const dispatch = { finishOperation }; const thunk = finalizeItem( 'test-id' ); await thunk( { select, dispatch } ); - expect( finalizeUpload ).toHaveBeenCalledWith( 42 ); + expect( mediaFinalize ).toHaveBeenCalledWith( 42 ); expect( finishOperation ).toHaveBeenCalledWith( 'test-id', {} ); } ); - it( 'should not call finalizeUpload when no callback is provided', async () => { + it( 'should not call mediaFinalize when no callback is provided', async () => { const finishOperation = jest.fn(); const select = { getItem: () => ( { @@ -287,26 +287,26 @@ describe( 'private actions', () => { expect( finishOperation ).toHaveBeenCalledWith( 'test-id', {} ); } ); - it( 'should not call finalizeUpload when there is no attachment ID', async () => { - const finalizeUpload = jest.fn(); + it( 'should not call mediaFinalize when there is no attachment ID', async () => { + const mediaFinalize = jest.fn(); const finishOperation = jest.fn(); const select = { getItem: () => ( { attachment: {}, } ), - getSettings: () => ( { finalizeUpload } ), + getSettings: () => ( { mediaFinalize } ), }; const dispatch = { finishOperation }; const thunk = finalizeItem( 'test-id' ); await thunk( { select, dispatch } ); - expect( finalizeUpload ).not.toHaveBeenCalled(); + expect( mediaFinalize ).not.toHaveBeenCalled(); expect( finishOperation ).toHaveBeenCalledWith( 'test-id', {} ); } ); - it( 'should handle finalizeUpload errors gracefully', async () => { - const finalizeUpload = jest + it( 'should handle mediaFinalize errors gracefully', async () => { + const mediaFinalize = jest .fn() .mockRejectedValue( new Error( 'Network error' ) ); const finishOperation = jest.fn(); @@ -317,14 +317,14 @@ describe( 'private actions', () => { getItem: () => ( { attachment: { id: 42 }, } ), - getSettings: () => ( { finalizeUpload } ), + getSettings: () => ( { mediaFinalize } ), }; const dispatch = { finishOperation }; const thunk = finalizeItem( 'test-id' ); await thunk( { select, dispatch } ); - expect( finalizeUpload ).toHaveBeenCalledWith( 42 ); + expect( mediaFinalize ).toHaveBeenCalledWith( 42 ); expect( warnSpy ).toHaveBeenCalledWith( 'Media finalization failed:', expect.any( Error ) diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index 824c840fb44740..da9c1d11661f8c 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -182,7 +182,7 @@ export interface Settings { // Default is 0.82 if not set. imageQuality?: number; // Function for finalizing an upload after all client-side processing is complete. - finalizeUpload?: ( id: number ) => Promise< void >; + mediaFinalize?: ( id: number ) => Promise< void >; } // Matches the Attachment type from the media-utils package.