diff --git a/src/wp-admin/options-connectors.php b/src/wp-admin/options-connectors.php index c67d857b2b60d..5cb5ba60d78ad 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 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. + $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[] = 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 ) { + 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[] = array( '/wp/v2/plugins/' . $basename . '?context=edit', 'GET', array( 200, 404 ) ); + } + + 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. 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; diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index a4c22e8f1cca1..3d0549967c5d2 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -2960,9 +2960,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', 2?: int<100, 599>|int<100, 599>[] } $path + * @phpstan-return array, headers: array } | array, headers: array }> > */ function rest_preload_api_request( $memo, $path ) { /* @@ -2977,14 +2981,25 @@ function rest_preload_api_request( $memo, $path ) { return $memo; } - $method = 'GET'; - if ( is_array( $path ) && 2 === count( $path ) ) { - $method = end( $path ); - $path = reset( $path ); - + $method = 'GET'; + $allowed_statuses = array( 200 ); + 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'; } + $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). @@ -2994,11 +3009,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, '?' ) ? @@ -3013,12 +3028,20 @@ 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 ); - $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( diff --git a/tests/phpunit/tests/rest-api.php b/tests/phpunit/tests/rest-api.php index fcb8e3da87f4c..964281756a566 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 = 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_post2_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; }