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
1 change: 1 addition & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

- Device Preview: Keep tablet and mobile iframe widths inside their responsive breakpoints so media queries remain accurate at browser zoom levels.
- Document tools: Fix icon button focus styles to use the design system `outset-ring__focus` mixin ([#81115](https://github.com/WordPress/gutenberg/pull/81115)).
- Post Revisions: Disable the "Restore" button on a revision that already matches the saved post. The current version was offered as restorable and clicking it reported a successful restore without anything having changed.

## 14.52.0 (2026-07-29)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fastDeepEqual from 'fast-deep-equal/es6/index.js';
import { useSelect, useDispatch } from '@wordpress/data';
import { Button } from '@wordpress/components';
import { store as interfaceStore } from '@wordpress/interface';
Expand All @@ -11,6 +12,65 @@ import { store as editorStore } from '../../store';
import { sidebars } from '../sidebar/constants';
import { unlock } from '../../lock-unlock';

/**
* Whether restoring a revision would actually change the post.
*
* `restoreRevision` writes the revision's content, title, excerpt and meta back
* onto the post, so a revision whose fields already match the saved post has
* nothing to restore. The newest revision is always in that state, because
* WordPress stores a copy of the post as a revision every time it is saved.
*
* The comparison is against the saved post rather than the last revision ID.
* An autosave becomes the post's latest revision while it exists, so comparing
* IDs would refuse to restore an autosave, which is the one revision users most
* often want back.
*
* @param {?Object} revision The revision being previewed.
* @param {Object} post The saved post, with raw attribute values.
* @return {boolean} Whether restoring the revision would change the post.
*/
export function hasChangesToRestore( revision, post ) {
if ( ! revision ) {
return false;
}

if ( revision.content?.raw !== post.content ) {
return true;
}

if (
revision.title?.raw !== undefined &&
revision.title.raw !== post.title
) {
return true;
}

if (
revision.excerpt?.raw !== undefined &&
revision.excerpt.raw !== post.excerpt
) {
return true;
}

// Revisions only carry meta registered with `revisions_enabled`, so compare
// the keys the revision actually has instead of the whole meta object.
//
// Protected keys are skipped. They hold internal bookkeeping rather than
// authored content, and revisions do not store it faithfully: the
// collaborative editing document (`_crdt_document`) is saved empty on every
// revision, so comparing it would report a difference against every single
// revision and never let the button settle.
if ( revision.meta ) {
return Object.keys( revision.meta ).some(
( key ) =>
! key.startsWith( '_' ) &&
! fastDeepEqual( revision.meta[ key ], post.meta?.[ key ] )
);
}

return false;
}

/**
* Header component for revisions preview mode.
*
Expand All @@ -20,17 +80,32 @@ import { unlock } from '../../lock-unlock';
* @return {React.JSX.Element} The revisions header component.
*/
function RevisionsHeader( { showDiff, onToggleDiff } ) {
const { currentRevisionId, sidebarIsOpened } = useSelect( ( select ) => {
return {
currentRevisionId: unlock(
const { currentRevisionId, canRestore, sidebarIsOpened } = useSelect(
( select ) => {
const { getCurrentRevisionId, getCurrentRevision } = unlock(
select( editorStore )
).getCurrentRevisionId(),
sidebarIsOpened:
!! select( interfaceStore ).getActiveComplementaryArea(
'core'
),
};
}, [] );
);
const { getCurrentPostAttribute } = select( editorStore );
const revisionId = getCurrentRevisionId();

return {
currentRevisionId: revisionId,
canRestore:
!! revisionId &&
hasChangesToRestore( getCurrentRevision(), {
content: getCurrentPostAttribute( 'content' ),
title: getCurrentPostAttribute( 'title' ),
excerpt: getCurrentPostAttribute( 'excerpt' ),
meta: getCurrentPostAttribute( 'meta' ),
} ),
sidebarIsOpened:
!! select( interfaceStore ).getActiveComplementaryArea(
'core'
),
};
},
[]
);

const { setCurrentRevisionId, restoreRevision } = unlock(
useDispatch( editorStore )
Expand All @@ -39,8 +114,6 @@ function RevisionsHeader( { showDiff, onToggleDiff } ) {
const { enableComplementaryArea, disableComplementaryArea } =
useDispatch( interfaceStore );

const canRestore = !! currentRevisionId;

const handleRestore = () => {
if ( currentRevisionId ) {
restoreRevision( currentRevisionId );
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { hasChangesToRestore } from '../revisions-header';

const post = {
content: '<!-- wp:paragraph --><p>Saved</p><!-- /wp:paragraph -->',
title: 'Saved title',
excerpt: 'Saved excerpt',
meta: {
footnotes: '[]',
unrelated: 'value',
_crdt_document: '{"document":"AAAV46m","updateId":151275664}',
},
};

function getRevision( overrides = {} ) {
return {
id: 2,
content: { raw: post.content },
title: { raw: post.title },
excerpt: { raw: post.excerpt },
...overrides,
};
}

describe( 'hasChangesToRestore', () => {
it( 'returns false while the revision is still loading', () => {
expect( hasChangesToRestore( null, post ) ).toBe( false );
} );

it( 'returns false for a revision matching the saved post', () => {
expect( hasChangesToRestore( getRevision(), post ) ).toBe( false );
} );

it( 'returns true when the content differs', () => {
const revision = getRevision( { content: { raw: 'Something else' } } );

expect( hasChangesToRestore( revision, post ) ).toBe( true );
} );

it( 'returns true when the title differs', () => {
const revision = getRevision( { title: { raw: 'Older title' } } );

expect( hasChangesToRestore( revision, post ) ).toBe( true );
} );

it( 'returns true when the excerpt differs', () => {
const revision = getRevision( { excerpt: { raw: 'Older excerpt' } } );

expect( hasChangesToRestore( revision, post ) ).toBe( true );
} );

it( 'ignores title and excerpt the revision does not carry', () => {
const revision = getRevision( {
title: undefined,
excerpt: undefined,
} );

expect( hasChangesToRestore( revision, post ) ).toBe( false );
} );

it( 'returns true when meta the revision carries differs', () => {
const revision = getRevision( { meta: { footnotes: '[{"id":"a"}]' } } );

expect( hasChangesToRestore( revision, post ) ).toBe( true );
} );

it( 'returns false when meta the revision carries matches', () => {
const revision = getRevision( { meta: { footnotes: '[]' } } );

expect( hasChangesToRestore( revision, post ) ).toBe( false );
} );

it( 'ignores post meta the revision does not carry', () => {
const revision = getRevision( { meta: {} } );

expect( hasChangesToRestore( revision, post ) ).toBe( false );
} );

it( 'ignores protected meta the revision stores empty', () => {
// Revisions save `_crdt_document` empty, so it differs from the post on
// every revision and must not count as a change to restore.
const revision = getRevision( {
meta: { footnotes: '[]', _crdt_document: '' },
} );

expect( hasChangesToRestore( revision, post ) ).toBe( false );
} );
} );
Loading