diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 15ed912fbd9c4a..311a127ea1755b 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +- `mediaFinalize` now returns the post-finalize attachment (transformed from the REST response), so the upload-media queue can refresh the in-flight attachment URL. Required for the front-end `srcset` to render on client-side-media uploads that exceeded the big-image threshold. + ## 14.46.0 (2026-05-14) ### Internal diff --git a/packages/editor/src/utils/media-finalize/index.js b/packages/editor/src/utils/media-finalize/index.js index 1133cecc7dd7ed..38afe9af8e321e 100644 --- a/packages/editor/src/utils/media-finalize/index.js +++ b/packages/editor/src/utils/media-finalize/index.js @@ -2,11 +2,16 @@ * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; +import { transformAttachment } from '@wordpress/media-utils'; export default async function mediaFinalize( id, subSizes = [] ) { - await apiFetch( { + const response = await apiFetch( { path: `/wp/v2/media/${ id }/finalize`, method: 'POST', data: { sub_sizes: subSizes }, } ); + + // Returning the post-finalize attachment lets callers refresh the block + // URL (via onChange) so it points at the scaled file and srcset matches. + return response ? transformAttachment( response ) : undefined; } diff --git a/packages/editor/src/utils/media-finalize/test/index.js b/packages/editor/src/utils/media-finalize/test/index.js index a13625ec13f618..8ed539e352735e 100644 --- a/packages/editor/src/utils/media-finalize/test/index.js +++ b/packages/editor/src/utils/media-finalize/test/index.js @@ -10,13 +10,21 @@ import mediaFinalize from '..'; jest.mock( '@wordpress/api-fetch', () => jest.fn() ); +const mockRestAttachment = { + id: 123, + alt_text: '', + caption: { raw: '' }, + title: { raw: '' }, + source_url: 'https://example.com/wp-content/uploads/image-scaled.jpg', +}; + describe( 'mediaFinalize', () => { beforeEach( () => { jest.clearAllMocks(); } ); it( 'should call the finalize endpoint with the correct path, method, and sub_sizes', async () => { - apiFetch.mockResolvedValue( {} ); + apiFetch.mockResolvedValue( mockRestAttachment ); const subSizes = [ { @@ -39,7 +47,7 @@ describe( 'mediaFinalize', () => { } ); it( 'should send empty sub_sizes array by default', async () => { - apiFetch.mockResolvedValue( {} ); + apiFetch.mockResolvedValue( mockRestAttachment ); await mediaFinalize( 123 ); @@ -50,6 +58,28 @@ describe( 'mediaFinalize', () => { } ); } ); + it( 'should return the transformed attachment with the scaled URL so the block can pick up srcset', async () => { + apiFetch.mockResolvedValue( mockRestAttachment ); + + const result = await mediaFinalize( 123 ); + + // transformAttachment maps source_url -> url, which is the key the + // block stores. Without this mapping, the block keeps the original + // (pre-finalize) URL and wp_calculate_image_srcset() emits no srcset. + expect( result ).toMatchObject( { + id: 123, + url: 'https://example.com/wp-content/uploads/image-scaled.jpg', + } ); + } ); + + it( 'should return undefined when the response is empty', async () => { + apiFetch.mockResolvedValue( undefined ); + + const result = await mediaFinalize( 123 ); + + expect( result ).toBeUndefined(); + } ); + it( 'should propagate errors from apiFetch', async () => { apiFetch.mockRejectedValue( new Error( 'Network error' ) ); diff --git a/packages/upload-media/CHANGELOG.md b/packages/upload-media/CHANGELOG.md index 316f2d5b2bcb29..572bc168438dde 100644 --- a/packages/upload-media/CHANGELOG.md +++ b/packages/upload-media/CHANGELOG.md @@ -5,6 +5,7 @@ ### Bug Fix - Fix `-scaled` suffix propagating to every sub-size filename when an image exceeds `big_image_size_threshold`. Threshold scaling now runs as a sideload after the original is uploaded, so sub-sizes inherit the un-suffixed basename — matching WordPress core's `wp_create_image_subsizes()` naming. +- Propagate the post-finalize attachment from `mediaFinalize` back to the queue so the block markup picks up the `-scaled` URL. Without this, oversized client-side uploads kept the unscaled original's URL in the block, and `wp_calculate_image_srcset()` could not match the `src` to any entry in `$image_meta['sizes']` — so the front-end `` shipped without `srcset`. ## 0.31.0 (2026-05-14) diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 846fbcfe3cfb84..a19823874f9b33 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -1304,11 +1304,24 @@ export function finalizeItem( id: QueueItemId ) { const attachment = item.attachment; const { mediaFinalize } = select.getSettings(); + const updates: Partial< QueueItem > = {}; // Only finalize if we have an attachment ID and a mediaFinalize callback. if ( attachment?.id && mediaFinalize ) { try { - await mediaFinalize( attachment.id, item.subSizes || [] ); + // Pass the post-finalize attachment through so the reducer + // merges the updated URL (now pointing at the `-scaled` file) + // into item.attachment. The next processItem pass fires + // onChange with that URL, which is what the block stores — + // and what `wp_calculate_image_srcset()` needs in order to + // match a known size and emit srcset on the front end. + const updatedAttachment = await mediaFinalize( + attachment.id, + item.subSizes || [] + ); + if ( updatedAttachment ) { + updates.attachment = updatedAttachment; + } } catch ( error ) { // Log but don't fail the upload if finalization fails. // eslint-disable-next-line no-console @@ -1316,7 +1329,7 @@ export function finalizeItem( id: QueueItemId ) { } } - dispatch.finishOperation( id, {} ); + dispatch.finishOperation( id, updates ); }; } diff --git a/packages/upload-media/src/store/test/private-actions.js b/packages/upload-media/src/store/test/private-actions.js index 0928751b317463..c918fbf2ffa319 100644 --- a/packages/upload-media/src/store/test/private-actions.js +++ b/packages/upload-media/src/store/test/private-actions.js @@ -337,6 +337,44 @@ describe( 'private actions', () => { expect( finishOperation ).toHaveBeenCalledWith( 'test-id', {} ); } ); + it( 'should forward the finalized attachment to finishOperation', async () => { + // Regression: after PR #78038, CSM uploads the original file rather + // than a pre-scaled copy, so the upload response carries the URL of + // the un-scaled original. The scaled-sideload step later updates + // _wp_attached_file server-side, and finalize returns the + // up-to-date attachment. The queue's stored attachment must be + // merged with that response so onChange propagates the scaled URL + // to the block — otherwise wp_calculate_image_srcset() cannot + // match the src to a known size and no srcset is rendered. + const updatedAttachment = { + id: 42, + url: 'https://example.com/wp-content/uploads/image-scaled.jpg', + }; + const mediaFinalize = jest + .fn() + .mockResolvedValue( updatedAttachment ); + const finishOperation = jest.fn(); + const select = { + getItem: () => ( { + attachment: { + id: 42, + url: 'https://example.com/wp-content/uploads/image.jpg', + }, + subSizes: mockSubSizes, + } ), + getSettings: () => ( { mediaFinalize } ), + }; + const dispatch = { finishOperation }; + + const thunk = finalizeItem( 'test-id' ); + await thunk( { select, dispatch } ); + + expect( mediaFinalize ).toHaveBeenCalledWith( 42, mockSubSizes ); + expect( finishOperation ).toHaveBeenCalledWith( 'test-id', { + attachment: updatedAttachment, + } ); + } ); + it( 'should handle mediaFinalize errors gracefully', async () => { const mediaFinalize = jest .fn() diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index 07c4bb2029deeb..308b2a67ff449d 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -204,7 +204,12 @@ export interface Settings { // Default is 0.82 if not set. imageQuality?: number; // Function for finalizing an upload after all client-side processing is complete. - mediaFinalize?: ( id: number, subSizes: SubSizeData[] ) => Promise< void >; + // May return the up-to-date attachment so the queue and block markup can pick + // up the post-finalize URL (the scaled file), which is required for `srcset`. + mediaFinalize?: ( + id: number, + subSizes: SubSizeData[] + ) => Promise< Partial< Attachment > | void >; } // Matches the Attachment type from the media-utils package. diff --git a/test/e2e/specs/editor/various/client-side-media-processing.spec.js b/test/e2e/specs/editor/various/client-side-media-processing.spec.js index 228174adc9396c..5f76e69d2f8aca 100644 --- a/test/e2e/specs/editor/various/client-side-media-processing.spec.js +++ b/test/e2e/specs/editor/various/client-side-media-processing.spec.js @@ -472,6 +472,84 @@ test.describe( 'Client-side media processing', () => { } } ); + test( 'renders srcset on the front end after publishing a CSM-uploaded image', async ( { + page, + editor, + mediaProcessingUtils, + requestUtils, + } ) => { + // Regression for the CSM srcset bug: when CSM uploads the unscaled + // original (then sideloads sub-sizes and a -scaled file), the block + // must end up storing a URL that matches a known size in the + // attachment metadata. Otherwise wp_calculate_image_srcset() returns + // false and the front-end `` ships with no srcset. + await editor.insertBlock( { name: 'core/image' } ); + + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + await mediaProcessingUtils.upload( + imageBlock.locator( 'data-testid=form-file-upload-input' ), + '5000x4000_e2e_test_image_oversized.jpeg' + ); + + const imageInEditor = imageBlock.getByRole( 'img', { + name: 'This image has an empty alt attribute', + } ); + await expect( imageInEditor ).toBeVisible(); + await expect( imageInEditor ).toHaveAttribute( 'src', /^https?:\/\//, { + timeout: 30_000, + } ); + + // Wait for the full upload pipeline (including finalize) to settle. + await mediaProcessingUtils.waitForUploadQueueEmpty(); + await expect( imageBlock ).not.toHaveClass( /is-transient/, { + timeout: 30_000, + } ); + await expect( + page.getByRole( 'button', { name: 'Publish', exact: true } ) + ).toBeEnabled( { timeout: 30_000 } ); + + // Confirm the stored block URL was updated to the scaled file after + // finalize. Without the fix, the block would keep the unscaled + // original's URL and the assertion would fail. + const blockUrl = await page.evaluate( () => { + return window.wp.data + .select( 'core/block-editor' ) + .getSelectedBlock()?.attributes?.url; + } ); + expect( blockUrl ).toMatch( /-scaled\.jpe?g$/ ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + const figureDom = page.getByRole( 'figure' ); + await expect( figureDom ).toBeVisible(); + + const imageDom = figureDom.locator( 'img' ); + await expect( imageDom ).toBeVisible(); + + // The fix: srcset must be present and reference the sub-sizes that + // CSM sideloaded. Without it, wp_calculate_image_srcset() returns + // false because the src basename doesn't match anything in + // $image_meta['sizes']. Require at least two width descriptors — + // core itself returns false (no attribute) when fewer than two + // candidates qualify. + await expect( imageDom ).toHaveAttribute( 'srcset', /\d+w.*\d+w/s ); + + const imageId = await mediaProcessingUtils.getSelectedBlockImageId(); + expect( imageId ).toBeDefined(); + const media = await mediaProcessingUtils.getMediaDetails( + requestUtils, + imageId + ); + // Sanity: the metadata used by core to build srcset is populated. + expect( media.media_details.sizes.medium ).toBeDefined(); + expect( media.media_details.sizes.large ).toBeDefined(); + } ); + test( 'auto-rotates images based on EXIF orientation', async ( { editor, mediaProcessingUtils,