Make WordPress Core

Opened 20 hours ago

Last modified 20 hours ago

#66329 new defect (bug)

Revisions endpoint returns meta that isn't revisioned, so restoring a revision in the editor overwrites it

Reported by: dlocc Owned by:
Priority: normal Milestone: Awaiting Review
Component: REST API Version: 7.0
Severity: normal Keywords: has-patch has-unit-tests
Cc: Focuses:

Description

Hey there! I'm report that the block editor's Visual Revisions, new in 7.0, restores a revision by reading it from the REST API and saving its title, excerpt, content and meta onto the post. The meta part is where it goes wrong.

WP_REST_Revisions_Controller returns every show_in_rest meta key registered for the parent post type, read from the revision ID. Only keys with revisions_enabled, or added with the wp_post_revision_meta_keys filter, are ever stored on a revision, so the rest come back as their default, or null when the default doesn't pass the key's schema. The editor then saves those over the post's real values.

Patterns hit this right away, since wp_pattern_sync_status isn't revisioned and only allows partial or unsynced:

  • Restoring an unsynced pattern sends null, which deletes the status, so the pattern quietly becomes synced.
  • Restoring a synced pattern fails with a 500: "The wp_pattern_sync_status property has an invalid stored value, and cannot be updated to null." The content is already saved by then, so the restore half works and the user sees an error.

On other post types it's silent. Restoring resets every non-revisioned show_in_rest key: strings become '', keys with a registered default get that default, and multi keys are deleted.

The classic revisions screen is fine, because wp_restore_post_revision() only restores the keys from wp_post_revision_meta_keys().

This was reported in Gutenberg as ​#84809, and @davidsword traced it to this endpoint. The editor can't tell which keys are revisioned, since revisions_enabled isn't exposed over REST, so the fix needs to happen here.

Steps to reproduce

  1. Register a meta key that isn't revisioned, for example in an mu-plugin:
    <?php
    add_action( 'init', function () {
            register_post_meta( 'post', 'kept_meta', array(
                    'show_in_rest' => true,
                    'single'       => true,
                    'type'         => 'string',
            ) );
    } );
    
  2. Create a post and update it a couple of times so it has revisions.
  3. Give it a value with wp post meta update <ID> kept_meta hello.
  4. With "Custom fields" turned off in the editor Preferences, so the editor uses Visual Revisions, open Revisions, move to an earlier revision, and click "Restore".
  5. kept_meta is now empty.

GET /wp/v2/posts/<ID>/revisions/<revision ID>?context=edit shows "kept_meta": "" even though nothing is stored on the revision. The steps for patterns are in the Gutenberg issue.

Proposed fix

Have the revisions endpoint only return, and only describe in its schema, the meta keys from wp_post_revision_meta_keys() for the parent post type. That matches what's stored on a revision and what the classic restore does, and the editor doesn't need a change. Autosave responses are built by the revisions controller, so they'd follow too, and autosaves only store revisioned keys anyway.

I have a patch with tests and will link the PR here.

Change History (1)

This ticket was mentioned in ​PR #14359 on ​WordPress/wordpress-develop by ​@dlocc.


20 hours ago
#1

  • Keywords has-patch has-unit-tests added

Visual Revisions in the block editor restores a revision by reading it from the REST API and saving its meta onto the post. The revisions endpoint returns every show_in_rest meta key for the parent post type, read from the revision ID, but only revisioned keys are ever stored on a revision. Everything else comes back as its default, or null when the default doesn't pass the key's schema, and the restore writes that over the post's real values.

For patterns, wp_pattern_sync_status comes back null. Restoring an unsynced pattern deletes the status, so it becomes synced, and restoring a synced pattern fails with "The wp_pattern_sync_status property has an invalid stored value, and cannot be updated to null." after the content has already been saved. On other post types, any non-revisioned show_in_rest key is quietly reset.

Reported in Gutenberg as ​https://github.com/WordPress/gutenberg/issues/84809, where @davidsword traced it to this endpoint.

