Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c3fc8ef
Media: attach media used by Image and Gallery blocks on save
andrewserong Aug 24, 2026
b88773e
Media: only attach to post types with a front end
andrewserong Aug 24, 2026
d543210
Media: don't claim media inside a synced pattern or template part
andrewserong Aug 24, 2026
bc0f9e4
Media: add an editor setting to switch off attaching media on save
andrewserong Aug 24, 2026
070d495
Update comments
andrewserong Aug 24, 2026
3221e58
Media: colocate the attach-on-save hook in its own directory
andrewserong Aug 24, 2026
69b5305
Media: only interrogate Image blocks
andrewserong Aug 24, 2026
6151ca8
Media: call the attach directly from savePost instead of hooking it
andrewserong Aug 26, 2026
22f014d
Media: don't attach while a post is being trashed, and say when a wri…
andrewserong Aug 26, 2026
5c31ef1
Add more error handling
andrewserong Aug 26, 2026
c975fbb
Sort block ids so that re-ordered blocks resolve to the same cache it…
andrewserong Aug 26, 2026
0296c7b
Don't import invalidate-attachment-resolutions, but instead make a du…
andrewserong Aug 26, 2026
ef6ad2b
Try out getEditorBlocks fix
andrewserong Aug 27, 2026
e9bc282
Simplify code comments
andrewserong Aug 28, 2026
279044e
Add a 100 items cap to the number of images that we will attempt to u…
andrewserong Aug 31, 2026
e9d913f
Rename test files to match new naming convention
andrewserong Aug 31, 2026
c8a35f5
Tweak comments for clarity
andrewserong Aug 31, 2026
18ac648
Add cap to request, too
andrewserong Aug 31, 2026
07fb062
Correct erroneous comments
andrewserong Sep 1, 2026
19c89ea
Move call to get blocks earlier so that the snapshot of blocks to che…
andrewserong Sep 1, 2026
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
1 change: 1 addition & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Bug Fixes

