From 671f83e2552a2f68e7779368532707b8553a8546 Mon Sep 17 00:00:00 2001 From: Jake Spurlock Date: Tue, 20 Oct 2026 10:30:32 -0700 Subject: [PATCH 1/2] REST API: Query autosaves directly for every autosaves collection request. Without `author`, the collection still loaded every revision of the parent through wp_get_post_revisions() and filtered out the autosaves, so memory grew with revision count, including for the editor's preload. Run the direct autosave query for every request and have `author` only add `author__in`. Match on `post_name__in` rather than `name`, which made the query singular, and suppress query filters to keep the isolation the get_posts() path had. Apply the per-author dedupe to both paths, and update the `@since` tags to 7.3.0. See #62057. Co-Authored-By: Claude Opus 5.5 --- .../class-wp-rest-autosaves-controller.php | 69 +++++++++---------- .../rest-api/rest-autosaves-controller.php | 52 ++++++++++++-- 2 files changed, 80 insertions(+), 41 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index 81d072f03e5d1..e5c4f9725aef5 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -302,12 +302,14 @@ public function get_item( $request ) { } /** - * Gets a collection of autosaves using wp_get_post_autosave. + * Gets a collection of autosaves for the parent post. * - * Contains the user's autosave, for empty if it doesn't exist. + * WordPress stores at most one autosave per author, so this contains one + * entry per author who has autosaved the parent post, newest first. * * @since 5.0.0 - * @since 7.2.0 Added support for the `author` parameter. + * @since 7.3.0 Autosaves are queried directly rather than filtered out of + * every revision of the parent. Added the `author` parameter. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. @@ -326,47 +328,39 @@ public function get_items( $request ) { $response = array(); $parent_id = $parent->ID; - if ( ! empty( $request['author'] ) ) { - $autosaves = get_posts( - array( - 'post_type' => 'revision', - 'post_status' => 'inherit', - 'post_parent' => $parent_id, - 'name' => $parent_id . '-autosave-v1', - 'author__in' => $request['author'], - 'orderby' => array( - 'date' => 'DESC', - 'ID' => 'DESC', - ), - 'posts_per_page' => -1, - 'suppress_filters' => false, - ) - ); + $query_args = array( + 'post_type' => 'revision', + 'post_status' => 'inherit', + 'post_parent' => $parent_id, + // Unlike `name`, this does not make the query singular. + 'post_name__in' => array( $parent_id . '-autosave-v1' ), + 'orderby' => array( + 'date' => 'DESC', + 'ID' => 'DESC', + ), + 'posts_per_page' => -1, + // Matches the isolation of the wp_get_post_revisions() lookup this replaces. + 'suppress_filters' => true, + ); - // Each author has at most one autosave; keep only the newest if not. - $seen_authors = array(); + if ( ! empty( $request['author'] ) ) { + $query_args['author__in'] = $request['author']; + } - foreach ( $autosaves as $autosave ) { - if ( in_array( $autosave->post_author, $seen_authors, true ) ) { - continue; - } + $autosaves = get_posts( $query_args ); - $seen_authors[] = $autosave->post_author; + // Each author has at most one autosave; keep only the newest if not. + $seen_authors = array(); - $data = $this->prepare_item_for_response( $autosave, $request ); - $response[] = $this->prepare_response_for_collection( $data ); + foreach ( $autosaves as $autosave ) { + if ( in_array( $autosave->post_author, $seen_authors, true ) ) { + continue; } - return rest_ensure_response( $response ); - } - - $revisions = wp_get_post_revisions( $parent_id, array( 'check_enabled' => false ) ); + $seen_authors[] = $autosave->post_author; - foreach ( $revisions as $revision ) { - if ( str_contains( $revision->post_name, "{$parent_id}-autosave" ) ) { - $data = $this->prepare_item_for_response( $revision, $request ); - $response[] = $this->prepare_response_for_collection( $data ); - } + $data = $this->prepare_item_for_response( $autosave, $request ); + $response[] = $this->prepare_response_for_collection( $data ); } return rest_ensure_response( $response ); @@ -544,6 +538,7 @@ public function prepare_item_for_response( $item, $request ) { * Retrieves the query params for the autosaves collection. * * @since 5.0.0 + * @since 7.3.0 Added the `author` parameter. * * @return array Collection parameters. */ diff --git a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php index 0c4ddc8b30a0a..59c6128875151 100644 --- a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php +++ b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php @@ -1105,10 +1105,6 @@ public function test_get_items_for_an_author_with_no_autosave() { /** * Tests that revisions newer than the autosave do not displace it. * - * Narrowing the underlying revision query by count rather than by author - * returns whatever is most recent, which on a post with revision history - * is usually not an autosave at all. - * * @ticket 62057 */ public function test_get_items_scoped_to_one_author_past_newer_revisions() { @@ -1283,4 +1279,52 @@ public function data_get_items_with_several_autosaves_for_one_author() { 'same date, newer ID wins' => array( '2030-01-01 00:00:00', '2030-01-01 00:00:00', 1 ), ); } + + /** + * Tests that query filters do not apply to the autosaves lookup. + * + * The lookup previously went through get_posts(), which suppresses them. + * + * @ticket 62057 + * + * @dataProvider data_get_items_is_isolated_from_query_filters + * + * @param bool $scoped Whether to scope the request to the extra users. + */ + public function test_get_items_is_isolated_from_query_filters( $scoped ) { + $autosave_ids = $this->create_autosaves_for_extra_users(); + + $filter_calls = 0; + $filter = static function ( $clause ) use ( &$filter_calls ) { + ++$filter_calls; + return $clause . ' AND 1=0'; + }; + add_filter( 'posts_where', $filter ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + if ( $scoped ) { + $request->set_param( 'author', self::$extra_users ); + } + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 0, $filter_calls, 'The posts_where filter should not run.' ); + $this->assertSameSets( + array_values( $autosave_ids ), + array_intersect( wp_list_pluck( $response->get_data(), 'id' ), $autosave_ids ) + ); + } + + /** + * Data provider for test_get_items_is_isolated_from_query_filters(). + * + * @return array[] + */ + public function data_get_items_is_isolated_from_query_filters() { + return array( + 'unscoped' => array( false ), + 'author scoped' => array( true ), + ); + } } From 6135e4c6f812d85de9fad8af28021cc615c6601e Mon Sep 17 00:00:00 2001 From: Jake Spurlock Date: Tue, 20 Oct 2026 10:49:33 -0700 Subject: [PATCH 2/2] Tests: Address review feedback on the author scoped autosave tests. Add the parameter, return and data provider types suggested in review, type the REST response data and created IDs, and use array_first() for the newest revision. Assert that wp_update_post() returns the post ID and that the newest revision is the ordinary one, not an autosave. Also drop a blank line inside the per-author dedupe loop. See #62057. Co-Authored-By: Claude Opus 5.5 --- .../class-wp-rest-autosaves-controller.php | 1 - .../rest-api/rest-autosaves-controller.php | 77 ++++++++++++------- .../wpRestTemplateAutosavesController.php | 3 +- 3 files changed, 53 insertions(+), 28 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index e5c4f9725aef5..b1b36430f8329 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -356,7 +356,6 @@ public function get_items( $request ) { if ( in_array( $autosave->post_author, $seen_authors, true ) ) { continue; } - $seen_authors[] = $autosave->post_author; $data = $this->prepare_item_for_response( $autosave, $request ); diff --git a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php index 59c6128875151..86cdb30da902d 100644 --- a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php +++ b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php @@ -24,6 +24,10 @@ class WP_Test_REST_Autosaves_Controller extends WP_Test_REST_Post_Type_Controlle protected static int $child_draft_page_id; private WP_Post $post_autosave; + + /** + * @var int[] + */ protected static array $extra_users; protected function set_post_data( $args = array() ) { @@ -981,20 +985,22 @@ public static function data_head_request_with_specified_fields_returns_success_r /** * Creates an autosave on the shared post for each of the extra users. * - * @return int[] Author id keyed to the autosave id created for them. + * @return array Author id keyed to the autosave id created for them. */ - private function create_autosaves_for_extra_users() { + private function create_autosaves_for_extra_users(): array { $autosave_ids = array(); foreach ( self::$extra_users as $i => $user_id ) { wp_set_current_user( $user_id ); - $autosave_ids[ $user_id ] = wp_create_post_autosave( + $autosave_id = wp_create_post_autosave( array( 'post_content' => "Autosave content {$i}", 'post_ID' => self::$post_id, 'post_type' => 'post', ) ); + $this->assertIsInt( $autosave_id ); + $autosave_ids[ $user_id ] = $autosave_id; } return $autosave_ids; @@ -1007,7 +1013,7 @@ private function create_autosaves_for_extra_users() { * @param int $post_id Post ID. * @param string $date Date in MySQL format. */ - private function set_post_date( $post_id, $date ) { + private function set_post_date( int $post_id, string $date ): void { global $wpdb; $wpdb->update( @@ -1032,9 +1038,11 @@ public function test_get_items_returns_every_author_by_default() { wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); $response = rest_get_server()->dispatch( $request ); + /** @var array> $data */ + $data = $response->get_data(); $this->assertSame( 200, $response->get_status() ); - $this->assertCount( count( $autosave_ids ) + 1, $response->get_data(), 'The extra users\' autosaves and the one created in set_up should all be returned.' ); + $this->assertCount( count( $autosave_ids ) + 1, $data, 'The extra users\' autosaves and the one created in set_up should all be returned.' ); } /** @@ -1053,7 +1061,8 @@ public function test_get_items_scoped_to_one_author() { $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); $request->set_param( 'author', $author_id ); $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); + /** @var array> $data */ + $data = $response->get_data(); $this->assertSame( 200, $response->get_status() ); $this->assertCount( 1, $data ); @@ -1074,7 +1083,8 @@ public function test_get_items_scoped_to_several_authors() { $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); $request->set_param( 'author', $author_ids ); $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); + /** @var array> $data */ + $data = $response->get_data(); $this->assertSame( 200, $response->get_status() ); $this->assertCount( 2, $data ); @@ -1112,20 +1122,25 @@ public function test_get_items_scoped_to_one_author_past_newer_revisions() { $author_id = self::$extra_users[0]; wp_set_current_user( self::$editor_id ); - wp_update_post( + $updated = wp_update_post( array( 'ID' => self::$post_id, 'post_content' => 'A revision made after every autosave.', ) ); + $this->assertSame( self::$post_id, $updated ); $revisions = wp_get_post_revisions( self::$post_id, array( 'check_enabled' => false ) ); - $this->set_post_date( reset( $revisions )->ID, '2030-01-01 00:00:00' ); + $revision = array_first( $revisions ); + $this->assertInstanceOf( WP_Post::class, $revision ); + $this->assertFalse( wp_is_post_autosave( $revision ), 'The newest revision should be the ordinary one just created.' ); + $this->set_post_date( $revision->ID, '2030-01-01 00:00:00' ); $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); $request->set_param( 'author', $author_id ); $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); + /** @var array> $data */ + $data = $response->get_data(); $this->assertSame( 200, $response->get_status() ); $this->assertCount( 1, $data ); @@ -1155,9 +1170,9 @@ public function test_get_items_rejects_invalid_author( $author ) { /** * Data provider for test_get_items_rejects_invalid_author(). * - * @return array[] + * @return array> */ - public function data_get_items_rejects_invalid_author() { + public function data_get_items_rejects_invalid_author(): array { return array( 'zero' => array( 0 ), 'negative' => array( -1 ), @@ -1179,7 +1194,8 @@ public function test_get_items_with_repeated_author() { $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); $request->set_param( 'author', array( $author_id, $author_id ) ); $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); + /** @var array> $data */ + $data = $response->get_data(); $this->assertSame( 200, $response->get_status() ); $this->assertCount( 1, $data ); @@ -1202,15 +1218,19 @@ public function test_get_items_scoped_to_authors_matches_unscoped_order() { wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); $response = rest_get_server()->dispatch( $request ); + /** @var array> $data */ + $data = $response->get_data(); $expected = array_values( - array_intersect( wp_list_pluck( $response->get_data(), 'id' ), $autosave_ids ) + array_intersect( array_map( 'intval', wp_list_pluck( $data, 'id' ) ), $autosave_ids ) ); $request->set_param( 'author', self::$extra_users ); $response = rest_get_server()->dispatch( $request ); + /** @var array> $data */ + $data = $response->get_data(); $this->assertSame( 200, $response->get_status() ); - $this->assertSame( $expected, wp_list_pluck( $response->get_data(), 'id' ) ); + $this->assertSame( $expected, wp_list_pluck( $data, 'id' ) ); $this->assertSame( array( $autosave_ids[0], $autosave_ids[2], $autosave_ids[1] ), $expected, @@ -1225,11 +1245,11 @@ public function test_get_items_scoped_to_authors_matches_unscoped_order() { * * @dataProvider data_get_items_with_several_autosaves_for_one_author * - * @param string $first_date Date of the autosave created first. - * @param string $second_date Date of the autosave created second. - * @param int $expected Index of the autosave expected to be returned. + * @param non-falsy-string $first_date Date of the autosave created first. + * @param non-falsy-string $second_date Date of the autosave created second. + * @param non-negative-int $expected Index of the autosave expected to be returned. */ - public function test_get_items_with_several_autosaves_for_one_author( $first_date, $second_date, $expected ) { + public function test_get_items_with_several_autosaves_for_one_author( string $first_date, string $second_date, int $expected ) { $author_id = self::$extra_users[0]; wp_set_current_user( $author_id ); @@ -1253,6 +1273,8 @@ public function test_get_items_with_several_autosaves_for_one_author( $first_dat ) ), ); + $this->assertIsInt( $autosave_ids[0] ); + $this->assertIsInt( $autosave_ids[1] ); $this->set_post_date( $autosave_ids[0], $first_date ); $this->set_post_date( $autosave_ids[1], $second_date ); @@ -1261,7 +1283,8 @@ public function test_get_items_with_several_autosaves_for_one_author( $first_dat $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); $request->set_param( 'author', $author_id ); $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); + /** @var array> $data */ + $data = $response->get_data(); $this->assertSame( 200, $response->get_status() ); $this->assertCount( 1, $data ); @@ -1271,9 +1294,9 @@ public function test_get_items_with_several_autosaves_for_one_author( $first_dat /** * Data provider for test_get_items_with_several_autosaves_for_one_author(). * - * @return array[] + * @return array */ - public function data_get_items_with_several_autosaves_for_one_author() { + public function data_get_items_with_several_autosaves_for_one_author(): array { return array( 'older ID has the newer date' => array( '2030-01-02 00:00:00', '2030-01-01 00:00:00', 0 ), 'same date, newer ID wins' => array( '2030-01-01 00:00:00', '2030-01-01 00:00:00', 1 ), @@ -1291,7 +1314,7 @@ public function data_get_items_with_several_autosaves_for_one_author() { * * @param bool $scoped Whether to scope the request to the extra users. */ - public function test_get_items_is_isolated_from_query_filters( $scoped ) { + public function test_get_items_is_isolated_from_query_filters( bool $scoped ) { $autosave_ids = $this->create_autosaves_for_extra_users(); $filter_calls = 0; @@ -1307,21 +1330,23 @@ public function test_get_items_is_isolated_from_query_filters( $scoped ) { $request->set_param( 'author', self::$extra_users ); } $response = rest_get_server()->dispatch( $request ); + /** @var array> $data */ + $data = $response->get_data(); $this->assertSame( 200, $response->get_status() ); $this->assertSame( 0, $filter_calls, 'The posts_where filter should not run.' ); $this->assertSameSets( array_values( $autosave_ids ), - array_intersect( wp_list_pluck( $response->get_data(), 'id' ), $autosave_ids ) + array_intersect( array_map( 'intval', wp_list_pluck( $data, 'id' ) ), $autosave_ids ) ); } /** * Data provider for test_get_items_is_isolated_from_query_filters(). * - * @return array[] + * @return array */ - public function data_get_items_is_isolated_from_query_filters() { + public function data_get_items_is_isolated_from_query_filters(): array { return array( 'unscoped' => array( false ), 'author scoped' => array( true ), diff --git a/tests/phpunit/tests/rest-api/wpRestTemplateAutosavesController.php b/tests/phpunit/tests/rest-api/wpRestTemplateAutosavesController.php index 88cd7f20be516..184143575b546 100644 --- a/tests/phpunit/tests/rest-api/wpRestTemplateAutosavesController.php +++ b/tests/phpunit/tests/rest-api/wpRestTemplateAutosavesController.php @@ -914,7 +914,8 @@ public function test_get_items_scoped_to_author() { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/templates/' . self::TEST_THEME . '//' . self::TEMPLATE_NAME . '/autosaves' ); $request->set_param( 'author', $other_admin_id ); - $response = rest_get_server()->dispatch( $request ); + $response = rest_get_server()->dispatch( $request ); + /** @var array> $autosaves */ $autosaves = $response->get_data(); $this->assertSame( WP_Http::OK, $response->get_status(), 'Response is expected to have a status code of 200.' );