Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion packages/editor/src/utils/media-finalize/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
34 changes: 32 additions & 2 deletions packages/editor/src/utils/media-finalize/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand All @@ -39,7 +47,7 @@ describe( 'mediaFinalize', () => {
} );

it( 'should send empty sub_sizes array by default', async () => {
apiFetch.mockResolvedValue( {} );
apiFetch.mockResolvedValue( mockRestAttachment );

await mediaFinalize( 123 );

Expand All @@ -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' ) );

Expand Down
1 change: 1 addition & 0 deletions packages/upload-media/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` shipped without `srcset`.

## 0.31.0 (2026-05-14)

Expand Down
17 changes: 15 additions & 2 deletions packages/upload-media/src/store/private-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1304,19 +1304,32 @@ 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
console.warn( 'Media finalization failed:', error );
}
}

dispatch.finishOperation( id, {} );
dispatch.finishOperation( id, updates );
};
}

Expand Down
38 changes: 38 additions & 0 deletions packages/upload-media/src/store/test/private-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 6 additions & 1 deletion packages/upload-media/src/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
78 changes: 78 additions & 0 deletions test/e2e/specs/editor/various/client-side-media-processing.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` 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,
Expand Down
Loading