Skip to content
Open
39 changes: 39 additions & 0 deletions src/wp-admin/options-connectors.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string|array{ 0: string, 1?: 'GET'|'OPTIONS', 2?: int<100, 599>|int<100, 599>[] }> $preload_paths Paths already queued for preloading.
* @return array<string|array{ 0: string, 1?: 'GET'|'OPTIONS', 2?: int<100, 599>|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', <basename> ) 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.
Expand Down
2 changes: 1 addition & 1 deletion src/wp-includes/class-wp-http-response.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class WP_HTTP_Response {
* Response headers.
*
* @since 4.4.0
* @var array
* @var array<string, string>
*/
public $headers;

Expand Down
47 changes: 35 additions & 12 deletions src/wp-includes/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{ body: array<mixed>, headers: array<string, string> } | array<string, array{ body: array<mixed>, headers: array<string, string> }> > $memo
* @phpstan-param string|array{ 0: string, 1?: 'GET'|'OPTIONS', 2?: int<100, 599>|int<100, 599>[] } $path
* @phpstan-return array<string, array{ body: array<mixed>, headers: array<string, string> } | array<string, array{ body: array<mixed>, headers: array<string, string> }> >
*/
function rest_preload_api_request( $memo, $path ) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change looks good, but getting it very close to the end of the release seems a little riskier it may be preferable to merge right after or for now just preload non 404 requests?

/*
Expand All @@ -2947,14 +2951,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).
Expand All @@ -2964,11 +2979,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, '?' ) ?
Expand All @@ -2983,12 +2998,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 );
Comment thread
westonruter marked this conversation as resolved.

if ( 'OPTIONS' === $method ) {
$memo[ $method ][ $path ] = array(
Expand Down
25 changes: 23 additions & 2 deletions tests/phpunit/tests/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
);

Expand All @@ -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;
}

Expand Down
Loading