From 761043e959257d2dd5de58413133ec5d89e250f4 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 3 Apr 2026 09:35:49 -0700 Subject: [PATCH 1/6] Deduplicate client-side image sizes with matching dimensions Group thumbnail size names by their effective dimensions (width, height, crop) before generating sideload items. When multiple registered sizes share the same dimensions, only one physical file is generated and the server registers it under all matching size names in attachment metadata. Extends the sideload endpoint's image_size parameter to accept an array of size names, so one upload can be registered for multiple sizes. Fixes #77035 --- ...-gutenberg-rest-attachments-controller.php | 67 +++++++++++-------- .../upload-media/src/store/private-actions.ts | 22 +++++- .../upload-media/src/store/test/actions.ts | 44 ++++++++++++ packages/upload-media/src/store/types.ts | 4 +- 4 files changed, 107 insertions(+), 30 deletions(-) diff --git a/lib/media/class-gutenberg-rest-attachments-controller.php b/lib/media/class-gutenberg-rest-attachments-controller.php index 802c424db3edcf..76a2f134722b65 100644 --- a/lib/media/class-gutenberg-rest-attachments-controller.php +++ b/lib/media/class-gutenberg-rest-attachments-controller.php @@ -46,9 +46,20 @@ public function register_routes(): void { 'type' => 'integer', ), 'image_size' => array( - 'description' => __( 'Image size.', 'gutenberg' ), - 'type' => 'string', - 'enum' => $valid_image_sizes, + 'description' => __( 'Image size. Can be a single size name or an array of size names to register the same file under multiple sizes.', 'gutenberg' ), + 'oneOf' => array( + array( + 'type' => 'string', + 'enum' => $valid_image_sizes, + ), + array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => $valid_image_sizes, + ), + ), + ), 'required' => true, ), ), @@ -474,7 +485,7 @@ public function sideload_item( WP_REST_Request $request ) { $type = $file['type']; $path = $file['file']; - $image_size = $request['image_size']; + $image_sizes = (array) $request['image_size']; $metadata = wp_get_attachment_metadata( $attachment_id, true ); @@ -482,34 +493,36 @@ public function sideload_item( WP_REST_Request $request ) { $metadata = array(); } - if ( 'original' === $image_size ) { - $metadata['original_image'] = wp_basename( $path ); - } elseif ( 'scaled' === $image_size ) { - // The current attached file is the original; record it as original_image. - $current_file = get_attached_file( $attachment_id, true ); - $metadata['original_image'] = wp_basename( $current_file ); + foreach ( $image_sizes as $image_size ) { + if ( 'original' === $image_size ) { + $metadata['original_image'] = wp_basename( $path ); + } elseif ( 'scaled' === $image_size ) { + // The current attached file is the original; record it as original_image. + $current_file = get_attached_file( $attachment_id, true ); + $metadata['original_image'] = wp_basename( $current_file ); - // Update the attached file to point to the scaled version. - update_attached_file( $attachment_id, $path ); + // Update the attached file to point to the scaled version. + update_attached_file( $attachment_id, $path ); - $size = wp_getimagesize( $path ); + $size = wp_getimagesize( $path ); - $metadata['width'] = $size ? $size[0] : 0; - $metadata['height'] = $size ? $size[1] : 0; - $metadata['filesize'] = wp_filesize( $path ); - $metadata['file'] = _wp_relative_upload_path( $path ); - } else { - $metadata['sizes'] = $metadata['sizes'] ?? array(); + $metadata['width'] = $size ? $size[0] : 0; + $metadata['height'] = $size ? $size[1] : 0; + $metadata['filesize'] = wp_filesize( $path ); + $metadata['file'] = _wp_relative_upload_path( $path ); + } else { + $metadata['sizes'] = $metadata['sizes'] ?? array(); - $size = wp_getimagesize( $path ); + $size = wp_getimagesize( $path ); - $metadata['sizes'][ $image_size ] = array( - 'width' => $size ? $size[0] : 0, - 'height' => $size ? $size[1] : 0, - 'file' => wp_basename( $path ), - 'mime-type' => $type, - 'filesize' => wp_filesize( $path ), - ); + $metadata['sizes'][ $image_size ] = array( + 'width' => $size ? $size[0] : 0, + 'height' => $size ? $size[1] : 0, + 'file' => wp_basename( $path ), + 'mime-type' => $type, + 'filesize' => wp_filesize( $path ), + ); + } } wp_update_attachment_metadata( $attachment_id, $metadata ); diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index b06af25f2d6d43..e63b99ed1c4702 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -1167,6 +1167,11 @@ export function generateThumbnails( id: QueueItemId ) { ); } + // Group sizes by dimensions to avoid creating duplicate files. + // When multiple size names have the same width/height/crop, + // only one physical file is generated and registered under + // all matching size names via a single sideload request. + const dimensionGroups = new Map< string, string[] >(); for ( const name of sizesToGenerate ) { const imageSize = allImageSizes[ name ]; if ( ! imageSize ) { @@ -1176,6 +1181,17 @@ export function generateThumbnails( id: QueueItemId ) { ); continue; } + const key = `${ imageSize.width }x${ imageSize.height }x${ imageSize.crop }`; + const group = dimensionGroups.get( key ); + if ( group ) { + group.push( name ); + } else { + dimensionGroups.set( key, [ name ] ); + } + } + + for ( const [ , names ] of dimensionGroups ) { + const imageSize = allImageSizes[ names[ 0 ] ]; // Build operations list for this thumbnail. const thumbnailOperations: Operation[] = [ @@ -1190,6 +1206,10 @@ export function generateThumbnails( id: QueueItemId ) { thumbnailOperations.push( OperationType.Upload ); + // Pass all size names so the server registers the same + // file under every matching size name in metadata. + const imageSizeParam = names.length === 1 ? names[ 0 ] : names; + dispatch.addSideloadItem( { file, onChange: ( [ updatedAttachment ] ) => { @@ -1210,7 +1230,7 @@ export function generateThumbnails( id: QueueItemId ) { // Sideloading does not use the parent post ID but the // attachment ID as the image sizes need to be added to it. post: attachment.id, - image_size: name, + image_size: imageSizeParam, convert_format: false, }, operations: thumbnailOperations, diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index 20524bf85ef83f..ed54fc8c18a3d1 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -725,6 +725,50 @@ describe( 'actions', () => { expect( mediumItems ).toHaveLength( 1 ); } ); + it( 'should deduplicate sizes with the same dimensions', async () => { + mockCreateImageBitmap( 800, 600 ); + + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + bigImageSizeThreshold: 2560, + allImageSizes: { + thumbnail: { width: 150, height: 150, crop: true }, + medium: { width: 300, height: 300, crop: false }, + // 'custom' has the same dimensions as 'medium'. + custom: { width: 300, height: 300, crop: false }, + }, + } ); + + const item = await setupItemForThumbnailGeneration( { + attachment: { + missing_image_sizes: [ 'thumbnail', 'medium', 'custom' ], + }, + } ); + await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( + item.id + ); + + const allItems = unlock( + registry.select( uploadStore ) + ).getAllItems(); + + // Should have the original item plus 2 sideload items (not 3), + // because medium and custom share the same dimensions. + const sideloadItems = allItems.filter( + ( i ) => i.parentId === item.id + ); + expect( sideloadItems ).toHaveLength( 2 ); + + // The deduplicated group should pass both size names. + const mediumCustomItem = sideloadItems.find( ( i ) => + Array.isArray( i.additionalData?.image_size ) + ); + expect( mediumCustomItem ).toBeDefined(); + expect( mediumCustomItem!.additionalData!.image_size ).toEqual( [ + 'medium', + 'custom', + ] ); + } ); + it( 'should skip thumbnail generation when item has no attachment', async () => { // Add an item without going through the attachment setup. unlock( registry.dispatch( uploadStore ) ).addItem( { diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index da9c1d11661f8c..da8164068215fb 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -303,8 +303,8 @@ export type AdditionalData = Record< string, unknown >; export interface SideloadAdditionalData extends AdditionalData { /** The attachment ID to add the image size to. */ post: number; - /** The name of the image size being generated (e.g., 'thumbnail', 'medium'). */ - image_size: string; + /** The name(s) of the image size being generated (e.g., 'thumbnail', 'medium'). When multiple size names share the same dimensions, an array can be passed to register one file under all names. */ + image_size: string | string[]; } export type ImageFormat = 'jpeg' | 'webp' | 'avif' | 'png' | 'gif'; From 850cf35f26ca642a785f61fad984bdb14ccabc32 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 20 Apr 2026 15:35:55 -0700 Subject: [PATCH 2/6] Add test coverage for image_size array deduplication Extend the sideload REST controller tests to cover the new behaviour of accepting an array of image_size names, including the happy path where one physical file is registered under multiple sizes in attachment metadata, and backwards compatibility when a single-element array is passed. On the JavaScript side, add regression tests ensuring that sizes with matching dimensions but different crop values are treated as distinct groups, and that three or more sizes sharing identical dimensions collapse into a single sideload request. --- .../upload-media/src/store/test/actions.ts | 82 ++++++++++++++ ...nberg-rest-attachments-controller-test.php | 103 ++++++++++++++++++ 2 files changed, 185 insertions(+) diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index ed54fc8c18a3d1..e45e59f1b27e4a 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -769,6 +769,88 @@ describe( 'actions', () => { ] ); } ); + it( 'should not deduplicate sizes that share dimensions but differ by crop', async () => { + mockCreateImageBitmap( 800, 600 ); + + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + bigImageSizeThreshold: 2560, + allImageSizes: { + // Same width/height, different crop — must be treated as distinct. + soft: { width: 300, height: 300, crop: false }, + hard: { width: 300, height: 300, crop: true }, + }, + } ); + + const item = await setupItemForThumbnailGeneration( { + attachment: { + missing_image_sizes: [ 'soft', 'hard' ], + }, + } ); + await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( + item.id + ); + + const allItems = unlock( + registry.select( uploadStore ) + ).getAllItems(); + + const sideloadItems = allItems.filter( + ( i ) => i.parentId === item.id + ); + // Two separate sideloads because crop differs. + expect( sideloadItems ).toHaveLength( 2 ); + + // Each sideload passes a single string (not an array). + for ( const sideload of sideloadItems ) { + expect( + typeof sideload.additionalData?.image_size === 'string' + ).toBe( true ); + } + const imageSizes = sideloadItems.map( + ( i ) => i.additionalData?.image_size + ); + expect( imageSizes ).toEqual( + expect.arrayContaining( [ 'soft', 'hard' ] ) + ); + } ); + + it( 'should group three sizes with identical dimensions into one sideload', async () => { + mockCreateImageBitmap( 800, 600 ); + + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + bigImageSizeThreshold: 2560, + allImageSizes: { + medium: { width: 300, height: 300, crop: false }, + alias_a: { width: 300, height: 300, crop: false }, + alias_b: { width: 300, height: 300, crop: false }, + }, + } ); + + const item = await setupItemForThumbnailGeneration( { + attachment: { + missing_image_sizes: [ 'medium', 'alias_a', 'alias_b' ], + }, + } ); + await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( + item.id + ); + + const allItems = unlock( + registry.select( uploadStore ) + ).getAllItems(); + + const sideloadItems = allItems.filter( + ( i ) => i.parentId === item.id + ); + // One sideload, all three names grouped together. + expect( sideloadItems ).toHaveLength( 1 ); + expect( sideloadItems[ 0 ].additionalData!.image_size ).toEqual( [ + 'medium', + 'alias_a', + 'alias_b', + ] ); + } ); + it( 'should skip thumbnail generation when item has no attachment', async () => { // Add an item without going through the attachment setup. unlock( registry.dispatch( uploadStore ) ).addItem( { diff --git a/phpunit/media/class-gutenberg-rest-attachments-controller-test.php b/phpunit/media/class-gutenberg-rest-attachments-controller-test.php index 0286ffb310cae9..9ec07ffbdf9852 100644 --- a/phpunit/media/class-gutenberg-rest-attachments-controller-test.php +++ b/phpunit/media/class-gutenberg-rest-attachments-controller-test.php @@ -894,6 +894,109 @@ public function test_sideload_scaled_filename_not_suffixed() { $this->assertStringNotContainsString( '-scaled-1', wp_basename( $attached_file ) ); } + /** + * Verifies that sideloading with an array of size names registers the same + * file under all of the given sizes in attachment metadata. + * + * This supports deduplication of client-side generated sub-sizes when multiple + * registered sizes share identical dimensions (e.g. Twenty Eleven's `large` + * is 768x1024, matching core's `medium_large`). One physical file should be + * registered under every matching size name. + * + * @covers ::sideload_item + * @covers ::register_routes + */ + public function test_sideload_item_accepts_array_of_image_sizes() { + wp_set_current_user( self::$admin_id ); + + $attachment_id = self::factory()->attachment->create_object( + DIR_TESTDATA . '/images/canola.jpg', + 0, + array( + 'post_mime_type' => 'image/jpeg', + ) + ); + + wp_update_attachment_metadata( + $attachment_id, + wp_generate_attachment_metadata( $attachment_id, DIR_TESTDATA . '/images/canola.jpg' ) + ); + + // Register a custom size with the same dimensions as `medium` so both + // sizes resolve to one sideloaded file. + add_image_size( 'duplicate_of_medium', 300, 300, false ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-300x200.jpg' ); + $request->set_param( 'image_size', array( 'medium', 'duplicate_of_medium' ) ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + remove_image_size( 'duplicate_of_medium' ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'sizes', $data['media_details'] ); + + // Both sizes should be registered with the identical file in metadata. + $this->assertArrayHasKey( 'medium', $data['media_details']['sizes'] ); + $this->assertArrayHasKey( 'duplicate_of_medium', $data['media_details']['sizes'] ); + $this->assertSame( 'canola-300x200.jpg', $data['media_details']['sizes']['medium']['file'] ); + $this->assertSame( 'canola-300x200.jpg', $data['media_details']['sizes']['duplicate_of_medium']['file'] ); + $this->assertSame( + $data['media_details']['sizes']['medium']['file'], + $data['media_details']['sizes']['duplicate_of_medium']['file'] + ); + + // Verify the stored metadata (not just the REST response) registers + // both sizes pointing at the single sideloaded file. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertArrayHasKey( 'medium', $metadata['sizes'] ); + $this->assertArrayHasKey( 'duplicate_of_medium', $metadata['sizes'] ); + $this->assertSame( + $metadata['sizes']['medium']['file'], + $metadata['sizes']['duplicate_of_medium']['file'] + ); + } + + /** + * Verifies that sideloading with a single-element array of size names + * works identically to passing a plain string. + * + * @covers ::sideload_item + */ + public function test_sideload_item_accepts_single_element_array() { + wp_set_current_user( self::$admin_id ); + + $attachment_id = self::factory()->attachment->create_object( + DIR_TESTDATA . '/images/canola.jpg', + 0, + array( + 'post_mime_type' => 'image/jpeg', + ) + ); + + wp_update_attachment_metadata( + $attachment_id, + wp_generate_attachment_metadata( $attachment_id, DIR_TESTDATA . '/images/canola.jpg' ) + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-thumb-single.jpg' ); + $request->set_param( 'image_size', array( 'thumbnail' ) ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'thumbnail', $data['media_details']['sizes'] ); + $this->assertSame( 'canola-thumb-single.jpg', $data['media_details']['sizes']['thumbnail']['file'] ); + } + /** * Verifies metadata consistency between server-side and client-side upload flows. * From 4364c14850c8d9c45afb677d4d31408b9412d678 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 20 Apr 2026 15:46:28 -0700 Subject: [PATCH 3/6] Fix PHPCS coding standards in sideload_item Restore the newline that was lost between the final else branch and the return statement during the merge conflict resolution, which caused PHPCS to flag tab alignment between the closing brace and the return. --- lib/media/class-gutenberg-rest-attachments-controller.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/media/class-gutenberg-rest-attachments-controller.php b/lib/media/class-gutenberg-rest-attachments-controller.php index 8ec690a92ec6ce..309dc9f95281b0 100644 --- a/lib/media/class-gutenberg-rest-attachments-controller.php +++ b/lib/media/class-gutenberg-rest-attachments-controller.php @@ -648,6 +648,8 @@ public function sideload_item( WP_REST_Request $request ) { $sub_size_data['file'] = wp_basename( $path ); $sub_size_data['mime_type'] = $type; $sub_size_data['filesize'] = wp_filesize( $path ); - } return rest_ensure_response( $sub_size_data ); + } + + return rest_ensure_response( $sub_size_data ); } } From 6b47f61adf03403b2b5a7153fe65ef2aa69a7f48 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 20 Apr 2026 16:12:25 -0700 Subject: [PATCH 4/6] TEMP: Add diagnostic message to regular_sub_sizes finalize assertion --- .../media/class-gutenberg-rest-attachments-controller-test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit/media/class-gutenberg-rest-attachments-controller-test.php b/phpunit/media/class-gutenberg-rest-attachments-controller-test.php index 2093291e42e21c..dfc0fe88c33c65 100644 --- a/phpunit/media/class-gutenberg-rest-attachments-controller-test.php +++ b/phpunit/media/class-gutenberg-rest-attachments-controller-test.php @@ -1049,7 +1049,7 @@ public function test_finalize_writes_regular_sub_sizes() { ); $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 200, $response->get_status(), 'CI DIAG finalize status ' . $response->get_status() . ' body ' . wp_json_encode( $response->get_data() ) ); $metadata = wp_get_attachment_metadata( $attachment_id, true ); From 8ee3d9a0544c64f49a21fa4ef42c7b449365af38 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 20 Apr 2026 16:14:13 -0700 Subject: [PATCH 5/6] Revert "TEMP: Add diagnostic message to regular_sub_sizes finalize assertion" This reverts commit 6b47f61adf03403b2b5a7153fe65ef2aa69a7f48. --- .../media/class-gutenberg-rest-attachments-controller-test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit/media/class-gutenberg-rest-attachments-controller-test.php b/phpunit/media/class-gutenberg-rest-attachments-controller-test.php index dfc0fe88c33c65..2093291e42e21c 100644 --- a/phpunit/media/class-gutenberg-rest-attachments-controller-test.php +++ b/phpunit/media/class-gutenberg-rest-attachments-controller-test.php @@ -1049,7 +1049,7 @@ public function test_finalize_writes_regular_sub_sizes() { ); $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status(), 'CI DIAG finalize status ' . $response->get_status() . ' body ' . wp_json_encode( $response->get_data() ) ); + $this->assertSame( 200, $response->get_status() ); $metadata = wp_get_attachment_metadata( $attachment_id, true ); From e1f2c0106ef3a09d9fb05e9472b63b0b095f3bfa Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 20 Apr 2026 16:22:55 -0700 Subject: [PATCH 6/6] Fix schema validation for string image_size against newer WordPress core The oneOf schema with {type: string} and {type: array} sub-schemas caused a "matches more than one of the expected formats" error on newer WordPress core because rest_is_array() treats scalar strings as single-element lists (via wp_parse_list). Both schemas matched a plain string, and rest_find_one_matching_schema() requires exactly one match. Replace the oneOf schemas with type: [string, array]. For the sideload endpoint, move the enum validation into a validate_callback so that sizes registered after route-registration (e.g. via add_image_size() in tests) are still honored, and so the check applies correctly to both string and array inputs. --- ...-gutenberg-rest-attachments-controller.php | 88 ++++++++++--------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/lib/media/class-gutenberg-rest-attachments-controller.php b/lib/media/class-gutenberg-rest-attachments-controller.php index 309dc9f95281b0..5c3ea2d7a34bb6 100644 --- a/lib/media/class-gutenberg-rest-attachments-controller.php +++ b/lib/media/class-gutenberg-rest-attachments-controller.php @@ -20,22 +20,6 @@ class Gutenberg_REST_Attachments_Controller extends WP_REST_Attachments_Controll public function register_routes(): void { parent::register_routes(); - // Override the parent's sideload route so that 'scaled' is included - // in the image_size enum. Without the override, core's handler - // validates first and rejects 'scaled' before ours is tried. - $valid_image_sizes = array_keys( wp_get_registered_image_subsizes() ); - - // Special case to set 'original_image' in attachment metadata. - $valid_image_sizes[] = 'original'; - // HEIC/HEIF companion original preserved alongside the JPEG derivative. - // Stored under its own meta key so it never collides with 'original' - // (which the scaled-sideload flow also writes to). - $valid_image_sizes[] = 'original-heic'; - // Client-side big image threshold: sideload the scaled version. - $valid_image_sizes[] = 'scaled'; - // Used for PDF thumbnails. - $valid_image_sizes[] = 'full'; - register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)/sideload', @@ -50,21 +34,47 @@ public function register_routes(): void { 'type' => 'integer', ), 'image_size' => array( - 'description' => __( 'Image size. Can be a single size name or an array of size names to register the same file under multiple sizes.', 'gutenberg' ), - 'oneOf' => array( - array( - 'type' => 'string', - 'enum' => $valid_image_sizes, - ), - array( - 'type' => 'array', - 'items' => array( - 'type' => 'string', - 'enum' => $valid_image_sizes, - ), - ), + 'description' => __( 'Image size. Can be a single size name or an array of size names to register the same file under multiple sizes.', 'gutenberg' ), + 'type' => array( 'string', 'array' ), + 'items' => array( + 'type' => 'string', ), - 'required' => true, + 'required' => true, + // A custom callback is used instead of the default `rest_validate_request_arg` + // because WordPress's `rest_is_array()` treats scalar strings as single-element + // lists (via wp_parse_list), so a oneOf with both a string and array schema + // matches a plain string twice and validation fails with "matches more than one + // of the expected formats". The callback validates the enum per-item using the + // current list of registered sizes, which reflects any sizes added after the + // route was registered (e.g. via add_image_size() in tests). + 'validate_callback' => static function ( $value, $request, $param ) { + $valid_sizes = array_keys( wp_get_registered_image_subsizes() ); + $valid_sizes[] = 'original'; + $valid_sizes[] = 'original-heic'; + $valid_sizes[] = 'scaled'; + $valid_sizes[] = 'full'; + + $items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null ); + if ( null === $items ) { + return new WP_Error( + 'rest_invalid_type', + /* translators: %s: Parameter name. */ + sprintf( __( '%s must be a string or an array of strings.', 'gutenberg' ), $param ) + ); + } + + foreach ( $items as $item ) { + if ( ! is_string( $item ) || ! in_array( $item, $valid_sizes, true ) ) { + return new WP_Error( + 'rest_not_in_enum', + /* translators: %s: Parameter name. */ + sprintf( __( '%s contains an invalid image size.', 'gutenberg' ), $param ) + ); + } + } + + return true; + }, ), 'generate_sub_sizes' => array( 'description' => __( 'Whether to generate image sub sizes from the sideloaded file.', 'gutenberg' ), @@ -100,17 +110,15 @@ public function register_routes(): void { 'type' => 'object', 'properties' => array( 'image_size' => array( + // Uses a multi-type schema instead of `oneOf` because WordPress's + // `rest_is_array()` treats scalar strings as single-element lists, + // so both a `{type: string}` and `{type: array}` oneOf schema would + // match a plain string and trigger a "matches more than one" + // validation error. 'description' => __( 'Size name, or an array of size names when a single file is registered under multiple sizes with matching dimensions.', 'gutenberg' ), - 'oneOf' => array( - array( - 'type' => 'string', - ), - array( - 'type' => 'array', - 'items' => array( - 'type' => 'string', - ), - ), + 'type' => array( 'string', 'array' ), + 'items' => array( + 'type' => 'string', ), 'required' => true, ),