From 0065b1654e9a157d22f7df189af9aae15b20a513 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 May 2026 15:11:23 -0700 Subject: [PATCH 01/10] Add preload paths filter introduced in https://github.com/WordPress/gutenberg/pull/77353 --- .../build/pages/options-connectors/page-wp-admin.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/wp-includes/build/pages/options-connectors/page-wp-admin.php b/src/wp-includes/build/pages/options-connectors/page-wp-admin.php index 3f3048b8fb98b..c3031a9c5b83d 100644 --- a/src/wp-includes/build/pages/options-connectors/page-wp-admin.php +++ b/src/wp-includes/build/pages/options-connectors/page-wp-admin.php @@ -93,6 +93,18 @@ function wp_options_connectors_wp_admin_preload_data() { array( '/wp/v2/settings', 'OPTIONS' ), ); + /** + * Filters the REST API paths preloaded for this page. + * + * Each entry is either a string path (GET) or a `[ path, method ]` tuple. + * Pages that know which requests their JS will issue on mount can add + * them here so the responses are embedded in the initial HTML rather + * than fetched over the network after hydration. + * + * @param string[] $preload_paths Paths to preload. + */ + $preload_paths = apply_filters( 'options-connectors-wp-admin_preload_paths', $preload_paths ); + // Use rest_preload_api_request to gather the preloaded data $preload_data = array_reduce( $preload_paths, From 1b0d88d6baaa50536e1e77737b1026616d58a33d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 May 2026 15:11:50 -0700 Subject: [PATCH 02/10] Preload REST API requests used on the Connectors screen --- src/wp-admin/options-connectors.php | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/wp-admin/options-connectors.php b/src/wp-admin/options-connectors.php index c67d857b2b60d..a04e3df58e094 100644 --- a/src/wp-admin/options-connectors.php +++ b/src/wp-admin/options-connectors.php @@ -32,6 +32,45 @@ // Set parent file for menu highlighting. $parent_file = 'options-general.php'; +/** + * Preloads the REST API responses the Connectors UI fetches on mount. + * + * Without this, the page does a network round-trip for site settings, + * plugin capability discovery, and each connector's plugin record after + * the JS hydrates, which noticeably delays first paint. + * + * @since 7.0.0 + * @access private + * + * @param string[] $preload_paths Paths already queued for preloading. + * @return string[] Paths with the Connectors-specific requests appended. + */ +function _wp_connectors_preload_paths( array $preload_paths ): array { + // getEntityRecord( 'root', 'site' ) in stage.tsx / use-connector-plugin.ts. + $preload_paths[] = '/wp/v2/settings'; + + // canUser( 'create', { kind: 'root', name: 'plugin' } ) in stage.tsx. + $preload_paths[] = array( '/wp/v2/plugins', 'OPTIONS' ); + + // AiPluginCallout in routes/connectors-home/ai-plugin-callout.tsx queries this + // hardcoded ID to check whether the WP AI plugin is installed/active. + $preload_paths[] = '/wp/v2/plugins/ai/ai?context=edit'; + + // getEntityRecord( 'root', 'plugin', ) per connector in use-connector-plugin.ts. + foreach ( wp_get_connectors() as $connector_data ) { + if ( empty( $connector_data['plugin']['file'] ) ) { + continue; + } + // core-data's plugin entity uses the basename with `.php` stripped + // as the record key (see routes/connectors-home/use-connector-plugin.ts). + $basename = preg_replace( '/\.php$/', '', plugin_basename( $connector_data['plugin']['file'] ) ); + $preload_paths[] = '/wp/v2/plugins/' . $basename . '?context=edit'; + } + + return $preload_paths; +} +add_filter( 'options-connectors-wp-admin_preload_paths', '_wp_connectors_preload_paths' ); + require_once ABSPATH . 'wp-admin/admin-header.php'; // Render the Connectors page. From 7f32ee46a732018c16f9bb6b69e82324300a24fd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 May 2026 15:56:35 -0700 Subject: [PATCH 03/10] Supply improved type for WP_HTTP_Response::$headers --- src/wp-includes/class-wp-http-response.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-http-response.php b/src/wp-includes/class-wp-http-response.php index d102877885b7c..23395053f91af 100644 --- a/src/wp-includes/class-wp-http-response.php +++ b/src/wp-includes/class-wp-http-response.php @@ -27,7 +27,7 @@ class WP_HTTP_Response { * Response headers. * * @since 4.4.0 - * @var array + * @var array */ public $headers; From 9b38307b4269fd2afa24e1e3908a18fdeaff0c2b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 May 2026 15:57:46 -0700 Subject: [PATCH 04/10] Harden rest_preload_api_request() and address PHPStan errors --- src/wp-includes/rest-api.php | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 5548ecf5c6f45..ff1416c1bf9a1 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -2930,9 +2930,13 @@ function rest_sanitize_value_from_schema( $value, $args, $param = '' ) { * * @since 5.0.0 * - * @param array $memo Reduce accumulator. - * @param string $path REST API path to preload. + * @param array $memo Reduce accumulator. + * @param string|array $path REST API path to preload. * @return array Modified reduce accumulator. + * + * @phpstan-param array, headers: array } | array, headers: array }> > $memo + * @phpstan-param string|array{ 0: string, 1?: 'GET'|'OPTIONS' } $path + * @phpstan-return array, headers: array } | array, headers: array }> > */ function rest_preload_api_request( $memo, $path ) { /* @@ -2948,10 +2952,13 @@ function rest_preload_api_request( $memo, $path ) { } $method = 'GET'; - if ( is_array( $path ) && 2 === count( $path ) ) { - $method = end( $path ); - $path = reset( $path ); - + if ( is_array( $path ) ) { + $path_array = $path; + $path = array_shift( $path_array ); + if ( ! is_string( $path ) ) { + return $memo; + } + $method = array_shift( $path_array ); if ( ! in_array( $method, array( 'GET', 'OPTIONS' ), true ) ) { $method = 'GET'; } @@ -2964,11 +2971,11 @@ function rest_preload_api_request( $memo, $path ) { } $path_parts = parse_url( $path ); - if ( false === $path_parts ) { + if ( false === $path_parts || ! isset( $path_parts['path'] ) ) { return $memo; } - if ( isset( $path_parts['path'] ) && '/' !== $path_parts['path'] ) { + if ( '/' !== $path_parts['path'] ) { // Remove trailing slashes from the "path" part of the REST API path. $path_parts['path'] = untrailingslashit( $path_parts['path'] ); $path = str_contains( $path, '?' ) ? @@ -2987,8 +2994,16 @@ function rest_preload_api_request( $memo, $path ) { $server = rest_get_server(); /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $server, $request ); - $embed = $request->has_param( '_embed' ) ? rest_parse_embed_param( $request['_embed'] ) : false; - $data = (array) $server->response_to_data( $response, $embed ); + if ( ! $response instanceof WP_REST_Response ) { + return $memo; + } + + if ( $request->has_param( '_embed' ) && ( is_array( $request['_embed'] ) || is_string( $request['_embed'] ) ) ) { + $embed = rest_parse_embed_param( $request['_embed'] ); + } else { + $embed = false; + } + $data = (array) $server->response_to_data( $response, $embed ); if ( 'OPTIONS' === $method ) { $memo[ $method ][ $path ] = array( From d4b1ec01135fe50df6ef13e7453bf04e7ec1e1c6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 May 2026 16:13:13 -0700 Subject: [PATCH 05/10] Allow preloading REST API responses with non-OK statuses --- src/wp-admin/options-connectors.php | 4 ++-- src/wp-includes/rest-api.php | 14 +++++++++++--- tests/phpunit/tests/rest-api.php | 25 +++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/wp-admin/options-connectors.php b/src/wp-admin/options-connectors.php index a04e3df58e094..39ddb6f42d19e 100644 --- a/src/wp-admin/options-connectors.php +++ b/src/wp-admin/options-connectors.php @@ -54,7 +54,7 @@ function _wp_connectors_preload_paths( array $preload_paths ): array { // AiPluginCallout in routes/connectors-home/ai-plugin-callout.tsx queries this // hardcoded ID to check whether the WP AI plugin is installed/active. - $preload_paths[] = '/wp/v2/plugins/ai/ai?context=edit'; + $preload_paths[] = array( '/wp/v2/plugins/ai/ai?context=edit', 'GET', array( 200, 404 ) ); // getEntityRecord( 'root', 'plugin', ) per connector in use-connector-plugin.ts. foreach ( wp_get_connectors() as $connector_data ) { @@ -64,7 +64,7 @@ function _wp_connectors_preload_paths( array $preload_paths ): array { // core-data's plugin entity uses the basename with `.php` stripped // as the record key (see routes/connectors-home/use-connector-plugin.ts). $basename = preg_replace( '/\.php$/', '', plugin_basename( $connector_data['plugin']['file'] ) ); - $preload_paths[] = '/wp/v2/plugins/' . $basename . '?context=edit'; + $preload_paths[] = array( '/wp/v2/plugins/' . $basename . '?context=edit', 'GET', array( 200, 404 ) ); } return $preload_paths; diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index ff1416c1bf9a1..702134458ff86 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -2935,7 +2935,7 @@ function rest_sanitize_value_from_schema( $value, $args, $param = '' ) { * @return array Modified reduce accumulator. * * @phpstan-param array, headers: array } | array, headers: array }> > $memo - * @phpstan-param string|array{ 0: string, 1?: 'GET'|'OPTIONS' } $path + * @phpstan-param string|array{ 0: string, 1?: 'GET'|'OPTIONS', 2?: int<100, 599>|int<100, 599>[] } $path * @phpstan-return array, headers: array } | array, headers: array }> > */ function rest_preload_api_request( $memo, $path ) { @@ -2951,7 +2951,8 @@ function rest_preload_api_request( $memo, $path ) { return $memo; } - $method = 'GET'; + $method = 'GET'; + $allowed_statuses = array( 200 ); if ( is_array( $path ) ) { $path_array = $path; $path = array_shift( $path_array ); @@ -2962,6 +2963,13 @@ function rest_preload_api_request( $memo, $path ) { if ( ! in_array( $method, array( 'GET', 'OPTIONS' ), true ) ) { $method = 'GET'; } + $statuses = array_shift( $path_array ); + if ( $statuses ) { + $statuses = array_filter( (array) $statuses, 'is_int' ); + if ( count( $statuses ) > 0 ) { + $allowed_statuses = $statuses; + } + } } // Remove trailing slashes at the end of the REST API path (query part). @@ -2990,7 +2998,7 @@ function rest_preload_api_request( $memo, $path ) { } $response = rest_do_request( $request ); - if ( 200 === $response->status ) { + if ( in_array( $response->status, $allowed_statuses, true ) ) { $server = rest_get_server(); /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $server, $request ); diff --git a/tests/phpunit/tests/rest-api.php b/tests/phpunit/tests/rest-api.php index 90de3e13eecea..7c3e737b80f10 100644 --- a/tests/phpunit/tests/rest-api.php +++ b/tests/phpunit/tests/rest-api.php @@ -938,12 +938,24 @@ public function test_register_rest_route_without_server() { $this->assertSame( $routes['/test-ns/test'][0]['methods'], array( 'GET' => true ) ); } - public function test_rest_preload_api_request_with_method() { + /** + * @ticket 65215 + */ + public function test_rest_preload_api_request_with_method_and_allowed_statuses() { $rest_server = $GLOBALS['wp_rest_server']; $GLOBALS['wp_rest_server'] = null; + $exiting_post_id = self::factory()->post->create(); + $missing_post1_id = 10001; + $missing_post2_id = 10001; + $this->assertNull( get_post( $missing_post1_id ), "Expected post with ID $missing_post1_id to not exist." ); + $this->assertNull( get_post( $missing_post2_id ), "Expected post with ID $missing_post1_id to not exist." ); + $preload_paths = array( '/wp/v2/types', + array( "/wp/v2/posts/$exiting_post_id", 'GET', array( 200 ) ), + array( "/wp/v2/posts/$missing_post1_id", 'GET', array( 200, 404 ) ), + array( "/wp/v2/posts/$missing_post2_id", 'GET' ), array( '/wp/v2/media', 'OPTIONS' ), ); @@ -953,9 +965,18 @@ public function test_rest_preload_api_request_with_method() { array() ); - $this->assertSame( array_keys( $preload_data ), array( '/wp/v2/types', 'OPTIONS' ) ); + $this->assertSame( array_keys( $preload_data ), array( '/wp/v2/types', "/wp/v2/posts/$exiting_post_id", "/wp/v2/posts/$missing_post1_id", 'OPTIONS' ) ); $this->assertArrayHasKey( '/wp/v2/media', $preload_data['OPTIONS'] ); + $existing_post_response_data = $preload_data[ "/wp/v2/posts/$exiting_post_id" ]; + $this->assertTrue( isset( $existing_post_response_data['body']['id'] ), 'Expected body.id to be exist.' ); + $this->assertSame( $exiting_post_id, $existing_post_response_data['body']['id'] ); + + $missing_post_response_data = $preload_data[ "/wp/v2/posts/$missing_post1_id" ]; + $this->assertTrue( isset( $missing_post_response_data['body']['code'], $missing_post_response_data['body']['data']['status'] ), 'Expected body.code and body.data.status to exist.' ); + $this->assertSame( 'rest_post_invalid_id', $missing_post_response_data['body']['code'] ); + $this->assertSame( 404, $missing_post_response_data['body']['data']['status'] ); + $GLOBALS['wp_rest_server'] = $rest_server; } From 03527068961ffdc556399efc4ec4b2bd6e3994c9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 May 2026 17:04:12 -0700 Subject: [PATCH 06/10] Update phpdoc for _wp_connectors_preload_paths() --- src/wp-admin/options-connectors.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/options-connectors.php b/src/wp-admin/options-connectors.php index 39ddb6f42d19e..5cb5ba60d78ad 100644 --- a/src/wp-admin/options-connectors.php +++ b/src/wp-admin/options-connectors.php @@ -42,8 +42,8 @@ * @since 7.0.0 * @access private * - * @param string[] $preload_paths Paths already queued for preloading. - * @return string[] Paths with the Connectors-specific requests appended. + * @param array|int<100, 599>[] }> $preload_paths Paths already queued for preloading. + * @return array|int<100, 599>[] }> Paths with the Connectors-specific requests appended. */ function _wp_connectors_preload_paths( array $preload_paths ): array { // getEntityRecord( 'root', 'site' ) in stage.tsx / use-connector-plugin.ts. From 35123b520a3d59371b809da1cc8e3d4948d2f63e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 May 2026 17:20:12 -0700 Subject: [PATCH 07/10] Revert "Add preload paths filter introduced in https://github.com/WordPress/gutenberg/pull/77353" This reverts commit 0065b1654e9a157d22f7df189af9aae15b20a513. --- .../build/pages/options-connectors/page-wp-admin.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/wp-includes/build/pages/options-connectors/page-wp-admin.php b/src/wp-includes/build/pages/options-connectors/page-wp-admin.php index c3031a9c5b83d..3f3048b8fb98b 100644 --- a/src/wp-includes/build/pages/options-connectors/page-wp-admin.php +++ b/src/wp-includes/build/pages/options-connectors/page-wp-admin.php @@ -93,18 +93,6 @@ function wp_options_connectors_wp_admin_preload_data() { array( '/wp/v2/settings', 'OPTIONS' ), ); - /** - * Filters the REST API paths preloaded for this page. - * - * Each entry is either a string path (GET) or a `[ path, method ]` tuple. - * Pages that know which requests their JS will issue on mount can add - * them here so the responses are embedded in the initial HTML rather - * than fetched over the network after hydration. - * - * @param string[] $preload_paths Paths to preload. - */ - $preload_paths = apply_filters( 'options-connectors-wp-admin_preload_paths', $preload_paths ); - // Use rest_preload_api_request to gather the preloaded data $preload_data = array_reduce( $preload_paths, From 416c45335e74f5ec87845f0ce065d61406db8d0a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 12 May 2026 09:48:12 -0700 Subject: [PATCH 08/10] Fix duplicated non-existing post ID Co-authored-by: Christoph Daum --- tests/phpunit/tests/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api.php b/tests/phpunit/tests/rest-api.php index 7c3e737b80f10..bbdae95006309 100644 --- a/tests/phpunit/tests/rest-api.php +++ b/tests/phpunit/tests/rest-api.php @@ -947,7 +947,7 @@ public function test_rest_preload_api_request_with_method_and_allowed_statuses() $exiting_post_id = self::factory()->post->create(); $missing_post1_id = 10001; - $missing_post2_id = 10001; + $missing_post2_id = 10002; $this->assertNull( get_post( $missing_post1_id ), "Expected post with ID $missing_post1_id to not exist." ); $this->assertNull( get_post( $missing_post2_id ), "Expected post with ID $missing_post1_id to not exist." ); From ad4c7e4a0d5c4cca40ef4f6f157cc5656f65e86b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 12 May 2026 09:48:56 -0700 Subject: [PATCH 09/10] Fix variable reference in assertion message --- tests/phpunit/tests/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api.php b/tests/phpunit/tests/rest-api.php index bbdae95006309..3107e09e72956 100644 --- a/tests/phpunit/tests/rest-api.php +++ b/tests/phpunit/tests/rest-api.php @@ -949,7 +949,7 @@ public function test_rest_preload_api_request_with_method_and_allowed_statuses() $missing_post1_id = 10001; $missing_post2_id = 10002; $this->assertNull( get_post( $missing_post1_id ), "Expected post with ID $missing_post1_id to not exist." ); - $this->assertNull( get_post( $missing_post2_id ), "Expected post with ID $missing_post1_id to not exist." ); + $this->assertNull( get_post( $missing_post2_id ), "Expected post with ID $missing_post2_id to not exist." ); $preload_paths = array( '/wp/v2/types', From e6131bccf11ea275e40019f7b489eae982bb4b1c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 08:57:20 -0700 Subject: [PATCH 10/10] Update since tag Co-authored-by: Aki Hamano <54422211+t-hamano@users.noreply.github.com> --- src/wp-admin/options-connectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/options-connectors.php b/src/wp-admin/options-connectors.php index 5cb5ba60d78ad..5d83afbef2ac8 100644 --- a/src/wp-admin/options-connectors.php +++ b/src/wp-admin/options-connectors.php @@ -39,7 +39,7 @@ * plugin capability discovery, and each connector's plugin record after * the JS hydrates, which noticeably delays first paint. * - * @since 7.0.0 + * @since 7.0.1 * @access private * * @param array|int<100, 599>[] }> $preload_paths Paths already queued for preloading.