diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 9d315fbbfe4ee6..c66793cefb737a 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -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) diff --git a/packages/editor/src/components/post-revisions-preview/revisions-header.js b/packages/editor/src/components/post-revisions-preview/revisions-header.js index a9e2e51aa275c4..42aa625af8abc7 100644 --- a/packages/editor/src/components/post-revisions-preview/revisions-header.js +++ b/packages/editor/src/components/post-revisions-preview/revisions-header.js @@ -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'; @@ -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. * @@ -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 ) @@ -39,8 +114,6 @@ function RevisionsHeader( { showDiff, onToggleDiff } ) { const { enableComplementaryArea, disableComplementaryArea } = useDispatch( interfaceStore ); - const canRestore = !! currentRevisionId; - const handleRestore = () => { if ( currentRevisionId ) { restoreRevision( currentRevisionId ); diff --git a/packages/editor/src/components/post-revisions-preview/test/revisions-header.js b/packages/editor/src/components/post-revisions-preview/test/revisions-header.js new file mode 100644 index 00000000000000..3f530fffbb9f86 --- /dev/null +++ b/packages/editor/src/components/post-revisions-preview/test/revisions-header.js @@ -0,0 +1,87 @@ +import { hasChangesToRestore } from '../revisions-header'; + +const post = { + content: '

Saved

', + 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 ); + } ); +} );