General: Add speculative loading support via the Speculation Rules API.

This changeset adds support for the Speculation Rules API and configures it by default to `prefetch` certain links with an eagerness of `conservative`, leading to improved performance by starting to load URLs before the user lands on them.

The new `WP_Speculation_Rules` class is a container class representing the set of used speculation rules. By default, WordPress Core will only add a single speculation rule, which results in most links being prefetched conservatively.

The behavior of that main speculation rule can be altered by using the new `wp_speculation_rules_configuration` filter, which receives an associative array with `mode` and `eagerness` keys, or `null`. Both `mode` and `eagerness` have a default value of `auto`, which for now will result in the aforementioned behavior. The value `null` is used by default in certain scenarios such as when the current user is logged in. Developers can explicitly provide supported mode values (`prefetch` or `prerender`) and other supported eagerness values (`conservative`, `moderate`, or `eager`) to override and enforce the respective behaviors, or return `null` to disable speculative loading feature (either unconditionally or for certain situations). The Speculative Loading feature plugin for example, which this feature is based on, will make use of this filter to continue to use mode `prerender` and eagerness `moderate` by default. Developers can call the `wp_get_speculation_rules_configuration()` function to check how speculative loading is configured on the WordPress site.

Another important filter introduced is `wp_speculation_rules_href_exclude_paths`, which allows to expand the list of URL patterns that are excluded from being prefetched or prerendered per WordPress Core's main speculation rule configuration. Several URL patterns such `/wp-admin/*` (any URL within WP Admin) or `/*\\?(.+)` (any URL that includes query parameters) are already excluded by default. Plugins that use content that would be preferable not to prefetch or prerender can use the filter to provide corresponding URL patterns.

More advanced customization is possible by adding further speculation rules that will be loaded in addition to WordPress Core's main speculation rule. This can be achieved via the new `wp_load_speculation_rules` action, which receives the `WP_Speculation_Rules` class instance and can amend it as needed.

Props flixos90, westonruter, joemcgill, desrosj, mukesh27, tunetheweb, thelovekesh, adamsilverstein, swissspidy, domenicdenicola, jeremyroman.
Fixes #62503.

Built from https://develop.svn.wordpress.org/trunk@59837


git-svn-id: http://core.svn.wordpress.org/trunk@59179 1a063a9b-81f0-0310-95a4-ce76da25c4cd
This commit is contained in:
Felix Arntz 2025-02-18 22:32:22 +00:00
parent bbb8734811
commit 7529466125
6 changed files with 687 additions and 1 deletions

View File