- Attach media an Image or Gallery block displays to the post on save, when it is not already attached to another post, matching what uploading into that post has always done ([#81977](https://github.com/WordPress/gutenberg/pull/81977)).
- `EditorInterface`: Apply the `showListViewByDefault` preference when the editor enters edit mode, so every editor built on the package honors it — including the extensible site editor, which previously ignored it. The logic moves here from `edit-post` and `edit-site`.
- `StylesCanvas`: In preview mode, render edge to edge without the close button and Escape handler. There the canvas is the whole surface rather than a frame opened over an editing session, and whatever opened it owns closing it.
- Upload progress snackbar: Stop announcing "Upload complete" for a batch in which everything failed. The snackbar treated the queue draining as success, so a failed upload showed a completion checkmark right next to its own error. A batch that fails outright now just takes the progress snackbar down, and a partly failed one reports "Uploaded 3 of 5" ([#81132](https://github.com/WordPress/gutenberg/issues/81132)).
Expand Down
27 changes: 27 additions & 0 deletions packages/editor/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
getNotificationArgumentsForSaveFail,
getNotificationArgumentsForTrashFail,
} from './utils/notice-builder';
import attachMediaInPost from './utils/attach-media-in-post';
import { unlock } from '../lock-unlock';
import { setCanvasWidth } from './private-actions';
import { getCanvasWidthByDeviceType } from '../utils/device-type';
Expand Down Expand Up @@ -199,6 +200,10 @@ export const savePost =
dispatch.editPost( { content }, { undoIgnore: true } );

const previousRecord = select.getCurrentPost();
// Snapshotted alongside the content above, so that attaching media once
// the save finishes works from what was saved for the post itself.
const savedBlocks = select.getEditorBlocks();

let edits = {
id: previousRecord.id,
...registry
Expand Down Expand Up @@ -277,6 +282,28 @@ export const savePost =
}
dispatch( { type: 'REQUEST_POST_UPDATE_FINISH', options } );

// Attach any images in the post that aren't attached to a post yet. Not
// awaited: it shouldn't hold up the editor showing "Saved", and it
// handles its own errors.
//
// `isDeletingPost` is what catches trashing. `trashPost` deletes the post
// and then calls this, and the record still says what it did before the
// delete, so checking the status alone would miss it.
if (
! error &&
! options.isAutosave &&
! options.isPreview &&
! select.isDeletingPost() &&
previousRecord.status !== 'trash' &&
select.getEditorSettings().autoAttachMediaEnabled
) {
Comment on lines +292 to +299

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another question - this attaches images after a save is kicked off. What happens if, says, I'm the Flash, and I add another image while the save request is still happening?

Would it hurt to hoist these checks further up, possibly also select.getEditorBlocks() so you're getting the saved post's blocks, not whatever else has been added to the screen?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, bit of an edge case, but good to be thorough here!

In terms of where we place the checks (this big if block), I chose this location because it occurs after all the hooked filters run (i.e. pre save, unstable save post, save post action, etc). If any of these stages have errored, then we want to skip this side-effect of attaching media.

But, we can place the select.getEditorBlocks() right up to the top so that we take a snapshot of the blocks early in the cycle. I've pushed that in 19c89ea, which I think gives us the best of both worlds (early snapshot, while still only attaching media late in the cycle).

attachMediaInPost( registry, {
id: previousRecord.id,
type: previousRecord.type,
blocks: savedBlocks,
} );
}
Comment on lines +293 to +305

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is all in savePost, but any save that goes through the entities save panel never calls savePost. So if I add an image in the post, then edit a template part and save from there, the attach isn't registered.

Kapture.2026-08-28.at.17.28.40.mp4

Not sure if this is a big deal TBH

Is there a way to run attachMediaInPost in the panel or elsewhere? I dunno!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, great catch! I'll dig in further next week. Cheers!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks again for flagging. I looked into this a little further, but I think getting this running in the other multi-entity panels winds up adding a bit more complexity than it's worth.

IMO I think this behaviour is roughly comparable to anything else that will get hooked on editor.savePost directly above this call, e.g. saving meta boxes in the post editor. For the purposes of this PR, that makes the scope: when saving a post or page in isolation as a side-effect of saving or updating a post.

I think that's probably good scope for now, and we can revisit / look at expanding it further if we get feedback or requests for it. My hunch is that we likely won't as this is largely a feature to cover a parity gap between the classic and block editors, and we're kind of doing it silently, or as silently as we can.

Let me know if you think it winds up being important!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense, thanks for diving in.

Scoping it to savePost makes sense the way you explain it. For me also, the change in context - saving from the other entity - helps to provide a physical gate anyway.


if ( error ) {
const args = getNotificationArgumentsForSaveFail( {
post: previousRecord,
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/src/store/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { SETTINGS_DEFAULTS } from '@wordpress/block-editor';
* @property {Array?} allowedMimeTypes List of allowed mime types and file extensions
* @property {number} maxUploadFileSize Maximum upload file size
* @property {boolean} supportsLayout Whether the editor supports layouts.
* @property {boolean} autoAttachMediaEnabled Whether media in Image or Gallery blocks not yet attached to the post should be automatically attached on save.
*/
export const EDITOR_SETTINGS_DEFAULTS = {
...SETTINGS_DEFAULTS,
Expand All @@ -31,4 +32,5 @@ export const EDITOR_SETTINGS_DEFAULTS = {
fontLibraryEnabled: true,
enableCustomFields: undefined,
defaultRenderingMode: 'post-only',
autoAttachMediaEnabled: true,
};
312 changes: 312 additions & 0 deletions packages/editor/src/store/test/actions.jsdom.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { speak } from '@wordpress/a11y';
import apiFetch from '@wordpress/api-fetch';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { registerBlockType, unregisterBlockType } from '@wordpress/blocks';
import { store as coreStore } from '@wordpress/core-data';
import { createRegistry } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';
Expand Down Expand Up @@ -395,6 +396,317 @@ describe( 'Post actions', () => {
} );
} );

describe( 'savePost() attaching media', () => {
// `getEditorBlocks` parses the post's content when there is no `blocks`
// edit, so the block type has to exist for an image to be recognisable.
beforeEach( () => {
registerBlockType( 'core/image', {
title: 'Image',
category: 'media',
// Anything below 3 warns about iframe compatibility.
apiVersion: 3,
attributes: { id: { type: 'number' } },
save: () => null,
} );
} );

afterEach( () => {
unregisterBlockType( 'core/image' );
} );

const imagePost = {
id: postId,
type: 'post',
title: 'bar',
// Void form, so it validates against the minimal `save` registered
// above — the markup is incidental, the `id` attribute is the point.
content: '<!-- wp:image {"id":12} /-->',
excerpt: 'crackers',
status: 'draft',
};

/**
* Answers everything `savePost` and the attach need, recording each
* request so the test can assert on what was and wasn't issued.
*
* @param {string[]} requests Collects `METHOD path` for every call.
* @param {Function} [duringPostSave] Run while the post's own save is in
* flight, to stand in for the user
* editing before it comes back.
*/
function setFetchHandler( requests, duringPostSave ) {
attachedTo = undefined;
apiFetch.setFetchHandler( async ( options ) => {
const method = getMethod( options );
const { path, data } = options;
requests.push( `${ method } ${ path }` );

if ( method === 'DELETE' ) {
return { ...imagePost, status: 'trash' };
}
if ( method === 'PUT' && path.startsWith( '/wp/v2/media/' ) ) {
attachedTo = data.post;
return { id: 12, post: data.post };
}
if ( method === 'GET' && path.startsWith( '/wp/v2/media' ) ) {
// Answer what was actually asked for. Returning a fixed
// record instead would mask the whole bug: a request that
// wrongly includes the template's image would still come
// back with only the post's.
const requested = [
...path.matchAll( /include(?:%5B\d+%5D)?=(\d+)/g ),
].map( ( match ) => Number( match[ 1 ] ) );

// Also resolved with `parse: false`, so the totals headers
// can be read. `link` must be absent or the fetch-all
// middleware follows a next page.
return {
json: async () =>
requested.map( ( id ) => ( { id, post: null } ) ),
headers: {
get: ( name ) =>
name.toLowerCase() === 'link' ? null : '1',
},
};
}
if (
method === 'PUT' &&
path.startsWith( `/wp/v2/posts/${ postId }` )
) {
duringPostSave?.();
return { ...imagePost, ...data };
}
if (
method === 'GET' &&
path.startsWith( '/wp/v2/types/post' )
) {
// Resolved with `parse: false`, so this has to look like a
// Response rather than the record itself.
return {
json: async () => ( {
...postTypeEntity,
viewable: true,
} ),
};
}

throw {
code: 'unknown_path',
message: `Unknown path: ${ method } ${ path }`,
};
} );
}

const hasAttached = ( requests ) =>
requests.some( ( request ) =>
request.startsWith( 'PUT /wp/v2/media/12' )
);

// What was written, not just that something was.
let attachedTo;

/**
* The attach is deliberately not awaited by `savePost`, so give its
* requests a chance to land before asserting they did not.
*/
async function flush() {
for ( let i = 0; i < 20; i++ ) {
await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
}
}

function setUpEditor( registry ) {
// Only the `post` entity is registered by default, and without a
// config for attachments the records lookup cannot resolve.
registry.dispatch( coreStore ).addEntities( [
{
kind: 'postType',
name: 'attachment',
baseURL: '/wp/v2/media',
rawAttributes: [ 'title', 'excerpt', 'content' ],
// As `loadPostTypeEntities` configures it. Without this the
// resolver takes its unpaginated branch, which isn't the one
// that runs in the editor.
supportsPagination: true,
},
] );
registry
.dispatch( coreStore )
.receiveEntityRecords( 'postType', 'post', imagePost );
registry.dispatch( editorStore ).setupEditor( imagePost, {
content: imagePost.content,
} );
// The block editor store is populated by the editor provider, which
// isn't mounted here — and without blocks there is no media to find,
// so every assertion below would pass whether the guards work or not.
registry.dispatch( blockEditorStore ).resetBlocks( [
{
clientId: 'image-1',
name: 'core/image',
isValid: true,
attributes: { id: 12 },
innerBlocks: [],
},
] );
}

/**
* `savePost` reads the post's blocks up front, next to the content it
* sends. An image added while the request is in flight was not part of
* this save, so it waits for the next one.
*/
it( 'attaches what was saved, not media added mid-save', async () => {
const requests = [];
const registry = createRegistryWithStores();
setFetchHandler( requests, () => {
registry.dispatch( editorStore ).editPost( {
blocks: [
{
clientId: 'image-1',
name: 'core/image',
isValid: true,
attributes: { id: 12 },
innerBlocks: [],
},
{
clientId: 'image-2',
name: 'core/image',
isValid: true,
attributes: { id: 77 },
innerBlocks: [],
},
],
} );
} );
setUpEditor( registry );

await registry.dispatch( editorStore ).savePost();
await flush();

expect( hasAttached( requests ) ).toBe( true );
expect(
requests.some( ( request ) =>
request.startsWith( 'PUT /wp/v2/media/77' )
)
).toBe( false );
} );

it( 'attaches media the post displays', async () => {
const requests = [];
setFetchHandler( requests );

const registry = createRegistryWithStores();
setUpEditor( registry );

await registry.dispatch( editorStore ).savePost();
await flush();

expect( hasAttached( requests ) ).toBe( true );
expect( attachedTo ).toBe( postId );
} );

/**
* The bug a contributor found: with "Show template" on, the block editor
* holds the *template's* tree with the post nested inside a
* `core/post-content` block, so reading the canvas picked up the
* template's media and attached it to the post.
*/
it( "ignores media in the canvas that is not the post's own", async () => {
const requests = [];
setFetchHandler( requests );

const registry = createRegistryWithStores();
setUpEditor( registry );

// The canvas as template mode leaves it: an image belonging to the
// template, alongside the post's content.
registry.dispatch( blockEditorStore ).resetBlocks( [
{
clientId: 'template-image',
name: 'core/image',
isValid: true,
attributes: { id: 99 },
innerBlocks: [],
},
{
clientId: 'post-content',
name: 'core/post-content',
isValid: true,
attributes: {},
innerBlocks: [
{
clientId: 'image-1',
name: 'core/image',
isValid: true,
attributes: { id: 12 },
innerBlocks: [],
},
],
},
] );

await registry.dispatch( editorStore ).savePost();
await flush();

// The post's own image, and only that.
expect( hasAttached( requests ) ).toBe( true );
expect(
requests.some( ( request ) =>
request.startsWith( 'PUT /wp/v2/media/99' )
)
).toBe( false );
} );

it( 'attaches nothing when the editor setting is off', async () => {
const requests = [];
setFetchHandler( requests );

const registry = createRegistryWithStores();
registry
.dispatch( editorStore )
.updateEditorSettings( { autoAttachMediaEnabled: false } );
setUpEditor( registry );

await registry.dispatch( editorStore ).savePost();
await flush();

expect( hasAttached( requests ) ).toBe( false );
} );

/**
* `trashPost` deletes the post and then calls `savePost`, and
* `isEditedPostSaveable` has no status check to stop it — so without a
* guard a post on its way to the bin would claim media on the way out.
*/
it( 'attaches nothing while the post is being trashed', async () => {
const requests = [];
setFetchHandler( requests );

const registry = createRegistryWithStores();
setUpEditor( registry );

await registry.dispatch( editorStore ).trashPost();
await flush();

expect( hasAttached( requests ) ).toBe( false );
} );

it( 'attaches nothing on an autosave', async () => {
const requests = [];
setFetchHandler( requests );

const registry = createRegistryWithStores();
setUpEditor( registry );

await registry
.dispatch( editorStore )
.savePost( { isAutosave: true } );
await flush();

expect( hasAttached( requests ) ).toBe( false );
} );
} );

describe( 'autosave()', () => {
it( 'autosaves a modified post', async () => {
const post = {
Expand Down
Loading
Loading