What changed

  • New WP_REST_Revision_Meta_Fields, a small WP_REST_Post_Meta_Fields subclass whose get_registered_fields() only keeps the keys from wp_post_revision_meta_keys() for the parent post type. That method is where meta keys get mapped to their REST names and schemas, so get_value() and get_field_schema() both follow it, including keys registered with a custom show_in_rest name.
  • WP_REST_Revisions_Controller uses it for $this->meta, so a revision's meta and the meta schema only include revisioned keys.

This lines up with wp_restore_post_revision(), which only restores those same keys, and the editor doesn't need a change. restoreRevision copies whatever meta the revision has, so it now only sends revisioned keys back.

Back compat

  • The response shape doesn't change. meta is still there (an object, or [] when nothing is revisioned, same as before), it just stops including keys that were never stored on the revision.
  • Keys added with the wp_post_revision_meta_keys filter are still included.
  • WP_REST_Autosaves_Controller prepares its responses and schema through the revisions controller, so autosave responses follow too. Autosaves only store revisioned keys already in create_post_autosave(), and autosave requests are still validated against the parent post type's schema, so what clients can send doesn't change.
  • The $meta property is still documented as WP_REST_Post_Meta_Fields, so subclasses that use it keep working.
  • Template revisions build their response with the templates controller, and global styles revisions remove meta from their schema, so neither is affected.
  • A plugin that writes meta onto revisions itself, without revisions_enabled or the filter, would stop seeing those keys in revision responses. Adding them to wp_post_revision_meta_keys brings them back.

Testing

New tests in tests/phpunit/tests/rest-api/rest-revisions-controller.php (9 cases) and tests/phpunit/tests/rest-api/rest-autosaves-controller.php (1 case), all in --group 66329. They cover single revisions, the collection, the schema, autosaves, the filter, custom REST names, and a restore done the same way restoreRevision does it for a post and for synced, unsynced and partially synced patterns. The filter test passes on trunk too, since it checks that filtered keys aren't dropped.

trunk this PR
--group 66329 9 of 10 fail 10/10 pass
--group restapi 3647 tests, no failures
--group revision 45/45 pass

The restapi run has 4 warnings (test_get_items_wdotorg_unavailable, test_create_item_wdotorg_unreachable and the two test_format_validation_is_applied_if_missing_type tests). They're PHPUnit deprecation notices that show up on trunk too and aren't related to this change.

tests/qunit/fixtures/wp-api-generated.js is regenerated as part of the run. The fixture setup registers a meta_key that isn't revisioned, so the revision and autosave fixtures now show "meta": [] instead of { "meta_key": "" }.

PHPCS and PHPStan are clean on the changed files.

I also ran the restore the way the editor does it (GET the revision with context=edit, then save its content and meta to the post) against a local trunk install:

trunk this PR
Post with a non-revisioned kept_meta set to hello kept_meta is '' after restoring stays hello
Unsynced pattern wp_pattern_sync_status deleted, pattern is synced stays unsynced
Synced pattern 500 rest_invalid_stored_value, content already restored 200, restored

Manual testing

  1. Add this as an mu-plugin:
    <?php
       add_action( 'init', function () {
            register_post_meta( 'post', 'kept_meta', array(
                    'show_in_rest' => true,
                    'single'       => true,
                    'type'         => 'string',
            ) );
       } );
    
  1. Create a post and update it a couple of times so it has revisions, then run wp post meta update <ID> kept_meta hello.
  2. With "Custom fields" turned off in the editor Preferences, open Revisions, move to an earlier revision, and click "Restore".
    • Before: kept_meta is empty.
    • After: kept_meta is still hello.
  3. For patterns, create an unsynced pattern, save a few edits, and restore an earlier revision from the Pattern tab.
    • Before: the pattern becomes synced. On a synced pattern, the restore shows an error.
    • After: the pattern keeps its sync status and the restore works.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5
Used for: Reproducing the bug, writing the fix and the PHPUnit tests, and drafting this description. I reviewed the code and the test results before opening this PR.

Note: See TracTickets for help on using tickets.