@ -0,0 +1,293 @@
<?php
/**
* Class 'WP_Speculation_Rules'.
*
* @package WordPress
* @subpackage Speculative Loading
* @since 6.8.0
*/
/**
* Class representing a set of speculation rules.
*
* @since 6.8.0
* @access private
*/
final class WP_Speculation_Rules implements JsonSerializable {
/**
* Stored rules, as a map of `$mode => $rules` pairs.
*
* Every `$rules` value is a map of `$id => $rule` pairs.
*
* @since 6.8.0
* @var array<string, array<string, mixed>>
*/
private $rules_by_mode = array();
/**
* The allowed speculation rules modes as a map, used for validation.
*
* @since 6.8.0
* @var array<string, bool>
*/
private static $mode_allowlist = array(
'prefetch' => true,
'prerender' => true,
);
/**
* The allowed speculation rules eagerness levels as a map, used for validation.
*
* @since 6.8.0
* @var array<string, bool>
*/
private static $eagerness_allowlist = array(
'immediate' => true,
'eager' => true,
'moderate' => true,
'conservative' => true,
);
/**
* The allowed speculation rules sources as a map, used for validation.
*
* @since 6.8.0
* @var array<string, bool>
*/
private static $source_allowlist = array(
'list' => true,
'document' => true,
);
/**
* Adds a speculation rule to the speculation rules to consider.
*
* @since 6.8.0
*
* @param string $mode Speculative loading mode. Either 'prefetch' or 'prerender'.
* @param string $id Unique string identifier for the speculation rule.
* @param array<string, mixed> $rule Associative array of rule arguments.
* @return bool True on success, false if invalid parameters are provided.
*/
public function add_rule( string $mode, string $id, array $rule ): bool {
if ( ! self::is_valid_mode( $mode ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: %s: invalid mode value */
__( 'The value "%s" is not a valid speculation rules mode.' ),
esc_html( $mode )
),
'6.8.0'
);
return false;
}
if ( ! $this->is_valid_id( $id ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: %s: invalid ID value */
__( 'The value "%s" is not a valid ID for a speculation rule.' ),
esc_html( $id )
),
'6.8.0'
);
return false;
}
if ( $this->has_rule( $mode, $id ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: %s: invalid ID value */
__( 'A speculation rule with ID "%s" already exists.' ),
esc_html( $id )
),
'6.8.0'
);
return false;
}
/*
* Perform some basic speculation rule validation.
* Every rule must have either a 'where' key or a 'urls' key, but not both.
* The presence of a 'where' key implies a 'source' of 'document', while the presence of a 'urls' key implies
* a 'source' of 'list'.
*/
if (
( ! isset( $rule['where'] ) && ! isset( $rule['urls'] ) ) ||
( isset( $rule['where'] ) && isset( $rule['urls'] ) )
) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: 1: allowed key, 2: alternative allowed key */
__( 'A speculation rule must include either a "%1$s" key or a "%2$s" key, but not both.' ),
'where',
'urls'
),
'6.8.0'
);
return false;
}
if ( isset( $rule['source'] ) ) {
if ( ! self::is_valid_source( $rule['source'] ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: %s: invalid source value */
__( 'The value "%s" is not a valid source for a speculation rule.' ),
esc_html( $rule['source'] )
),
'6.8.0'
);
return false;
}
if ( 'list' === $rule['source'] && isset( $rule['where'] ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: 1: source value, 2: forbidden key */
__( 'A speculation rule of source "%1$s" must not include a "%2$s" key.' ),
'list',
'where'
),
'6.8.0'
);
return false;
}
if ( 'document' === $rule['source'] && isset( $rule['urls'] ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: 1: source value, 2: forbidden key */
__( 'A speculation rule of source "%1$s" must not include a "%2$s" key.' ),
'document',
'urls'
),
'6.8.0'
);
return false;
}
}
// If there is an 'eagerness' key specified, make sure it's valid.
if ( isset( $rule['eagerness'] ) ) {
if ( ! self::is_valid_eagerness( $rule['eagerness'] ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: %s: invalid eagerness value */
__( 'The value "%s" is not a valid eagerness for a speculation rule.' ),
esc_html( $rule['eagerness'] )
),
'6.8.0'
);
return false;
}
if ( isset( $rule['where'] ) && 'immediate' === $rule['eagerness'] ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: %s: forbidden eagerness value */
__( 'The eagerness value "%s" is forbidden for document-level speculation rules.' ),
'immediate'
),
'6.8.0'
);
return false;
}
}
if ( ! isset( $this->rules_by_mode[ $mode ] ) ) {
$this->rules_by_mode[ $mode ] = array();
}
$this->rules_by_mode[ $mode ][ $id ] = $rule;
return true;
}
/**
* Checks whether a speculation rule for the given mode and ID already exists.
*
* @since 6.8.0
*
* @param string $mode Speculative loading mode. Either 'prefetch' or 'prerender'.
* @param string $id Unique string identifier for the speculation rule.
* @return bool True if the rule already exists, false otherwise.
*/
public function has_rule( string $mode, string $id ): bool {
return isset( $this->rules_by_mode[ $mode ][ $id ] );
}
/**
* Returns the speculation rules data ready to be JSON-encoded.
*
* @since 6.8.0
*
* @return array<string, array<string, mixed>> Speculation rules data.
*/
#[ReturnTypeWillChange]
public function jsonSerialize() {
// Strip the IDs for JSON output, since they are not relevant for the Speculation Rules API.
return array_map(
static function ( array $rules ) {
return array_values( $rules );
},
array_filter( $this->rules_by_mode )
);
}
/**
* Checks whether the given ID is valid.
*
* @since 6.8.0
*
* @param string $id Unique string identifier for the speculation rule.
* @return bool True if the ID is valid, false otherwise.
*/
private function is_valid_id( string $id ): bool {
return (bool) preg_match( '/^[a-z][a-z0-9_-]+$/', $id );
}
/**
* Checks whether the given speculation rules mode is valid.
*
* @since 6.8.0
*
* @param string $mode Speculation rules mode.
* @return bool True if valid, false otherwise.
*/
public static function is_valid_mode( string $mode ): bool {
return isset( self::$mode_allowlist[ $mode ] );
}
/**
* Checks whether the given speculation rules eagerness is valid.
*
* @since 6.8.0
*
* @param string $eagerness Speculation rules eagerness.
* @return bool True if valid, false otherwise.
*/
public static function is_valid_eagerness( string $eagerness ): bool {
return isset( self::$eagerness_allowlist[ $eagerness ] );
}
/**
* Checks whether the given speculation rules source is valid.
*
* @since 6.8.0
*
* @param string $source Speculation rules source.
* @return bool True if valid, false otherwise.
*/
public static function is_valid_source( string $source ): bool {
return isset( self::$source_allowlist[ $source ] );
}
}

