From 3e0970c75321f745f618d40ecf9655625acc1285 Mon Sep 17 00:00:00 2001 From: mpeshev Date: Sun, 25 Oct 2026 13:01:18 +0200 Subject: [PATCH 1/5] REST API: Avoid fetching each template again to build its links. `WP_REST_Templates_Controller::prepare_links()` receives only the template ID. When the post type supports revisions, it called `get_block_template()` for every item to read the template's post ID. For each template in `/wp/v2/templates` and `/wp/v2/template-parts` responses, that ran another `WP_Query` and, for theme file templates, read the file again and re-applied Block Hooks, only to discard the result. Reuse the template that `prepare_item_for_response()` is already preparing. The signature of `prepare_links()` is unchanged, so subclasses that override it keep working. Co-Authored-By: Claude Opus 5.5 --- .../class-wp-rest-templates-controller.php | 19 ++++++- .../rest-api/wpRestTemplatesController.php | 52 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php index b2429f1f482cb..a3b8cec03244e 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php @@ -24,6 +24,14 @@ class WP_REST_Templates_Controller extends WP_REST_Controller { */ protected $post_type; + /** + * The template being prepared for the response, so prepare_links() can reuse it. + * + * @since 7.3.0 + * @var WP_Block_Template|null + */ + private $prepared_template = null; + /** * Constructor. * @@ -832,7 +840,9 @@ public function prepare_item_for_response( $item, $request ) { $response = rest_ensure_response( $data ); if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { - $links = $this->prepare_links( $template->id ); + $this->prepared_template = $template; + $links = $this->prepare_links( $template->id ); + $this->prepared_template = null; $response->add_links( $links ); if ( ! empty( $links['self']['href'] ) ) { $actions = $this->get_available_actions(); @@ -984,7 +994,12 @@ protected function prepare_links( $id ) { ); if ( post_type_supports( $this->post_type, 'revisions' ) ) { - $template = get_block_template( $id, $this->post_type ); + // Reuse the template being prepared instead of querying it again for each item. + if ( $this->prepared_template instanceof WP_Block_Template && $this->prepared_template->id === $id ) { + $template = $this->prepared_template; + } else { + $template = get_block_template( $id, $this->post_type ); + } if ( $template instanceof WP_Block_Template && ! empty( $template->wp_id ) ) { $revisions = wp_get_latest_revision_id_and_total_count( $template->wp_id ); $revisions_count = ! is_wp_error( $revisions ) ? $revisions['count'] : 0; diff --git a/tests/phpunit/tests/rest-api/wpRestTemplatesController.php b/tests/phpunit/tests/rest-api/wpRestTemplatesController.php index 2403e1f3115f3..14e35512e89b8 100644 --- a/tests/phpunit/tests/rest-api/wpRestTemplatesController.php +++ b/tests/phpunit/tests/rest-api/wpRestTemplatesController.php @@ -1247,4 +1247,56 @@ public function test_prepare_item_for_database_injects_hooked_block() { 'The hooked block was not injected into the anchor block\'s ignoredHookedBlocks metadata.' ); } + + /** + * Tests that preparing a collection does not fetch each template again to build its links. + * + * @ticket 00000 + * + * @covers WP_REST_Templates_Controller::prepare_links + */ + public function test_get_items_should_not_refetch_each_template_for_links() { + wp_set_current_user( self::$admin_id ); + + $refetched = 0; + $counter = static function ( $template ) use ( &$refetched ) { + ++$refetched; + return $template; + }; + add_filter( 'pre_get_block_template', $counter ); + $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/templates' ) ); + remove_filter( 'pre_get_block_template', $counter ); + + $this->assertSame( 200, $response->get_status(), 'The request should succeed.' ); + $this->assertSame( 0, $refetched, 'Templates should not be fetched again to prepare their links.' ); + } + + /** + * Tests that the version history link of a customized template is kept when preparing a collection. + * + * @ticket 00000 + * + * @covers WP_REST_Templates_Controller::prepare_links + */ + public function test_get_items_should_include_version_history_link_for_customized_template() { + wp_set_current_user( self::$admin_id ); + + wp_update_post( + array( + 'ID' => self::$template_post->ID, + 'post_content' => 'Content for a revision.', + ) + ); + $revisions = wp_get_latest_revision_id_and_total_count( self::$template_post->ID ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/templates' ); + $data = rest_get_server()->response_to_data( rest_get_server()->dispatch( $request ), false ); + $item = $this->find_and_normalize_template_by_id( $data, 'default//my_template' ); + $found = wp_list_filter( $data, array( 'id' => 'default//my_template' ) ); + $links = reset( $found )['_links']; + + $this->assertNotEmpty( $item, 'The customized template should be in the collection.' ); + $this->assertSame( $revisions['count'], $links['version-history'][0]['count'], 'The version history count should match the revisions of the template.' ); + $this->assertSame( $revisions['latest_id'], $links['predecessor-version'][0]['id'], 'The predecessor version should be the latest revision.' ); + } } From 35388d4a220b92e834c162cea7e49c6da8378651 Mon Sep 17 00:00:00 2001 From: mpeshev Date: Sun, 25 Oct 2026 20:05:11 +0200 Subject: [PATCH 2/5] Tests: Reference #66381 in the new tests. See #66381. Co-Authored-By: Claude Opus 5.5 --- tests/phpunit/tests/rest-api/wpRestTemplatesController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestTemplatesController.php b/tests/phpunit/tests/rest-api/wpRestTemplatesController.php index 14e35512e89b8..95aea129049ea 100644 --- a/tests/phpunit/tests/rest-api/wpRestTemplatesController.php +++ b/tests/phpunit/tests/rest-api/wpRestTemplatesController.php @@ -1251,7 +1251,7 @@ public function test_prepare_item_for_database_injects_hooked_block() { /** * Tests that preparing a collection does not fetch each template again to build its links. * - * @ticket 00000 + * @ticket 66381 * * @covers WP_REST_Templates_Controller::prepare_links */ @@ -1274,7 +1274,7 @@ public function test_get_items_should_not_refetch_each_template_for_links() { /** * Tests that the version history link of a customized template is kept when preparing a collection. * - * @ticket 00000 + * @ticket 66381 * * @covers WP_REST_Templates_Controller::prepare_links */ From 62ef5d1dd3c393e1aa1b6c60abd3ef3df8425ca5 Mon Sep 17 00:00:00 2001 From: mpeshev Date: Sun, 25 Oct 2026 22:40:22 +0200 Subject: [PATCH 3/5] REST API: Type the prepared template property and note when the lookup fallback runs. Use a typed `?WP_Block_Template` property instead of an `@var` tag, as suggested in review, and say that the `get_block_template()` fallback in `prepare_links()` is only reached when a subclass calls `prepare_links()` outside of `prepare_item_for_response()`. See #66381. Co-Authored-By: Claude Opus 5.5 --- .../rest-api/endpoints/class-wp-rest-templates-controller.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php index a3b8cec03244e..6bf0fc8e1d427 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php @@ -28,9 +28,8 @@ class WP_REST_Templates_Controller extends WP_REST_Controller { * The template being prepared for the response, so prepare_links() can reuse it. * * @since 7.3.0 - * @var WP_Block_Template|null */ - private $prepared_template = null; + private ?WP_Block_Template $prepared_template = null; /** * Constructor. @@ -998,6 +997,7 @@ protected function prepare_links( $id ) { if ( $this->prepared_template instanceof WP_Block_Template && $this->prepared_template->id === $id ) { $template = $this->prepared_template; } else { + // Only reached when a subclass calls prepare_links() outside of prepare_item_for_response(). $template = get_block_template( $id, $this->post_type ); } if ( $template instanceof WP_Block_Template && ! empty( $template->wp_id ) ) { From 02a6d2d5baf6adf5ed48785d54ccc01fc93002b1 Mon Sep 17 00:00:00 2001 From: Mario Peshev Date: Mon, 26 Oct 2026 00:49:04 +0200 Subject: [PATCH 4/5] Apply suggestion from @westonruter Co-authored-by: Weston Ruter --- .../rest-api/endpoints/class-wp-rest-templates-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php index 6bf0fc8e1d427..d01bcfc69bbec 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php @@ -27,7 +27,7 @@ class WP_REST_Templates_Controller extends WP_REST_Controller { /** * The template being prepared for the response, so prepare_links() can reuse it. * - * @since 7.3.0 + * @since 7.2.0 */ private ?WP_Block_Template $prepared_template = null; From 44f2c6b114feee553951c4ea09c42107fd8880c3 Mon Sep 17 00:00:00 2001 From: mpeshev Date: Mon, 26 Oct 2026 01:22:02 +0200 Subject: [PATCH 5/5] Tests: Apply review suggestions to the template link tests. Count `pre_get_block_template` calls with `MockAction`, assert that the template update succeeds before checking its revision links, and read the found template with `array_first()`. The update assertion passes `true` as the `$wp_error` argument of `wp_update_post()`, so a failed update returns a `WP_Error` and fails `assertIsInt()` instead of returning 0. See #66381. Co-Authored-By: Claude Opus 5.5 --- .../rest-api/wpRestTemplatesController.php | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestTemplatesController.php b/tests/phpunit/tests/rest-api/wpRestTemplatesController.php index 95aea129049ea..d098bc9c0e321 100644 --- a/tests/phpunit/tests/rest-api/wpRestTemplatesController.php +++ b/tests/phpunit/tests/rest-api/wpRestTemplatesController.php @@ -1258,17 +1258,12 @@ public function test_prepare_item_for_database_injects_hooked_block() { public function test_get_items_should_not_refetch_each_template_for_links() { wp_set_current_user( self::$admin_id ); - $refetched = 0; - $counter = static function ( $template ) use ( &$refetched ) { - ++$refetched; - return $template; - }; - add_filter( 'pre_get_block_template', $counter ); + $mock = new MockAction(); + add_filter( 'pre_get_block_template', array( $mock, 'filter' ) ); $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/templates' ) ); - remove_filter( 'pre_get_block_template', $counter ); $this->assertSame( 200, $response->get_status(), 'The request should succeed.' ); - $this->assertSame( 0, $refetched, 'Templates should not be fetched again to prepare their links.' ); + $this->assertSame( 0, $mock->get_call_count(), 'Templates should not be fetched again to prepare their links.' ); } /** @@ -1281,10 +1276,13 @@ public function test_get_items_should_not_refetch_each_template_for_links() { public function test_get_items_should_include_version_history_link_for_customized_template() { wp_set_current_user( self::$admin_id ); - wp_update_post( - array( - 'ID' => self::$template_post->ID, - 'post_content' => 'Content for a revision.', + $this->assertIsInt( + wp_update_post( + array( + 'ID' => self::$template_post->ID, + 'post_content' => 'Content for a revision.', + ), + true ) ); $revisions = wp_get_latest_revision_id_and_total_count( self::$template_post->ID ); @@ -1293,7 +1291,7 @@ public function test_get_items_should_include_version_history_link_for_customize $data = rest_get_server()->response_to_data( rest_get_server()->dispatch( $request ), false ); $item = $this->find_and_normalize_template_by_id( $data, 'default//my_template' ); $found = wp_list_filter( $data, array( 'id' => 'default//my_template' ) ); - $links = reset( $found )['_links']; + $links = array_first( $found )['_links']; $this->assertNotEmpty( $item, 'The customized template should be in the collection.' ); $this->assertSame( $revisions['count'], $links['version-history'][0]['count'], 'The version history count should match the revisions of the template.' );