From 4a2b483cdb02f3414f1cddbea1205526c66417a9 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 15 May 2026 09:28:50 -0700 Subject: [PATCH] Upload Media: pick up the finalized attachment URL so srcset renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #78038 moved big-image threshold scaling out of prepareItem() into a post-upload sideload, so the create-item response now carries the URL of the unscaled original. The block stores that URL via onChange, and the scaled-sideload step's server-side update_attached_file() never makes its way back to the client — mediaFinalize() is typed as Promise and discards the REST response. The block then persists the unscaled URL, so on the front end wp_calculate_image_srcset() compares the block's src (IMG_0441.jpg) to each size file (IMG_0441-225x300.jpg, IMG_0441-768x1024.jpg, …) via str_contains() and matches none of them. $src_matched stays false and the function returns false — no srcset attribute is emitted. Make mediaFinalize() return the REST attachment (transformed so source_url maps to url), and have finalizeItem() forward it to finishOperation. The reducer merges it into item.attachment, and the next processItem pass fires onChange with the scaled URL, which is what the block needs to land a src that matches a known size. Adds a unit test for the new mediaFinalize return value, a regression test for finalizeItem forwarding the attachment to the reducer, and an e2e test that publishes an oversized CSM upload and asserts the rendered ships with a srcset of at least two width descriptors. Fixes #78358 --- packages/editor/CHANGELOG.md | 4 + .../editor/src/utils/media-finalize/index.js | 7 +- .../src/utils/media-finalize/test/index.js | 34 +++++++- packages/upload-media/CHANGELOG.md | 1 + .../upload-media/src/store/private-actions.ts | 17 +++- .../src/store/test/private-actions.js | 38 +++++++++ packages/upload-media/src/store/types.ts | 7 +- .../client-side-media-processing.spec.js | 78 +++++++++++++++++++ 8 files changed, 180 insertions(+), 6 deletions(-) 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,