View File

@ -0,0 +1,135 @@
<?php
/**
* Class 'WP_URL_Pattern_Prefixer'.
*
* @package WordPress
* @subpackage Speculative Loading
* @since 6.8.0
*/
/**
* Class for prefixing URL patterns.
*
* This class is intended primarily for use as part of the speculative loading feature.
*
* @since 6.8.0
* @access private
*/
class WP_URL_Pattern_Prefixer {
/**
* Map of `$context_string => $base_path` pairs.
*
* @since 6.8.0
* @var array<string, string>
*/
private $contexts;
/**
* Constructor.
*
* @since 6.8.0
*
* @param array<string, string> $contexts Optional. Map of `$context_string => $base_path` pairs. Default is the
* contexts returned by the
* {@see WP_URL_Pattern_Prefixer::get_default_contexts()} method.
*/
public function __construct( array $contexts = array() ) {
if ( count( $contexts ) > 0 ) {
$this->contexts = array_map(
static function ( string $str ): string {
return self::escape_pattern_string( trailingslashit( $str ) );
},
$contexts
);
} else {
$this->contexts = self::get_default_contexts();
}
}
/**
* Prefixes the given URL path pattern with the base path for the given context.
*
* This ensures that these path patterns work correctly on WordPress subdirectory sites, for example in a multisite
* network, or when WordPress itself is installed in a subdirectory of the hostname.
*
* The given URL path pattern is only prefixed if it does not already include the expected prefix.
*
* @since 6.8.0
*
* @param string $path_pattern URL pattern starting with the path segment.
* @param string $context Optional. Context to use for prefixing the path pattern. Default 'home'.
* @return string URL pattern, prefixed as necessary.
*/
public function prefix_path_pattern( string $path_pattern, string $context = 'home' ): string {
// If context path does not exist, the context is invalid.
if ( ! isset( $this->contexts[ $context ] ) ) {
_doing_it_wrong(
__FUNCTION__,
esc_html(
sprintf(
/* translators: %s: context string */
__( 'Invalid URL pattern context %s.' ),
$context
)
),
'6.8.0'
);
return $path_pattern;
}
/*
* In the event that the context path contains a :, ? or # (which can cause the URL pattern parser to switch to
* another state, though only the latter two should be percent encoded anyway), it additionally needs to be
* enclosed in grouping braces. The final forward slash (trailingslashit ensures there is one) affects the
* meaning of the * wildcard, so is left outside the braces.
*/
$context_path = $this->contexts[ $context ];
$escaped_context_path = $context_path;
if ( strcspn( $context_path, ':?#' ) !== strlen( $context_path ) ) {
$escaped_context_path = '{' . substr( $context_path, 0, -1 ) . '}/';
}
/*
* If the path already starts with the context path (including '/'), remove it first
* since it is about to be added back.
*/
if ( str_starts_with( $path_pattern, $context_path ) ) {
$path_pattern = substr( $path_pattern, strlen( $context_path ) );
}
return $escaped_context_path . ltrim( $path_pattern, '/' );
}
/**
* Returns the default contexts used by the class.
*
* @since 6.8.0
*
* @return array<string, string> Map of `$context_string => $base_path` pairs.
*/
public static function get_default_contexts(): array {
return array(
'home' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ) ),
'site' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( site_url( '/' ), PHP_URL_PATH ) ) ),
'uploads' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( wp_upload_dir( null, false )['baseurl'], PHP_URL_PATH ) ) ),
'content' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( content_url(), PHP_URL_PATH ) ) ),
'plugins' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( plugins_url(), PHP_URL_PATH ) ) ),
'template' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( get_stylesheet_directory_uri(), PHP_URL_PATH ) ) ),
'stylesheet' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( get_template_directory_uri(), PHP_URL_PATH ) ) ),
);
}
/**
* Escapes a string for use in a URL pattern component.
*
* @since 6.8.0
* @see https://urlpattern.spec.whatwg.org/#escape-a-pattern-string
*
* @param string $str String to be escaped.
* @return string String with backslashes added where required.
*/
private static function escape_pattern_string( string $str ): string {
return addcslashes( $str, '+*?:{}()\\' );
}
}

View File

