<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[59837] trunk: General: Add speculative loading support via the Speculation Rules API.</title>
</head>
<body>
<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; }
#msg dl a { font-weight: bold}
#msg dl a:link { color:#fc3; }
#msg dl a:active { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { white-space: pre-line; overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="https://core.trac.wordpress.org/changeset/59837">59837</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"https://core.trac.wordpress.org/changeset/59837","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>flixos90</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2025-02-18 22:30:05 +0000 (Tue, 18 Feb 2025)</dd>
</dl>
<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>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()` funct
ion 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 <a href="https://core.trac.wordpress.org/ticket/62503">#62503</a>.</pre>
<h3>Modified Paths</h3>
<ul>
<li><a href="#trunksrcwpincludesdefaultfiltersphp">trunk/src/wp-includes/default-filters.php</a></li>
<li><a href="#trunksrcwpsettingsphp">trunk/src/wp-settings.php</a></li>
</ul>
<h3>Added Paths</h3>
<ul>
<li><a href="#trunksrcwpincludesclasswpspeculationrulesphp">trunk/src/wp-includes/class-wp-speculation-rules.php</a></li>
<li><a href="#trunksrcwpincludesclasswpurlpatternprefixerphp">trunk/src/wp-includes/class-wp-url-pattern-prefixer.php</a></li>
<li><a href="#trunksrcwpincludesspeculativeloadingphp">trunk/src/wp-includes/speculative-loading.php</a></li>
<li>trunk/tests/phpunit/tests/speculative-loading/</li>
<li><a href="#trunktestsphpunittestsspeculativeloadingwpGetSpeculationRulesphp">trunk/tests/phpunit/tests/speculative-loading/wpGetSpeculationRules.php</a></li>
<li><a href="#trunktestsphpunittestsspeculativeloadingwpGetSpeculationRulesConfigurationphp">trunk/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesConfiguration.php</a></li>
<li><a href="#trunktestsphpunittestsspeculativeloadingwpPrintSpeculationRulesphp">trunk/tests/phpunit/tests/speculative-loading/wpPrintSpeculationRules.php</a></li>
<li><a href="#trunktestsphpunittestsspeculativeloadingwpSpeculationRulesphp">trunk/tests/phpunit/tests/speculative-loading/wpSpeculationRules.php</a></li>
<li><a href="#trunktestsphpunittestsspeculativeloadingwpUrlPatternPrefixerphp">trunk/tests/phpunit/tests/speculative-loading/wpUrlPatternPrefixer.php</a></li>
</ul>
</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunksrcwpincludesclasswpspeculationrulesphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/class-wp-speculation-rules.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/class-wp-speculation-rules.php (rev 0)
+++ trunk/src/wp-includes/class-wp-speculation-rules.php 2025-02-18 22:30:05 UTC (rev 59837)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,293 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?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 ] );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/class-wp-speculation-rules.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesclasswpurlpatternprefixerphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/class-wp-url-pattern-prefixer.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/class-wp-url-pattern-prefixer.php (rev 0)
+++ trunk/src/wp-includes/class-wp-url-pattern-prefixer.php 2025-02-18 22:30:05 UTC (rev 59837)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,135 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?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, '+*?:{}()\\' );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/class-wp-url-pattern-prefixer.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesdefaultfiltersphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-includes/default-filters.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/default-filters.php 2025-02-18 08:02:30 UTC (rev 59836)
+++ trunk/src/wp-includes/default-filters.php 2025-02-18 22:30:05 UTC (rev 59837)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -358,6 +358,7 @@
</span><span class="cx" style="display: block; padding: 0 10px"> add_action( 'wp_head', 'wp_shortlink_wp_head', 10, 0 );
</span><span class="cx" style="display: block; padding: 0 10px"> add_action( 'wp_head', 'wp_custom_css_cb', 101 );
</span><span class="cx" style="display: block; padding: 0 10px"> add_action( 'wp_head', 'wp_site_icon', 99 );
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+add_action( 'wp_footer', 'wp_print_speculation_rules' );
</ins><span class="cx" style="display: block; padding: 0 10px"> add_action( 'wp_footer', 'wp_print_footer_scripts', 20 );
</span><span class="cx" style="display: block; padding: 0 10px"> add_action( 'template_redirect', 'wp_shortlink_header', 11, 0 );
</span><span class="cx" style="display: block; padding: 0 10px"> add_action( 'wp_print_footer_scripts', '_wp_footer_scripts' );
</span></span></pre></div>
<a id="trunksrcwpincludesspeculativeloadingphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/speculative-loading.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/speculative-loading.php (rev 0)
+++ trunk/src/wp-includes/speculative-loading.php 2025-02-18 22:30:05 UTC (rev 59837)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,254 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?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' )
+ );
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/speculative-loading.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpsettingsphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-settings.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-settings.php 2025-02-18 08:02:30 UTC (rev 59836)
+++ trunk/src/wp-settings.php 2025-02-18 22:30:05 UTC (rev 59837)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -405,6 +405,9 @@
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api-directives-processor.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/interactivity-api/interactivity-api.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/class-wp-plugin-dependencies.php';
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+require ABSPATH . WPINC . '/class-wp-url-pattern-prefixer.php';
+require ABSPATH . WPINC . '/class-wp-speculation-rules.php';
+require ABSPATH . WPINC . '/speculative-loading.php';
</ins><span class="cx" style="display: block; padding: 0 10px">
</span><span class="cx" style="display: block; padding: 0 10px"> add_action( 'after_setup_theme', array( wp_script_modules(), 'add_hooks' ) );
</span><span class="cx" style="display: block; padding: 0 10px"> add_action( 'after_setup_theme', array( wp_interactivity(), 'add_hooks' ) );
</span></span></pre></div>
<a id="trunktestsphpunittestsspeculativeloadingwpGetSpeculationRulesphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/speculative-loading/wpGetSpeculationRules.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/speculative-loading/wpGetSpeculationRules.php (rev 0)
+++ trunk/tests/phpunit/tests/speculative-loading/wpGetSpeculationRules.php 2025-02-18 22:30:05 UTC (rev 59837)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,561 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Tests for the wp_get_speculation_rules() function.
+ *
+ * @package WordPress
+ * @subpackage Speculative Loading
+ */
+
+/**
+ * @group speculative-loading
+ * @covers ::wp_get_speculation_rules
+ */
+class Tests_Speculative_Loading_wpGetSpeculationRules extends WP_UnitTestCase {
+
+ private $prefetch_config = array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'conservative',
+ );
+ private $prerender_config = array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'conservative',
+ );
+
+ public function set_up() {
+ parent::set_up();
+
+ add_filter(
+ 'template_directory_uri',
+ static function () {
+ return content_url( 'themes/template' );
+ }
+ );
+
+ add_filter(
+ 'stylesheet_directory_uri',
+ static function () {
+ return content_url( 'themes/stylesheet' );
+ }
+ );
+
+ update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' );
+ }
+
+ /**
+ * Tests speculation rules output with prefetch for the different eagerness levels.
+ *
+ * @ticket 62503
+ * @dataProvider data_eagerness
+ */
+ public function test_wp_get_speculation_rules_with_prefetch( string $eagerness ) {
+ remove_all_filters( 'wp_speculation_rules_configuration' );
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ static function () use ( $eagerness ) {
+ return array(
+ 'mode' => 'prefetch',
+ 'eagerness' => $eagerness,
+ );
+ }
+ );
+
+ $rules = wp_get_speculation_rules();
+
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $rules = $rules->jsonSerialize();
+
+ $this->assertArrayHasKey( 'prefetch', $rules );
+ $this->assertIsArray( $rules['prefetch'] );
+ foreach ( $rules['prefetch'] as $entry ) {
+ $this->assertIsArray( $entry );
+ $this->assertArrayHasKey( 'source', $entry );
+ $this->assertSame( 'document', $entry['source'] );
+ $this->assertArrayHasKey( 'eagerness', $entry );
+ $this->assertSame( $eagerness, $entry['eagerness'] );
+ }
+ }
+
+ /**
+ * Tests speculation rules output with prerender for the different eagerness levels.
+ *
+ * @ticket 62503
+ * @dataProvider data_eagerness
+ */
+ public function test_wp_get_speculation_rules_with_prerender( string $eagerness ) {
+ remove_all_filters( 'wp_speculation_rules_configuration' );
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ static function () use ( $eagerness ) {
+ return array(
+ 'mode' => 'prerender',
+ 'eagerness' => $eagerness,
+ );
+ }
+ );
+
+ $rules = wp_get_speculation_rules();
+
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $rules = $rules->jsonSerialize();
+
+ $this->assertArrayHasKey( 'prerender', $rules );
+ $this->assertIsArray( $rules['prerender'] );
+ foreach ( $rules['prerender'] as $entry ) {
+ $this->assertIsArray( $entry );
+ $this->assertArrayHasKey( 'source', $entry );
+ $this->assertSame( 'document', $entry['source'] );
+ $this->assertArrayHasKey( 'eagerness', $entry );
+ $this->assertSame( $eagerness, $entry['eagerness'] );
+ }
+ }
+
+ public static function data_eagerness(): array {
+ return array(
+ array( 'conservative' ),
+ array( 'moderate' ),
+ array( 'eager' ),
+ );
+ }
+
+ /**
+ * Tests that the number of entries included for prefetch configuration is correct.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_prefetch_entries() {
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ function () {
+ return $this->prefetch_config;
+ }
+ );
+
+ $rules = wp_get_speculation_rules();
+
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $rules = $rules->jsonSerialize();
+
+ $this->assertArrayHasKey( 'prefetch', $rules );
+ $this->assertCount( 4, $rules['prefetch'][0]['where']['and'] );
+ $this->assertArrayHasKey( 'not', $rules['prefetch'][0]['where']['and'][3] );
+ $this->assertArrayHasKey( 'selector_matches', $rules['prefetch'][0]['where']['and'][3]['not'] );
+ $this->assertSame( '.no-prefetch', $rules['prefetch'][0]['where']['and'][3]['not']['selector_matches'] );
+ }
+
+ /**
+ * Tests that the number of entries included for prerender configuration is correct.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_prerender_entries() {
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ function () {
+ return $this->prerender_config;
+ }
+ );
+
+ $rules = wp_get_speculation_rules();
+
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $rules = $rules->jsonSerialize();
+
+ $this->assertArrayHasKey( 'prerender', $rules );
+ $this->assertCount( 5, $rules['prerender'][0]['where']['and'] );
+ $this->assertArrayHasKey( 'not', $rules['prerender'][0]['where']['and'][3] );
+ $this->assertArrayHasKey( 'selector_matches', $rules['prerender'][0]['where']['and'][3]['not'] );
+ $this->assertSame( '.no-prerender', $rules['prerender'][0]['where']['and'][3]['not']['selector_matches'] );
+ $this->assertArrayHasKey( 'not', $rules['prerender'][0]['where']['and'][4] );
+ $this->assertArrayHasKey( 'selector_matches', $rules['prerender'][0]['where']['and'][4]['not'] );
+ $this->assertSame( '.no-prefetch', $rules['prerender'][0]['where']['and'][4]['not']['selector_matches'] );
+ }
+
+ /**
+ * Tests the default exclude paths and ensures they cannot be altered via filter.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_href_exclude_paths() {
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ function () {
+ return $this->prefetch_config;
+ }
+ );
+
+ $rules = wp_get_speculation_rules();
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $rules = $rules->jsonSerialize();
+
+ $href_exclude_paths = $rules['prefetch'][0]['where']['and'][1]['not']['href_matches'];
+
+ $this->assertSameSets(
+ array(
+ '/wp-*.php',
+ '/wp-admin/*',
+ '/wp-content/uploads/*',
+ '/wp-content/*',
+ '/wp-content/plugins/*',
+ '/wp-content/themes/stylesheet/*',
+ '/wp-content/themes/template/*',
+ '/*\\?(.+)',
+ ),
+ $href_exclude_paths,
+ 'Snapshot: ' . var_export( $href_exclude_paths, true )
+ );
+
+ // Add filter that attempts to replace base exclude paths with a custom path to exclude.
+ add_filter(
+ 'wp_speculation_rules_href_exclude_paths',
+ static function () {
+ return array( 'custom-file.php' );
+ }
+ );
+
+ $rules = wp_get_speculation_rules();
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $rules = $rules->jsonSerialize();
+
+ $href_exclude_paths = $rules['prefetch'][0]['where']['and'][1]['not']['href_matches'];
+
+ // Ensure the base exclude paths are still present and that the custom path was formatted correctly.
+ $this->assertSameSets(
+ array(
+ '/wp-*.php',
+ '/wp-admin/*',
+ '/wp-content/uploads/*',
+ '/wp-content/*',
+ '/wp-content/plugins/*',
+ '/wp-content/themes/stylesheet/*',
+ '/wp-content/themes/template/*',
+ '/*\\?(.+)',
+ '/custom-file.php',
+ ),
+ $href_exclude_paths,
+ 'Snapshot: ' . var_export( $href_exclude_paths, true )
+ );
+ }
+
+ /**
+ * Tests the default exclude paths and ensures they cannot be altered via filter.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_href_exclude_paths_without_pretty_permalinks() {
+ update_option( 'permalink_structure', '' );
+
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ function () {
+ return $this->prefetch_config;
+ }
+ );
+
+ $rules = wp_get_speculation_rules();
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $rules = $rules->jsonSerialize();
+
+ $href_exclude_paths = $rules['prefetch'][0]['where']['and'][1]['not']['href_matches'];
+
+ $this->assertSameSets(
+ array(
+ '/wp-*.php',
+ '/wp-admin/*',
+ '/wp-content/uploads/*',
+ '/wp-content/*',
+ '/wp-content/plugins/*',
+ '/wp-content/themes/stylesheet/*',
+ '/wp-content/themes/template/*',
+ '/*\\?*(^|&)*nonce*=*',
+ ),
+ $href_exclude_paths,
+ 'Snapshot: ' . var_export( $href_exclude_paths, true )
+ );
+ }
+
+ /**
+ * Tests that exclude paths can be altered specifically based on the mode used.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_href_exclude_paths_with_mode() {
+ // Add filter that adds an exclusion only if the mode is 'prerender'.
+ add_filter(
+ 'wp_speculation_rules_href_exclude_paths',
+ static function ( $exclude_paths, $mode ) {
+ if ( 'prerender' === $mode ) {
+ $exclude_paths[] = '/products/*';
+ }
+ return $exclude_paths;
+ },
+ 10,
+ 2
+ );
+
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ function () {
+ return $this->prerender_config;
+ }
+ );
+ $rules = wp_get_speculation_rules();
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $rules = $rules->jsonSerialize();
+
+ $href_exclude_paths = $rules['prerender'][0]['where']['and'][1]['not']['href_matches'];
+
+ // Ensure the additional exclusion is present because the mode is 'prerender'.
+ // Also ensure keys are sequential starting from 0 (that is, that array_is_list()).
+ $this->assertSame(
+ array(
+ '/wp-*.php',
+ '/wp-admin/*',
+ '/wp-content/uploads/*',
+ '/wp-content/*',
+ '/wp-content/plugins/*',
+ '/wp-content/themes/stylesheet/*',
+ '/wp-content/themes/template/*',
+ '/*\\?(.+)',
+ '/products/*',
+ ),
+ $href_exclude_paths,
+ 'Snapshot: ' . var_export( $href_exclude_paths, true )
+ );
+
+ // Redo with 'prefetch'.
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ function () {
+ return $this->prefetch_config;
+ }
+ );
+ $rules = wp_get_speculation_rules();
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $rules = $rules->jsonSerialize();
+
+ $href_exclude_paths = $rules['prefetch'][0]['where']['and'][1]['not']['href_matches'];
+
+ // Ensure the additional exclusion is not present because the mode is 'prefetch'.
+ $this->assertSame(
+ array(
+ '/wp-*.php',
+ '/wp-admin/*',
+ '/wp-content/uploads/*',
+ '/wp-content/*',
+ '/wp-content/plugins/*',
+ '/wp-content/themes/stylesheet/*',
+ '/wp-content/themes/template/*',
+ '/*\\?(.+)',
+ ),
+ $href_exclude_paths,
+ 'Snapshot: ' . var_export( $href_exclude_paths, true )
+ );
+ }
+
+ /**
+ * Tests filter that explicitly adds non-sequential keys.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_with_filtering_bad_keys() {
+
+ add_filter(
+ 'wp_speculation_rules_href_exclude_paths',
+ static function ( array $exclude_paths ): array {
+ $exclude_paths[] = '/next/';
+ array_unshift( $exclude_paths, '/unshifted/' );
+ $exclude_paths[-1] = '/negative-one/';
+ $exclude_paths[100] = '/one-hundred/';
+ $exclude_paths['a'] = '/letter-a/';
+ return $exclude_paths;
+ }
+ );
+
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ function () {
+ return $this->prerender_config;
+ }
+ );
+ $rules = wp_get_speculation_rules();
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $rules = $rules->jsonSerialize();
+
+ $href_exclude_paths = $rules['prerender'][0]['where']['and'][1]['not']['href_matches'];
+ $this->assertSame(
+ array(
+ '/wp-*.php',
+ '/wp-admin/*',
+ '/wp-content/uploads/*',
+ '/wp-content/*',
+ '/wp-content/plugins/*',
+ '/wp-content/themes/stylesheet/*',
+ '/wp-content/themes/template/*',
+ '/*\\?(.+)',
+ '/unshifted/',
+ '/next/',
+ '/negative-one/',
+ '/one-hundred/',
+ '/letter-a/',
+ ),
+ $href_exclude_paths,
+ 'Snapshot: ' . var_export( $href_exclude_paths, true )
+ );
+ }
+
+ /**
+ * Tests scenario when the home_url and site_url have different paths.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_different_home_and_site_urls() {
+ add_filter(
+ 'site_url',
+ static function (): string {
+ return 'https://example.com/wp/';
+ }
+ );
+ add_filter(
+ 'home_url',
+ static function (): string {
+ return 'https://example.com/blog/';
+ }
+ );
+ add_filter(
+ 'wp_speculation_rules_href_exclude_paths',
+ static function ( array $exclude_paths ): array {
+ $exclude_paths[] = '/store/*';
+ return $exclude_paths;
+ }
+ );
+
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ function () {
+ return $this->prerender_config;
+ }
+ );
+ $rules = wp_get_speculation_rules();
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $rules = $rules->jsonSerialize();
+
+ $href_exclude_paths = $rules['prerender'][0]['where']['and'][1]['not']['href_matches'];
+ $this->assertSame(
+ array(
+ '/wp/wp-*.php',
+ '/wp/wp-admin/*',
+ '/wp-content/uploads/*',
+ '/wp-content/*',
+ '/wp-content/plugins/*',
+ '/wp-content/themes/stylesheet/*',
+ '/wp-content/themes/template/*',
+ '/blog/*\\?(.+)',
+ '/blog/store/*',
+ ),
+ $href_exclude_paths,
+ 'Snapshot: ' . var_export( $href_exclude_paths, true )
+ );
+ }
+
+ /**
+ * Tests that passing an invalid configuration to the function does not lead to unexpected problems.
+ *
+ * This is mostly an integration test as it is resolved as part of wp_get_speculation_rules_configuration().
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_with_invalid_configuration() {
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ static function () {
+ return array(
+ 'mode' => 'none',
+ 'eagerness' => 'none',
+ );
+ }
+ );
+ $rules = wp_get_speculation_rules();
+
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $rules = $rules->jsonSerialize();
+
+ $this->assertArrayHasKey( 'prefetch', $rules );
+ $this->assertSame( 'conservative', $rules['prefetch'][0]['eagerness'] );
+ }
+
+ /**
+ * Tests that passing no configuration (`null`) results in no speculation rules being returned.
+ *
+ * This is used to effectively disable the feature.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_with_null() {
+ add_filter( 'wp_speculation_rules_configuration', '__return_null' );
+
+ $rules = wp_get_speculation_rules();
+ $this->assertNull( $rules );
+ }
+
+ /**
+ * Tests that the 'wp_load_speculation_rules' action allows providing additional rules.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_with_additional_rules() {
+ $filtered_obj = null;
+ add_action(
+ 'wp_load_speculation_rules',
+ static function ( $speculation_rules ) use ( &$filtered_obj ) {
+ $filtered_obj = $speculation_rules;
+
+ /*
+ * In practice, these rules would ensure that links marked with the classes would be opt in to
+ * prerendering with moderate and eager eagerness respectively.
+ */
+ $speculation_rules->add_rule(
+ 'prerender',
+ 'prerender-moderate-marked-links',
+ array(
+ 'source' => 'document',
+ 'where' => array(
+ 'selector_matches' => '.moderate-prerender, .moderate-prerender a',
+ ),
+ 'eagerness' => 'moderate',
+ )
+ );
+ $speculation_rules->add_rule(
+ 'prerender',
+ 'prerender-eager-marked-links',
+ array(
+ 'source' => 'document',
+ 'where' => array(
+ 'selector_matches' => '.eager-prerender, .eager-prerender a',
+ ),
+ 'eagerness' => 'eager',
+ )
+ );
+ }
+ );
+
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ function () {
+ return $this->prefetch_config;
+ }
+ );
+ $rules = wp_get_speculation_rules();
+ $this->assertInstanceOf( WP_Speculation_Rules::class, $rules );
+ $this->assertSame( $filtered_obj, $rules );
+
+ $rules = $rules->jsonSerialize();
+
+ $this->assertArrayHasKey( 'prefetch', $rules );
+ $this->assertCount( 1, $rules['prefetch'] );
+ $this->assertArrayHasKey( 'prerender', $rules );
+ $this->assertCount( 2, $rules['prerender'] );
+ $this->assertSame( 'conservative', $rules['prefetch'][0]['eagerness'] );
+ $this->assertSame( 'moderate', $rules['prerender'][0]['eagerness'] );
+ $this->assertSame( 'eager', $rules['prerender'][1]['eagerness'] );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/speculative-loading/wpGetSpeculationRules.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsspeculativeloadingwpGetSpeculationRulesConfigurationphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesConfiguration.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesConfiguration.php (rev 0)
+++ trunk/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesConfiguration.php 2025-02-18 22:30:05 UTC (rev 59837)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,267 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Tests for the wp_get_speculation_rules_configuration() function.
+ *
+ * @package WordPress
+ * @subpackage Speculative Loading
+ */
+
+/**
+ * @group speculative-loading
+ * @covers ::wp_get_speculation_rules_configuration
+ */
+class Tests_Speculative_Loading_wpGetSpeculationRulesConfiguration extends WP_UnitTestCase {
+
+ public function set_up() {
+ parent::set_up();
+
+ update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' );
+ }
+
+ /**
+ * Tests that the default configuration is the expected value.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_configuration_default() {
+ $filter_default = null;
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ function ( $config ) use ( &$filter_default ) {
+ $filter_default = $config;
+ return $config;
+ }
+ );
+
+ $config_default = wp_get_speculation_rules_configuration();
+
+ // The filter default uses 'auto', but for the function result this is evaluated to actual mode and eagerness.
+ $this->assertSame(
+ array(
+ 'mode' => 'auto',
+ 'eagerness' => 'auto',
+ ),
+ $filter_default
+ );
+ $this->assertSame(
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'conservative',
+ ),
+ $config_default
+ );
+ }
+
+ /**
+ * Tests that the speculative loading is disabled by default when not using pretty permalinks.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_configuration_without_pretty_permalinks() {
+ update_option( 'permalink_structure', '' );
+ $this->assertNull( wp_get_speculation_rules_configuration() );
+ }
+
+ /**
+ * Tests that the speculative loading is disabled by default for logged-in users.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_get_speculation_rules_configuration_with_logged_in_user() {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ $this->assertNull( wp_get_speculation_rules_configuration() );
+ }
+
+ /**
+ * Tests that the configuration can be filtered and leads to the expected results.
+ *
+ * @ticket 62503
+ * @dataProvider data_wp_get_speculation_rules_configuration_filter
+ */
+ public function test_wp_get_speculation_rules_configuration_filter( $filter_value, $expected ) {
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ function () use ( $filter_value ) {
+ return $filter_value;
+ }
+ );
+
+ $this->assertSame( $expected, wp_get_speculation_rules_configuration() );
+ }
+
+ public static function data_wp_get_speculation_rules_configuration_filter(): array {
+ return array(
+ 'conservative prefetch' => array(
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'conservative',
+ ),
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'conservative',
+ ),
+ ),
+ 'moderate prefetch' => array(
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'moderate',
+ ),
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'moderate',
+ ),
+ ),
+ 'eager prefetch' => array(
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'eager',
+ ),
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'eager',
+ ),
+ ),
+ 'conservative prerender' => array(
+ array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'conservative',
+ ),
+ array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'conservative',
+ ),
+ ),
+ 'moderate prerender' => array(
+ array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'moderate',
+ ),
+ array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'moderate',
+ ),
+ ),
+ 'eager prerender' => array(
+ array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'eager',
+ ),
+ array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'eager',
+ ),
+ ),
+ 'auto' => array(
+ array(
+ 'mode' => 'auto',
+ 'eagerness' => 'auto',
+ ),
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'conservative',
+ ),
+ ),
+ 'auto mode only' => array(
+ array(
+ 'mode' => 'auto',
+ 'eagerness' => 'eager',
+ ),
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'eager',
+ ),
+ ),
+ 'auto eagerness only' => array(
+ array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'auto',
+ ),
+ array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'conservative',
+ ),
+ ),
+ // 'immediate' is a valid eagerness, but for safety WordPress does not allow it for document-level rules.
+ 'immediate eagerness' => array(
+ array(
+ 'mode' => 'auto',
+ 'eagerness' => 'immediate',
+ ),
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'conservative',
+ ),
+ ),
+ 'null' => array(
+ null,
+ null,
+ ),
+ 'false' => array(
+ false,
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'conservative',
+ ),
+ ),
+ 'true' => array(
+ true,
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'conservative',
+ ),
+ ),
+ 'missing mode' => array(
+ array(
+ 'eagerness' => 'eager',
+ ),
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'eager',
+ ),
+ ),
+ 'missing eagerness' => array(
+ array(
+ 'mode' => 'prerender',
+ ),
+ array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'conservative',
+ ),
+ ),
+ 'empty array' => array(
+ array(),
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'conservative',
+ ),
+ ),
+ 'invalid mode' => array(
+ array(
+ 'mode' => 'invalid',
+ 'eagerness' => 'eager',
+ ),
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'eager',
+ ),
+ ),
+ 'invalid eagerness' => array(
+ array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'invalid',
+ ),
+ array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'conservative',
+ ),
+ ),
+ 'invalid type' => array(
+ 42,
+ array(
+ 'mode' => 'prefetch',
+ 'eagerness' => 'conservative',
+ ),
+ ),
+ );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesConfiguration.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsspeculativeloadingwpPrintSpeculationRulesphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/speculative-loading/wpPrintSpeculationRules.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/speculative-loading/wpPrintSpeculationRules.php (rev 0)
+++ trunk/tests/phpunit/tests/speculative-loading/wpPrintSpeculationRules.php 2025-02-18 22:30:05 UTC (rev 59837)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,89 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Tests for the wp_print_speculation_rules() function.
+ *
+ * @package WordPress
+ * @subpackage Speculative Loading
+ */
+
+/**
+ * @group speculative-loading
+ * @covers ::wp_print_speculation_rules
+ */
+class Tests_Speculative_Loading_wpPrintSpeculationRules extends WP_UnitTestCase {
+
+ private $original_wp_theme_features = array();
+
+ public function set_up() {
+ parent::set_up();
+ $this->original_wp_theme_features = $GLOBALS['_wp_theme_features'];
+ }
+
+ public function tear_down() {
+ $GLOBALS['_wp_theme_features'] = $this->original_wp_theme_features;
+ parent::tear_down();
+ }
+
+ /**
+ * Tests that the hook for printing speculation rules is set up.
+ *
+ * @ticket 62503
+ */
+ public function test_hook() {
+ $this->assertSame( 10, has_action( 'wp_footer', 'wp_print_speculation_rules' ) );
+ }
+
+ /**
+ * Tests speculation rules script output with HTML5 support.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_print_speculation_rules_with_html5_support() {
+ add_theme_support( 'html5', array( 'script' ) );
+
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ static function () {
+ return array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'moderate',
+ );
+ }
+ );
+
+ $output = get_echo( 'wp_print_speculation_rules' );
+ $this->assertStringContainsString( '<script type="speculationrules">', $output );
+
+ $json = str_replace( array( '<script type="speculationrules">', '</script>' ), '', $output );
+ $rules = json_decode( $json, true );
+ $this->assertIsArray( $rules );
+ $this->assertArrayHasKey( 'prerender', $rules );
+ }
+
+ /**
+ * Tests speculation rules script output without HTML5 support.
+ *
+ * @ticket 62503
+ */
+ public function test_wp_print_speculation_rules_without_html5_support() {
+ remove_theme_support( 'html5' );
+
+ add_filter(
+ 'wp_speculation_rules_configuration',
+ static function () {
+ return array(
+ 'mode' => 'prerender',
+ 'eagerness' => 'moderate',
+ );
+ }
+ );
+
+ $output = get_echo( 'wp_print_speculation_rules' );
+ $this->assertStringContainsString( '<script type="speculationrules">', $output );
+
+ $json = str_replace( array( '<script type="speculationrules">', '</script>' ), '', $output );
+ $rules = json_decode( $json, true );
+ $this->assertIsArray( $rules );
+ $this->assertArrayHasKey( 'prerender', $rules );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/speculative-loading/wpPrintSpeculationRules.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsspeculativeloadingwpSpeculationRulesphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/speculative-loading/wpSpeculationRules.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/speculative-loading/wpSpeculationRules.php (rev 0)
+++ trunk/tests/phpunit/tests/speculative-loading/wpSpeculationRules.php 2025-02-18 22:30:05 UTC (rev 59837)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,369 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Tests for the WP_Speculation_Rules class.
+ *
+ * @package WordPress
+ * @subpackage Speculative Loading
+ */
+
+/**
+ * @group speculative-loading
+ * @coversDefaultClass WP_Speculation_Rules
+ */
+class Tests_Speculative_Loading_wpSpeculationRules extends WP_UnitTestCase {
+
+ /**
+ * Tests that adding a speculation rule is subject to the expected validation.
+ *
+ * @ticket 62503
+ * @covers ::add_rule
+ * @dataProvider data_add_rule
+ */
+ public function test_add_rule( string $mode, string $id, array $rule, bool $expected ) {
+ $speculation_rules = new WP_Speculation_Rules();
+
+ if ( ! $expected ) {
+ $this->setExpectedIncorrectUsage( 'WP_Speculation_Rules::add_rule' );
+ }
+
+ $result = $speculation_rules->add_rule( $mode, $id, $rule );
+ if ( $expected ) {
+ $this->assertTrue( $result );
+ } else {
+ $this->assertFalse( $result );
+ }
+ }
+
+ /**
+ * Tests that adding a speculation rule with a duplicate ID results in the expected behavior.
+ *
+ * @ticket 62503
+ * @covers ::add_rule
+ */
+ public function test_add_rule_with_duplicate() {
+ $speculation_rules = new WP_Speculation_Rules();
+
+ $this->assertTrue( $speculation_rules->add_rule( 'prerender', 'my-custom-rule', array( 'where' => array( 'href_matches' => '/*' ) ) ) );
+
+ // It should be possible to add a rule of the same ID for another mode.
+ $this->assertTrue( $speculation_rules->add_rule( 'prefetch', 'my-custom-rule', array( 'where' => array( 'href_matches' => '/*' ) ) ) );
+
+ // But it should not be possible to add a rule of the same ID to a mode where it's already present.
+ $this->setExpectedIncorrectUsage( 'WP_Speculation_Rules::add_rule' );
+ $this->assertFalse( $speculation_rules->add_rule( 'prerender', 'my-custom-rule', array( 'urls' => array( 'https://important-url.com/' ) ) ) );
+ }
+
+ public static function data_add_rule(): array {
+ return array(
+ 'basic-prefetch' => array(
+ 'prefetch',
+ 'test-rule-1',
+ array(
+ 'source' => 'document',
+ 'where' => array( 'selector_matches' => '.prefetch' ),
+ 'eagerness' => 'eager',
+ ),
+ true,
+ ),
+ 'basic-prefetch-no-source' => array(
+ 'prefetch',
+ 'test-rule-2',
+ array(
+ 'where' => array( 'selector_matches' => '.prefetch' ),
+ 'eagerness' => 'eager',
+ ),
+ true,
+ ),
+ 'basic-prefetch-no-eagerness' => array(
+ 'prefetch',
+ 'test-rule-3',
+ array(
+ 'source' => 'document',
+ 'where' => array( 'selector_matches' => '.prefetch' ),
+ ),
+ true,
+ ),
+ 'basic-prerender' => array(
+ 'prerender',
+ 'test-rule-1',
+ array(
+ 'source' => 'list',
+ 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ),
+ 'eagerness' => 'eager',
+ ),
+ true,
+ ),
+ 'basic-prerender-no-source' => array(
+ 'prerender',
+ 'test-rule-2',
+ array(
+ 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ),
+ 'eagerness' => 'eager',
+ ),
+ true,
+ ),
+ 'basic-prerender-no-eagerness' => array(
+ 'prerender',
+ 'test-rule-3',
+ array(
+ 'source' => 'list',
+ 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ),
+ ),
+ true,
+ ),
+ 'invalid-mode' => array(
+ 'load-fast', // Only 'prefetch' and 'prerender' are allowed.
+ 'test-rule-1',
+ array(
+ 'source' => 'document',
+ 'where' => array( 'selector_matches' => '.prefetch' ),
+ 'eagerness' => 'eager',
+ ),
+ false,
+ ),
+ 'invalid-id-characters' => array(
+ 'prefetch',
+ 'test rule 1', // Spaces are not allowed.
+ array(
+ 'source' => 'document',
+ 'where' => array( 'selector_matches' => '.prefetch' ),
+ 'eagerness' => 'eager',
+ ),
+ false,
+ ),
+ 'invalid-id-start' => array(
+ 'prefetch',
+ '1_test_rule', // The first character must be a lower-case letter.
+ array(
+ 'source' => 'document',
+ 'where' => array( 'selector_matches' => '.prefetch' ),
+ 'eagerness' => 'eager',
+ ),
+ false,
+ ),
+ 'invalid-source' => array(
+ 'prerender',
+ 'test-rule-1',
+ array(
+ 'source' => 'magic', // Only 'list' and 'document' are allowed.
+ 'where' => array( 'selector_matches' => '.prerender' ),
+ 'eagerness' => 'eager',
+ ),
+ false,
+ ),
+ 'missing-keys' => array(
+ 'prefetch',
+ 'test-rule-1',
+ array(), // The minimum requirements are presence of either a 'where' or 'urls' key.
+ false,
+ ),
+ 'conflicting-keys' => array(
+ 'prefetch',
+ 'test-rule-1',
+ array( // Only 'where' or 'urls' is allowed, but not both.
+ 'where' => array( 'selector_matches' => '.prefetch' ),
+ 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ),
+ ),
+ false,
+ ),
+ 'conflicting-list-source' => array(
+ 'prefetch',
+ 'test-rule-1',
+ array(
+ 'source' => 'list', // Source 'list' can only be used with key 'urls', but not 'where'.
+ 'where' => array( 'selector_matches' => '.prefetch' ),
+ 'eagerness' => 'eager',
+ ),
+ false,
+ ),
+ 'conflicting-document-source' => array(
+ 'prefetch',
+ 'test-rule-1',
+ array(
+ 'source' => 'document', // Source 'document' can only be used with key 'where', but not 'urls'.
+ 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ),
+ 'eagerness' => 'eager',
+ ),
+ false,
+ ),
+ 'invalid-eagerness' => array(
+ 'prefetch',
+ 'test-rule-1',
+ array(
+ 'source' => 'document',
+ 'where' => array( 'selector_matches' => '.prefetch' ),
+ 'eagerness' => 'fast', // Only 'immediate', 'eager, 'moderate', and 'conservative' are allowed.
+ ),
+ false,
+ ),
+ 'immediate-eagerness-list' => array(
+ 'prefetch',
+ 'test-rule-1',
+ array(
+ 'source' => 'list',
+ 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ),
+ 'eagerness' => 'immediate',
+ ),
+ true,
+ ),
+ // 'immediate' is a valid eagerness, but for safety WordPress does not allow it for document-level rules.
+ 'immediate-eagerness-document' => array(
+ 'prefetch',
+ 'test-rule-1',
+ array(
+ 'source' => 'document',
+ 'where' => array( 'selector_matches' => '.prefetch' ),
+ 'eagerness' => 'immediate',
+ ),
+ false,
+ ),
+ );
+ }
+
+ /**
+ * Tests that checking for existence of a rule works as expected.
+ *
+ * @ticket 62503
+ * @covers ::has_rule
+ */
+ public function test_has_rule() {
+ $speculation_rules = new WP_Speculation_Rules();
+
+ $this->assertFalse( $speculation_rules->has_rule( 'prerender', 'my-custom-rule' ), 'Custom rule should not be marked as present before it is added' );
+
+ $speculation_rules->add_rule( 'prerender', 'my-custom-rule', array( 'urls' => array( 'https://url-to-prerender.com/' ) ) );
+ $this->assertTrue( $speculation_rules->has_rule( 'prerender', 'my-custom-rule' ), 'Custom rule should be marked as present after it has been added' );
+ $this->assertFalse( $speculation_rules->has_rule( 'prefetch', 'my-custom-rule' ), 'Custom rule should not be marked as present for different mode even after it has been added' );
+ }
+
+ /**
+ * Tests that transforming a speculation rules object into JSON-encodable data works as expected.
+ *
+ * @ticket 62503
+ * @covers ::jsonSerialize
+ */
+ public function test_jsonSerialize() {
+ $prefetch_rule_1 = array( 'where' => array( 'href_matches' => '/*' ) );
+ $prefetch_rule_2 = array( 'where' => array( 'selector_matches' => '.prefetch-opt-in' ) );
+ $prerender_rule_1 = array( 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ) );
+ $prerender_rule_2 = array(
+ 'where' => array(
+ 'or' => array(
+ array( 'selector_matches' => '.prerender-opt-in' ),
+ array( 'selector_matches' => '.prerender-fast' ),
+ ),
+ ),
+ 'eagerness' => 'moderate',
+ );
+
+ $speculation_rules = new WP_Speculation_Rules();
+ $this->assertSame( array(), $speculation_rules->jsonSerialize(), 'Speculation rules JSON data should be empty before adding any rules' );
+
+ $speculation_rules->add_rule( 'prefetch', 'prefetch-rule-1', $prefetch_rule_1 );
+ $this->assertSame(
+ array(
+ 'prefetch' => array( $prefetch_rule_1 ),
+ ),
+ $speculation_rules->jsonSerialize(),
+ 'Speculation rules JSON data should only contain a single "prefetch" entry when only that rule is added'
+ );
+
+ $speculation_rules->add_rule( 'prefetch', 'prefetch-rule-2', $prefetch_rule_2 );
+ $speculation_rules->add_rule( 'prerender', 'prerender-rule-1', $prerender_rule_1 );
+ $speculation_rules->add_rule( 'prerender', 'prerender-rule-2', $prerender_rule_2 );
+ $this->assertSame(
+ array(
+ 'prefetch' => array(
+ $prefetch_rule_1,
+ $prefetch_rule_2,
+ ),
+ 'prerender' => array(
+ $prerender_rule_1,
+ $prerender_rule_2,
+ ),
+ ),
+ $speculation_rules->jsonSerialize(),
+ 'Speculation rules JSON data should contain all added rules'
+ );
+ }
+
+ /**
+ * Tests that the mode validation method correctly identifies valid and invalid values.
+ *
+ * @ticket 62503
+ * @covers ::is_valid_mode
+ * @dataProvider data_is_valid_mode
+ */
+ public function test_is_valid_mode( $mode, $expected ) {
+ if ( $expected ) {
+ $this->assertTrue( WP_Speculation_Rules::is_valid_mode( $mode ) );
+ } else {
+ $this->assertFalse( WP_Speculation_Rules::is_valid_mode( $mode ) );
+ }
+ }
+
+ public static function data_is_valid_mode(): array {
+ return array(
+ 'prefetch' => array( 'prefetch', true ),
+ 'prerender' => array( 'prerender', true ),
+ 'auto' => array( 'auto', false ),
+ 'none' => array( 'none', false ),
+ '42' => array( 42, false ),
+ 'empty string' => array( '', false ),
+ );
+ }
+
+ /**
+ * Tests that the eagerness validation method correctly identifies valid and invalid values.
+ *
+ * @ticket 62503
+ * @covers ::is_valid_eagerness
+ * @dataProvider data_is_valid_eagerness
+ */
+ public function test_is_valid_eagerness( $eagerness, $expected ) {
+ if ( $expected ) {
+ $this->assertTrue( WP_Speculation_Rules::is_valid_eagerness( $eagerness ) );
+ } else {
+ $this->assertFalse( WP_Speculation_Rules::is_valid_eagerness( $eagerness ) );
+ }
+ }
+
+ public static function data_is_valid_eagerness(): array {
+ return array(
+ 'conservative' => array( 'conservative', true ),
+ 'moderate' => array( 'moderate', true ),
+ 'eager' => array( 'eager', true ),
+ 'immediate' => array( 'immediate', true ),
+ 'auto' => array( 'auto', false ),
+ 'none' => array( 'none', false ),
+ '42' => array( 42, false ),
+ 'empty string' => array( '', false ),
+ );
+ }
+
+ /**
+ * Tests that the source validation method correctly identifies valid and invalid values.
+ *
+ * @ticket 62503
+ * @covers ::is_valid_source
+ * @dataProvider data_is_valid_source
+ */
+ public function test_is_valid_source( $source, $expected ) {
+ if ( $expected ) {
+ $this->assertTrue( WP_Speculation_Rules::is_valid_source( $source ) );
+ } else {
+ $this->assertFalse( WP_Speculation_Rules::is_valid_source( $source ) );
+ }
+ }
+
+ public static function data_is_valid_source(): array {
+ return array(
+ 'list' => array( 'list', true ),
+ 'document' => array( 'document', true ),
+ 'auto' => array( 'auto', false ),
+ 'none' => array( 'none', false ),
+ '42' => array( 42, false ),
+ 'empty string' => array( '', false ),
+ );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/speculative-loading/wpSpeculationRules.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsspeculativeloadingwpUrlPatternPrefixerphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/speculative-loading/wpUrlPatternPrefixer.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/speculative-loading/wpUrlPatternPrefixer.php (rev 0)
+++ trunk/tests/phpunit/tests/speculative-loading/wpUrlPatternPrefixer.php 2025-02-18 22:30:05 UTC (rev 59837)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,94 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Tests for the WP_URL_Pattern_Prefixer class.
+ *
+ * @package WordPress
+ * @subpackage Speculative Loading
+ */
+
+/**
+ * @group speculative-loading
+ * @coversDefaultClass WP_URL_Pattern_Prefixer
+ */
+class Tests_Speculative_Loading_wpUrlPatternPrefixer extends WP_UnitTestCase {
+
+ /**
+ * Tests prefixing URL path patterns with a consistent demo context.
+ *
+ * @ticket 62503
+ * @covers ::prefix_path_pattern
+ * @dataProvider data_prefix_path_pattern
+ */
+ public function test_prefix_path_pattern( string $base_path, string $path_pattern, string $expected ) {
+ $p = new WP_URL_Pattern_Prefixer( array( 'demo' => $base_path ) );
+
+ $this->assertSame(
+ $expected,
+ $p->prefix_path_pattern( $path_pattern, 'demo' )
+ );
+ }
+
+ public static function data_prefix_path_pattern(): array {
+ return array(
+ array( '/', '/my-page/', '/my-page/' ),
+ array( '/', 'my-page/', '/my-page/' ),
+ array( '/wp/', '/my-page/', '/wp/my-page/' ),
+ array( '/wp/', 'my-page/', '/wp/my-page/' ),
+ array( '/wp/', '/blog/2023/11/new-post/', '/wp/blog/2023/11/new-post/' ),
+ array( '/wp/', 'blog/2023/11/new-post/', '/wp/blog/2023/11/new-post/' ),
+ array( '/subdir', '/my-page/', '/subdir/my-page/' ),
+ array( '/subdir', 'my-page/', '/subdir/my-page/' ),
+ // Missing trailing slash still works, does not consider "cut-off" directory names.
+ array( '/subdir', '/subdirectory/my-page/', '/subdir/subdirectory/my-page/' ),
+ array( '/subdir', 'subdirectory/my-page/', '/subdir/subdirectory/my-page/' ),
+ // A base path containing a : must be enclosed in braces to avoid confusion.
+ array( '/scope:0/', '/*/foo', '{/scope\\:0}/*/foo' ),
+ );
+ }
+
+ /**
+ * Tests the values of the default URL pattern contexts.
+ *
+ * @ticket 62503
+ * @covers ::get_default_contexts
+ */
+ public function test_get_default_contexts() {
+ $contexts = WP_URL_Pattern_Prefixer::get_default_contexts();
+
+ $this->assertArrayHasKey( 'home', $contexts );
+ $this->assertArrayHasKey( 'site', $contexts );
+ $this->assertSame( '/', $contexts['home'] );
+ $this->assertSame( '/', $contexts['site'] );
+ }
+
+ /**
+ * Tests the values of the default URL pattern contexts when using subdirectories.
+ *
+ * @ticket 62503
+ * @covers ::get_default_contexts
+ * @dataProvider data_default_contexts_with_subdirectories
+ */
+ public function test_get_default_contexts_with_subdirectories( string $context, string $unescaped, string $expected ) {
+ add_filter(
+ $context . '_url',
+ static function () use ( $unescaped ) {
+ return $unescaped;
+ }
+ );
+
+ $contexts = WP_URL_Pattern_Prefixer::get_default_contexts();
+
+ $this->assertArrayHasKey( $context, $contexts );
+ $this->assertSame( $expected, $contexts[ $context ] );
+ }
+
+ public static function data_default_contexts_with_subdirectories(): array {
+ return array(
+ array( 'home', 'https://example.com/subdir/', '/subdir/' ),
+ array( 'site', 'https://example.com/subdir/wp/', '/subdir/wp/' ),
+ // If the context URL has URL pattern special characters it may need escaping.
+ array( 'home', 'https://example.com/scope:0.*/', '/scope\\:0.\\*/' ),
+ array( 'site', 'https://example.com/scope:0.*/wp+/', '/scope\\:0.\\*/wp\\+/' ),
+ );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/speculative-loading/wpUrlPatternPrefixer.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span></div>
</body>
</html>