Skip to content

Commit d899bf9

Browse files
Merge 356c5fd into 2059f09
2 parents 2059f09 + 356c5fd commit d899bf9

11 files changed

Lines changed: 641 additions & 19 deletions

File tree

‎backport-changelog/7.1/13412.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
https://github.com/WordPress/wordpress-develop/pull/13412
2+
3+
* https://github.com/WordPress/gutenberg/pull/81947

‎lib/media/class-gutenberg-rest-attachments-controller.php‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1740,6 +1740,21 @@ public function finalize_item( WP_REST_Request $request ) {
17401740
$response_request['_fields'] = $request['_fields'];
17411741
}
17421742

1743+
/*
1744+
* Re-read the post. The 'wp_generate_attachment_metadata' filter above
1745+
* runs long after $post was fetched, and a callback that rewrites the
1746+
* post row - an optimizer changing post_mime_type once it has
1747+
* converted the file, say - would otherwise be missing from this
1748+
* response. The editor stores the response as its copy of the record
1749+
* rather than reading the attachment again, so a stale row here is
1750+
* what it keeps. A callback that deleted the attachment instead leaves
1751+
* nothing to respond with, so that is reported as the error it is.
1752+
*/
1753+
$post = $this->get_post( $attachment_id );
1754+
if ( is_wp_error( $post ) ) {
1755+
return $post;
1756+
}
1757+
17431758
return $this->prepare_item_for_response( $post, $response_request );
17441759
}
17451760