@ -358,6 +358,7 @@ add_action( 'wp_head', 'rel_canonical' );
add_action( 'wp_head', 'wp_shortlink_wp_head', 10, 0 );
add_action( 'wp_head', 'wp_custom_css_cb', 101 );
add_action( 'wp_head', 'wp_site_icon', 99 );
add_action( 'wp_footer', 'wp_print_speculation_rules' );
add_action( 'wp_footer', 'wp_print_footer_scripts', 20 );
add_action( 'template_redirect', 'wp_shortlink_header', 11, 0 );
add_action( 'wp_print_footer_scripts', '_wp_footer_scripts' );

View File

@ -0,0 +1,254 @@
<?php
/**
* Speculative loading functions.
*
* @package WordPress
* @subpackage Speculative Loading
* @since 6.8.0
*/
/**
* Returns the speculation rules configuration.
*
* @since 6.8.0
*
* @return array<string, string>|null Associative array with 'mode' and 'eagerness' keys, or null if speculative
* loading is disabled.
*/
function wp_get_speculation_rules_configuration(): ?array {
// By default, speculative loading is only enabled for sites with pretty permalinks when no user is logged in.
if ( ! is_user_logged_in() && get_option( 'permalink_structure' ) ) {
$config = array(
'mode' => 'auto',
'eagerness' => 'auto',
);
} else {
$config = null;
}
/**
* Filters the way that speculation rules are configured.
*
* The Speculation Rules API is a web API that allows to automatically prefetch or prerender certain URLs on the
* page, which can lead to near-instant page load times. This is also referred to as speculative loading.
*
* There are two aspects to the configuration:
* * The "mode" (whether to "prefetch" or "prerender" URLs).
* * The "eagerness" (whether to speculatively load URLs in an "eager", "moderate", or "conservative" way).
*
* By default, the speculation rules configuration is decided by WordPress Core ("auto"). This filter can be used
* to force a certain configuration, which could for instance load URLs more or less eagerly.
*
* For logged-in users or for sites that are not configured to use pretty permalinks, the default value is `null`,
* indicating that speculative loading is entirely disabled.
*
* @since 6.8.0
* @see https://developer.chrome.com/docs/web-platform/prerender-pages
*
* @param array<string, string>|null $config Associative array with 'mode' and 'eagerness' keys, or `null`. The
* default value for both of the keys is 'auto'. Other possible values
* for 'mode' are 'prefetch' and 'prerender'. Other possible values for
* 'eagerness' are 'eager', 'moderate', and 'conservative'. The value
* `null` is used to disable speculative loading entirely.
*/
$config = apply_filters( 'wp_speculation_rules_configuration', $config );
// Allow the value `null` to indicate that speculative loading is disabled.
if ( null === $config ) {
return null;
}
// Sanitize the configuration and replace 'auto' with current defaults.
$default_mode = 'prefetch';
$default_eagerness = 'conservative';
if ( ! is_array( $config ) ) {
return array(
'mode' => $default_mode,
'eagerness' => $default_eagerness,
);
}
if (
! isset( $config['mode'] ) ||
'auto' === $config['mode'] ||
! WP_Speculation_Rules::is_valid_mode( $config['mode'] )
) {
$config['mode'] = $default_mode;
}
if (
! isset( $config['eagerness'] ) ||
'auto' === $config['eagerness'] ||
! WP_Speculation_Rules::is_valid_eagerness( $config['eagerness'] ) ||
// 'immediate' is a valid eagerness, but for safety WordPress does not allow it for document-level rules.
'immediate' === $config['eagerness']
) {
$config['eagerness'] = $default_eagerness;
}
return array(
'mode' => $config['mode'],
'eagerness' => $config['eagerness'],
);
}
/**
* Returns the full speculation rules data based on the configuration.
*
* Plugins with features that rely on frontend URLs to exclude from prefetching or prerendering should use the
* {@see 'wp_speculation_rules_href_exclude_paths'} filter to ensure those URL patterns are excluded.
*
* Additional speculation rules other than the default rule from WordPress Core can be provided by using the
* {@see 'wp_load_speculation_rules'} action and amending the passed WP_Speculation_Rules object.
*
* @since 6.8.0
* @access private
*
* @return WP_Speculation_Rules|null Object representing the speculation rules to use, or null if speculative loading
* is disabled in the current context.
*/
function wp_get_speculation_rules(): ?WP_Speculation_Rules {
$configuration = wp_get_speculation_rules_configuration();
if ( null === $configuration ) {
return null;
}
$mode = $configuration['mode'];
$eagerness = $configuration['eagerness'];
$prefixer = new WP_URL_Pattern_Prefixer();
$base_href_exclude_paths = array(
$prefixer->prefix_path_pattern( '/wp-*.php', 'site' ),
$prefixer->prefix_path_pattern( '/wp-admin/*', 'site' ),
$prefixer->prefix_path_pattern( '/*', 'uploads' ),
$prefixer->prefix_path_pattern( '/*', 'content' ),
$prefixer->prefix_path_pattern( '/*', 'plugins' ),
$prefixer->prefix_path_pattern( '/*', 'template' ),
$prefixer->prefix_path_pattern( '/*', 'stylesheet' ),
);
/*
* If pretty permalinks are enabled, exclude any URLs with query parameters.
* Otherwise, exclude specifically the URLs with a `_wpnonce` query parameter or any other query parameter
* containing the word `nonce`.
*/
if ( get_option( 'permalink_structure' ) ) {
$base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?(.+)', 'home' );
} else {
$base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?*(^|&)*nonce*=*', 'home' );
}
/**
* Filters the paths for which speculative loading should be disabled.
*
* All paths should start in a forward slash, relative to the root document. The `*` can be used as a wildcard.
* If the WordPress site is in a subdirectory, the exclude paths will automatically be prefixed as necessary.
*
* Note that WordPress always excludes certain path patterns such as `/wp-login.php` and `/wp-admin/*`, and those
* cannot be modified using the filter.
*
* @since 6.8.0
*
* @param string[] $href_exclude_paths Additional path patterns to disable speculative loading for.
* @param string $mode Mode used to apply speculative loading. Either 'prefetch' or 'prerender'.
*/
$href_exclude_paths = (array) apply_filters( 'wp_speculation_rules_href_exclude_paths', array(), $mode );
// Ensure that:
// 1. There are no duplicates.
// 2. The base paths cannot be removed.
// 3. The array has sequential keys (i.e. array_is_list()).
$href_exclude_paths = array_values(
array_unique(
array_merge(
$base_href_exclude_paths,
array_map(
static function ( string $href_exclude_path ) use ( $prefixer ): string {
return $prefixer->prefix_path_pattern( $href_exclude_path );
},
$href_exclude_paths
)
)
)
);
$speculation_rules = new WP_Speculation_Rules();
$main_rule_conditions = array(
// Include any URLs within the same site.
array(
'href_matches' => $prefixer->prefix_path_pattern( '/*' ),
),
// Except for excluded paths.
array(
'not' => array(
'href_matches' => $href_exclude_paths,
),
),
// Also exclude rel=nofollow links, as certain plugins use that on their links that perform an action.
array(
'not' => array(
'selector_matches' => 'a[rel~="nofollow"]',
),
),
// Also exclude links that are explicitly marked to opt out.
array(
'not' => array(
'selector_matches' => ".no-{$mode}",
),
),
);
// If using 'prerender', also exclude links that opt-out of 'prefetch' because it's part of 'prerender'.
if ( 'prerender' === $mode ) {
$main_rule_conditions[] = array(
'not' => array(
'selector_matches' => '.no-prefetch',
),
);
}
$speculation_rules->add_rule(
$mode,
'main',
array(
'source' => 'document',
'where' => array(
'and' => $main_rule_conditions,
),
'eagerness' => $eagerness,
)
);
/**
* Fires when speculation rules data is loaded, allowing to amend the rules.
*
* @since 6.8.0
*
* @param WP_Speculation_Rules $speculation_rules Object representing the speculation rules to use.
*/
do_action( 'wp_load_speculation_rules', $speculation_rules );
return $speculation_rules;
}
/**
* Prints the speculation rules.
*
* For browsers that do not support speculation rules yet, the `script[type="speculationrules"]` tag will be ignored.
*
* @since 6.8.0
* @access private
*/
function wp_print_speculation_rules(): void {
$speculation_rules = wp_get_speculation_rules();
if ( null === $speculation_rules ) {
return;
}
wp_print_inline_script_tag(
(string) wp_json_encode(
$speculation_rules
),
array( 'type' => 'speculationrules' )
);
}

View File

@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
$wp_version = '6.8-alpha-59836';
$wp_version = '6.8-alpha-59837';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.

View File

@ -405,6 +405,9 @@ require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api.php';
require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api-directives-processor.php';
require ABSPATH . WPINC . '/interactivity-api/interactivity-api.php';
require ABSPATH . WPINC . '/class-wp-plugin-dependencies.php';
require ABSPATH . WPINC . '/class-wp-url-pattern-prefixer.php';
require ABSPATH . WPINC . '/class-wp-speculation-rules.php';
require ABSPATH . WPINC . '/speculative-loading.php';
add_action( 'after_setup_theme', array( wp_script_modules(), 'add_hooks' ) );
add_action( 'after_setup_theme', array( wp_interactivity(), 'add_hooks' ) );