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,