‎packages/editor/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
- Device Preview: Center the editor canvas with the canvas container's own flex alignment rather than the canvas's auto margins, which were conditional on the resize handles being active. Switching from the mobile or tablet preview back to desktop no longer expands the canvas from the left edge ([#81484](https://github.com/WordPress/gutenberg/pull/81484)).
5454
- `PostSchedule`: Announce the new publish date to screen readers when the date is changed ([#81629](https://github.com/WordPress/gutenberg/pull/81629)).
5555
- Start page/template pattern modals: Align the footer actions with the modal content's padding, so the footer no longer overflows the modal width ([#82021](https://github.com/WordPress/gutenberg/pull/82021)).
56+
- Media: Store the attachment record returned by the `finalize` endpoint instead of refetching the attachment once a client-side upload completes. The finalize response is prepared after the sub-size metadata is written, so it already carries `media_details.sizes`; the refetch it replaces could be answered with the pre-finalize record - by an out-of-order response, or by a host cache keyed on the request URL - leaving the Image block without its Resolution control ([#81947](https://github.com/WordPress/gutenberg/pull/81947)).
5657

5758
### Internal
5859

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import apiFetch from '@wordpress/api-fetch';
22
import { transformAttachment } from '@wordpress/media-utils';
3+
import { receiveFinalizedAttachment } from '../media-upload/finalized-attachments';
34

45
export default async function mediaFinalize( id, subSizes = [] ) {
56
const response = await apiFetch( {
@@ -8,7 +9,19 @@ export default async function mediaFinalize( id, subSizes = [] ) {
89
data: { sub_sizes: subSizes },
910
} );
1011

12+
if ( ! response ) {
13+
return undefined;
14+
}
15+
16+
// The finalize response is the attachment as prepared after its sub-size
17+
// metadata was written, so it is the record the editor should be holding.
18+
// Storing it here means the upload does not have to fetch the attachment
19+
// again to pick the sizes up - a refetch that can be served the pre-finalize
20+
// record either by an out-of-order response or by a host cache keyed on the
21+
// request URL. See https://github.com/WordPress/gutenberg/issues/81844.
22+
receiveFinalizedAttachment( response );
23+
1124
// Returning the post-finalize attachment lets callers refresh the block
1225
// URL (via onChange) so it points at the scaled file and srcset matches.
13-
return response ? transformAttachment( response ) : undefined;
26+
return transformAttachment( response );
1427
}

‎packages/editor/src/utils/media-finalize/test/index.jsdom.test.js‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
22
import apiFetch from '@wordpress/api-fetch';
33
import mediaFinalize from '..';
4+
import { receiveFinalizedAttachment } from '../../media-upload/finalized-attachments';
45

56
vi.mock( import( '@wordpress/api-fetch' ), () => ( {
67
default: vi.fn(),
78
} ) );
89

10+
vi.mock( import( '../../media-upload/finalized-attachments' ), () => ( {
11+
receiveFinalizedAttachment: vi.fn(),
12+
} ) );
13+
914
const mockRestAttachment = {
1015
id: 123,
1116
alt_text: '',
@@ -81,4 +86,30 @@ describe( 'mediaFinalize', () => {
8186

8287
await expect( mediaFinalize( 456 ) ).rejects.toThrow( 'Network error' );
8388
} );
89+
90+
it( 'should store the finalize response as the attachment record', async () => {
91+
// The finalize response is the attachment as prepared after its
92+
// sub-size metadata was written, so it is what the editor should hold.
93+
// Receiving it here is what removes the need to fetch the attachment
94+
// again just to pick the generated sizes up.
95+
const finalized = {
96+
...mockRestAttachment,
97+
media_details: { sizes: { thumbnail: {} } },
98+
};
99+
apiFetch.mockResolvedValue( finalized );
100+
101+
await mediaFinalize( 123 );
102+
103+
// The untransformed record: core-data holds REST records, and
104+
// transformAttachment renames fields for block consumers.
105+
expect( receiveFinalizedAttachment ).toHaveBeenCalledWith( finalized );
106+
} );
107+
108+
it( 'should store nothing when the response is empty', async () => {
109+
apiFetch.mockResolvedValue( undefined );
110+
111+
await mediaFinalize( 123 );
112+
113+
expect( receiveFinalizedAttachment ).not.toHaveBeenCalled();
114+
} );
84115
} );
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { dispatch } from '@wordpress/data';
2+
import { store as coreDataStore } from '@wordpress/core-data';
3+
import type { Attachment } from '@wordpress/core-data';
4+
5+
/**
6+
* The queries the editor resolves an attachment record with.
7+
*
8+
* Blocks read the record either as `getEntityRecord( 'postType', 'attachment',
9+
* id, { context: 'view' } )` or without a query at all, and resolution state is
10+
* keyed on the exact arguments, so both have to be addressed.
11+
*/
12+
const ATTACHMENT_QUERIES = [ { context: 'view' }, undefined ];
13+
14+
/**
15+
* IDs of attachments whose finalized record has already been received.
16+
*
17+
* An entry is consumed by `mediaUploadOnSuccess`, which then has nothing left
18+
* to refetch for that attachment.
19+
*/
20+
const finalized = new Set< number >();
21+
22+
/**
23+
* Stores the attachment record returned by the `finalize` endpoint.
24+
*
25+
* `finalize` is the server's commit point for a client-side upload: it writes
26+
* the sub-size metadata collected from the sideloads and returns the attachment
27+
* as prepared *after* that write, so its `media_details.sizes` is the finished
28+
* set of generated sizes. Storing that response is what makes the editor's copy
29+
* of the record current, so `mediaUploadOnSuccess` has nothing left to
30+
* invalidate for it.
31+
*
32+
* The record is received under both queries the editor resolves attachments
33+
* with. It is prepared in the `edit` context, which for an attachment carries
34+
* every field the `view` context does plus a few edit-only ones, so it also
35+
* satisfies a consumer reading the `view` record.
36+
*
37+
* @param record Attachment record as returned by the `finalize` endpoint.
38+
*/
39+
export function receiveFinalizedAttachment(
40+
record: Partial< Attachment >
41+
): void {
42+
if ( ! record?.id ) {
43+
return;
44+
}
45+
46+
const { receiveEntityRecords } = dispatch( coreDataStore );
47+
48+
for ( const query of ATTACHMENT_QUERIES ) {
49+
/*
50+
* Passing the record on its own rather than in an array keeps this a
51+
* single-item receive, which leaves list queries (the media library,
52+
* the inserter's Media tab) untouched.
53+
*/
54+
receiveEntityRecords( 'postType', 'attachment', record, query );
55+
}
56+
57+
finalized.add( record.id );
58+
}
59+
60+
/**
61+
* Reports whether a finalized record has been received for an attachment,
62+
* forgetting it in the process.
63+
*
64+
* @param id Attachment ID.
65+
*
66+
* @return Whether the editor already holds the finalized record.
67+
*/
68+
export function consumeFinalizedAttachment( id: number ): boolean {
69+
return finalized.delete( id );
70+
}
Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,39 @@
11
import { dispatch } from '@wordpress/data';
22
import { store as coreDataStore } from '@wordpress/core-data';
3+
import { consumeFinalizedAttachment } from './finalized-attachments';
34

45
/**
5-
* Invalidates core-data entity records for uploaded attachments so that
6-
* blocks re-fetch updated data (e.g. `media_details.sizes` after
7-
* thumbnails have been generated via client-side media processing).
6+
* Brings core-data's copy of an uploaded attachment up to date, so that blocks
7+
* see the data the server produced after the upload itself (e.g.
8+
* `media_details.sizes`, once the sub-sizes have been generated).
9+
*
10+
* A client-side upload ends with a `finalize` request whose response already
11+
* carries the finished record, and that response has been stored by then, so
12+
* there is nothing left to fetch. An upload the server processed on its own
13+
* never goes through `finalize`, so its record is invalidated and refetched.
814
*
915
* @param {Object[]} attachments Array of attachment objects from the upload queue.
1016
*/
1117
export default function mediaUploadOnSuccess( attachments ) {
1218
const { invalidateResolution } = dispatch( coreDataStore );
1319
for ( const attachment of attachments ) {
14-
if ( attachment.id ) {
15-
// Invalidate with and without the query argument, since
16-
// resolution keys must exactly match the args used by
17-
// each consumer's getEntityRecord() call.
18-
invalidateResolution( 'getEntityRecord', [
19-
'postType',
20-
'attachment',
21-
attachment.id,
22-
{ context: 'view' },
23-
] );
24-
invalidateResolution( 'getEntityRecord', [
25-
'postType',
26-
'attachment',
27-
attachment.id,
28-
] );
20+
if ( ! attachment.id || consumeFinalizedAttachment( attachment.id ) ) {
21+
continue;
2922
}
23+
24+
// Invalidate with and without the query argument, since
25+
// resolution keys must exactly match the args used by
26+
// each consumer's getEntityRecord() call.
27+
invalidateResolution( 'getEntityRecord', [
28+
'postType',
29+
'attachment',
30+
attachment.id,
31+
{ context: 'view' },
32+
] );
33+
invalidateResolution( 'getEntityRecord', [
34+
'postType',
35+
'attachment',
36+
attachment.id,
37+
] );
3038
}
3139
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { Mock } from 'vitest';
3+
import type { Attachment } from '@wordpress/core-data';
4+
import { dispatch } from '@wordpress/data';
5+
import {
6+
receiveFinalizedAttachment,
7+
consumeFinalizedAttachment,
8+
} from '../finalized-attachments';
9+
10+
vi.mock( import( '@wordpress/data' ), () => ( {
11+
dispatch: vi.fn(),
12+
} ) );
13+
14+
vi.mock(
15+
import( '@wordpress/core-data' ),
16+
() =>
17+
( {
18+
store: 'core',
19+
} ) as unknown as typeof import('@wordpress/core-data')
20+
);
21+
22+
const receiveEntityRecords = vi.fn();
23+
24+
describe( 'receiveFinalizedAttachment', () => {
25+
beforeEach( () => {
26+
vi.clearAllMocks();
27+
( dispatch as unknown as Mock ).mockReturnValue( {
28+
receiveEntityRecords,
29+
} );
30+
} );
31+
32+
afterEach( () => {
33+
// Leave no entry behind for the next test.
34+
consumeFinalizedAttachment( 123 );
35+
} );
36+
37+
it( 'receives the record under both queries the editor resolves attachments with', () => {
38+
const record: Partial< Attachment > = {
39+
id: 123,
40+
media_details: {
41+
sizes: {
42+
full: {
43+
file: 'image.jpg',
44+
width: 1024,
45+
height: 768,
46+
mime_type: 'image/jpeg',
47+
source_url: 'https://example.com/image.jpg',
48+
},
49+
},
50+
},
51+
};
52+
53+
receiveFinalizedAttachment( record );
54+
55+
expect( receiveEntityRecords ).toHaveBeenCalledTimes( 2 );
56+
expect( receiveEntityRecords ).toHaveBeenCalledWith(
57+
'postType',
58+
'attachment',
59+
record,
60+
{ context: 'view' }
61+
);
62+
expect( receiveEntityRecords ).toHaveBeenCalledWith(
63+
'postType',
64+
'attachment',
65+
record,
66+
undefined
67+
);
68+
} );
69+
70+
it( 'receives the record on its own, so list queries are left alone', () => {
71+
// core-data only merges an item list into a query's results when the
72+
// received items are an array; a lone record must stay a lone record.
73+
receiveFinalizedAttachment( { id: 123 } );
74+
75+
for ( const call of receiveEntityRecords.mock.calls ) {
76+
expect( Array.isArray( call[ 2 ] ) ).toBe( false );
77+
}
78+
} );
79+
80+
it( 'ignores a response without an ID', () => {
81+
receiveFinalizedAttachment( {} );
82+
receiveFinalizedAttachment(
83+
undefined as unknown as Partial< Attachment >
84+
);
85+
86+
expect( receiveEntityRecords ).not.toHaveBeenCalled();
87+
} );
88+
} );
89+
90+
describe( 'consumeFinalizedAttachment', () => {
91+
beforeEach( () => {
92+
vi.clearAllMocks();
93+
( dispatch as unknown as Mock ).mockReturnValue( {
94+
receiveEntityRecords,
95+
} );
96+
} );
97+
98+
it( 'reports an attachment whose finalized record was received', () => {
99+
receiveFinalizedAttachment( { id: 456 } );
100+
101+
expect( consumeFinalizedAttachment( 456 ) ).toBe( true );
102+
} );
103+
104+
it( 'reports an attachment that was never finalized', () => {
105+
expect( consumeFinalizedAttachment( 789 ) ).toBe( false );
106+
} );
107+
108+
it( 'forgets the attachment once consumed, so a later upload is not skipped', () => {
109+
receiveFinalizedAttachment( { id: 456 } );
110+
111+
expect( consumeFinalizedAttachment( 456 ) ).toBe( true );
112+
expect( consumeFinalizedAttachment( 456 ) ).toBe( false );
113+
} );
114+
} );

0 commit comments

Comments
 (0)