Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add speculative loading support #7860

Closed
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
6a19089
Port speculative loading implementation to core, using filter instead…
felixarntz Nov 21, 2024
061819d
Merge branch 'trunk' into add/62503-speculative-loading
felixarntz Nov 21, 2024
7448b4c
Fix WPCS violation.
felixarntz Nov 21, 2024
f65e1a5
Fix more WPCS issues.
felixarntz Nov 21, 2024
694c1b1
Add missing translator comment.
felixarntz Nov 21, 2024
f38167c
Merge branch 'trunk' into add/62503-speculative-loading
felixarntz Nov 25, 2024
cc66024
Use null instead of false to disable speculative loading.
felixarntz Nov 25, 2024
1485faf
Fix WPCS.
felixarntz Nov 25, 2024
a78f8f5
Merge branch 'trunk' into add/62503-speculative-loading
felixarntz Jan 9, 2025
535c6f8
Exclude URLs with query parameters when pretty permalinks are enabled…
felixarntz Jan 9, 2025
a8c27e8
Disable speculative loading by default for logged-in users.
felixarntz Jan 9, 2025
a0d4dda
Exclude not only wp-login.php but any wp- PHP files from the WordPres…
felixarntz Jan 9, 2025
4bb68cb
Merge branch 'trunk' into add/62503-speculative-loading
felixarntz Jan 10, 2025
70b6204
Use coversDefaultClass in tests.
felixarntz Jan 10, 2025
8a55d2f
Make data provider methods static.
felixarntz Jan 10, 2025
cae7faa
Merge branch 'add/62503-speculative-loading' of github.com:felixarntz…
felixarntz Jan 10, 2025
da5fb19
Merge branch 'trunk' into add/62503-speculative-loading
felixarntz Jan 30, 2025
04d41c7
Merge branch 'trunk' into add/62503-speculative-loading
felixarntz Feb 3, 2025
b267ae5
Allow providing additional speculation rules by amending speculation …
felixarntz Feb 3, 2025
aa50b92
Add tests for WP_Speculation_Rules class and fix bugs found by tests.
felixarntz Feb 3, 2025
8c12bd3
Add support for the eagerness value immediate.
felixarntz Feb 3, 2025
6827503
Fix prerender exclusions so that they also exclude links that opt out…
felixarntz Feb 3, 2025
4d54a08
Fix WPCS errors.
felixarntz Feb 3, 2025
82a6ef6
Make `WP_Speculation_Rules` final.
felixarntz Feb 3, 2025
d4c382d
Remove unnecessary to_array() method.
felixarntz Feb 3, 2025
b85f0b8
Make comment more future-proof.
felixarntz Feb 5, 2025
066a1f7
Merge branch 'trunk' into add/62503-speculative-loading
felixarntz Feb 5, 2025
5093344
Merge branch 'add/62503-speculative-loading' of github.com:felixarntz…
felixarntz Feb 5, 2025
b587733
Avoid using we in inline comments and use encouraged syntax for multi…
felixarntz Feb 5, 2025
a9c52f8
Disallow use of immediate eagerness for document-level rules.
felixarntz Feb 5, 2025
a704a2b
Merge branch 'trunk' into add/62503-speculative-loading
felixarntz Feb 6, 2025
b08592a
For sites without pretty permalinks, exclude URLs using any kind of n…
felixarntz Feb 6, 2025
0a798ff
Merge branch 'trunk' into add/62503-speculative-loading
felixarntz Feb 12, 2025
349f4c0
Remove unnecessary parameter from wp_get_speculation_rules() and inst…
felixarntz Feb 12, 2025
029324e
Move speculative loading validation functions to become static method…
felixarntz Feb 12, 2025
f290c26
Use static callbacks in tests.
felixarntz Feb 14, 2025
57fd8c1
Merge branch 'trunk' into add/62503-speculative-loading
felixarntz Feb 18, 2025
b330805
Merge branch 'add/62503-speculative-loading' of github.com:felixarntz…
felixarntz Feb 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions src/wp-includes/class-wp-url-pattern-prefixer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?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), we need to
// additionally enclose it 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, '+*?:{}()\\' );
}
}
1 change: 1 addition & 0 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@
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' );
Expand Down
248 changes: 248 additions & 0 deletions src/wp-includes/speculative-loading.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
<?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 {
$config = array(
'mode' => 'auto',
'eagerness' => 'auto',
);

/**
* 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.
*
* @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. The default value
* for both of them is 'auto'. Other possible values for 'mode' are
* 'prefetch' and 'prerender'. Other possible values for 'eagerness' are
* 'eager', 'moderate', and 'conservative'. Alternatively, you may
* return `null` 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_is_valid_speculation_rules_mode( $config['mode'] ) ) {
$config['mode'] = $default_mode;
}
if ( ! isset( $config['eagerness'] ) || 'auto' === $config['eagerness'] || ! wp_is_valid_speculation_rules_eagerness( $config['eagerness'] ) ) {
$config['eagerness'] = $default_eagerness;
}

return array(
'mode' => $config['mode'],
'eagerness' => $config['eagerness'],
);
}

/**
* 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.
*/
function wp_is_valid_speculation_rules_mode( string $mode ): bool {
static $mode_allowlist = array(
'prefetch' => true,
'prerender' => true,
);
return isset( $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.
*/
function wp_is_valid_speculation_rules_eagerness( string $eagerness ): bool {
static $eagerness_allowlist = array(
'eager' => true,
'moderate' => true,
'conservative' => true,
);
return isset( $eagerness_allowlist[ $eagerness ] );
}

/**
* Returns the full speculation rules data based on the given 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.
*
* @since 6.8.0
* @access private
*
* @param array<string, string> $configuration Associative array with 'mode' and 'eagerness' keys.
* @return array<string, array<int, array<string, mixed>>> Associative array of speculation rules by type.
*/
function wp_get_speculation_rules( array $configuration ): array {
// If the passed configuration is invalid, trigger a warning and fall back to the default configuration.
if (
! isset( $configuration['mode'] ) ||
! wp_is_valid_speculation_rules_mode( $configuration['mode'] ) ||
! isset( $configuration['eagerness'] ) ||
! wp_is_valid_speculation_rules_eagerness( $configuration['eagerness'] )
) {
_doing_it_wrong(
__FUNCTION__,
esc_html(
sprintf(
/* translators: %s is $configuration */
__( 'The %s parameter was provided with an invalid value.' ),
'$configuration'
)
),
'6.8.0'
);
$configuration = wp_get_speculation_rules_configuration();
}

$mode = $configuration['mode'];
$eagerness = $configuration['eagerness'];

$prefixer = new WP_URL_Pattern_Prefixer();

$base_href_exclude_paths = array(
$prefixer->prefix_path_pattern( '/wp-login.php', 'site' ),
$prefixer->prefix_path_pattern( '/wp-admin/*', 'site' ),
$prefixer->prefix_path_pattern( '/*\\?*(^|&)_wpnonce=*', 'home' ),
$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' ),
);

/**
* 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
)
)
)
);

$rules = array(
array(
'source' => 'document',
'where' => array(
'and' => 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"]',
),
),
// Last but not least, exclude links that are explicitly marked to opt out.
array(
'not' => array(
'selector_matches' => ".no-{$mode}",
),
),
),
),
'eagerness' => $eagerness,
),
);

return array( $mode => $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 {
$configuration = wp_get_speculation_rules_configuration();
if ( null === $configuration ) {
return;
}

wp_print_inline_script_tag(
(string) wp_json_encode(
wp_get_speculation_rules( $configuration )
),
array( 'type' => 'speculationrules' )
);
}
2 changes: 2 additions & 0 deletions src/wp-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@
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 . '/speculative-loading.php';

add_action( 'after_setup_theme', array( wp_script_modules(), 'add_hooks' ) );
add_action( 'after_setup_theme', array( wp_interactivity(), 'add_hooks' ) );
Expand Down
Loading
Loading