diff --git a/src/wp-includes/class-wp-speculation-rules.php b/src/wp-includes/class-wp-speculation-rules.php index bca9e28a383a8..45e6f3ff0be0a 100644 --- a/src/wp-includes/class-wp-speculation-rules.php +++ b/src/wp-includes/class-wp-speculation-rules.php @@ -259,24 +259,30 @@ private function is_valid_id( string $id ): bool { * Checks whether the given speculation rules mode is valid. * * @since 6.8.0 + * @since 7.1.0 The $mode param now allows mixed and not just string. * - * @param string $mode Speculation rules mode. + * @param mixed $mode Speculation rules mode. * @return bool True if valid, false otherwise. + * + * @phpstan-assert-if-true 'prefetch'|'prerender' $mode */ - public static function is_valid_mode( string $mode ): bool { - return isset( self::$mode_allowlist[ $mode ] ); + public static function is_valid_mode( $mode ): bool { + return is_string( $mode ) && isset( self::$mode_allowlist[ $mode ] ); } /** * Checks whether the given speculation rules eagerness is valid. * * @since 6.8.0 + * @since 7.1.0 The $eagerness param now allows mixed and not just string. * - * @param string $eagerness Speculation rules eagerness. + * @param mixed $eagerness Speculation rules eagerness. * @return bool True if valid, false otherwise. + * + * @phpstan-assert-if-true 'conservative'|'moderate'|'eager'|'immediate' $eagerness */ - public static function is_valid_eagerness( string $eagerness ): bool { - return isset( self::$eagerness_allowlist[ $eagerness ] ); + public static function is_valid_eagerness( $eagerness ): bool { + return is_string( $eagerness ) && isset( self::$eagerness_allowlist[ $eagerness ] ); } /** diff --git a/src/wp-includes/speculative-loading.php b/src/wp-includes/speculative-loading.php index 319e04d36ddde..dd33cc5395ab5 100644 --- a/src/wp-includes/speculative-loading.php +++ b/src/wp-includes/speculative-loading.php @@ -11,9 +11,17 @@ * Returns the speculation rules configuration. * * @since 6.8.0 + * @since 7.1.0 The `WP_SPECULATIVE_LOADING_DEFAULT_MODE` and `WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS` constants and + * environment variables can now specify the default mode and eagerness, respectively. + * + * @see wp_get_speculation_rules_default_configuration() * * @return array|null Associative array with 'mode' and 'eagerness' keys, or null if speculative * loading is disabled. + * @phpstan-return array{ + * mode: 'prefetch'|'prerender', + * eagerness: 'conservative'|'moderate'|'eager', + * }|null */ 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. @@ -59,8 +67,10 @@ function wp_get_speculation_rules_configuration(): ?array { } // Sanitize the configuration and replace 'auto' with current defaults. - $default_mode = 'prefetch'; - $default_eagerness = 'conservative'; + $defaults = wp_get_speculation_rules_default_configuration(); + $default_mode = $defaults['mode']; + $default_eagerness = $defaults['eagerness']; + if ( ! is_array( $config ) ) { return array( 'mode' => $default_mode, @@ -90,6 +100,83 @@ function wp_get_speculation_rules_configuration(): ?array { ); } +/** + * Returns the default speculation rules configuration that the value 'auto' resolves to. + * + * WordPress Core defaults to a mode of 'prefetch' and an eagerness of 'conservative'. Hosting providers can override + * either default by way of the `WP_SPECULATIVE_LOADING_DEFAULT_MODE` and `WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS` + * constants or environment variables. This only changes what the 'auto' value resolves to, so a plugin which supplies + * an explicit mode or eagerness via the {@see 'wp_speculation_rules_configuration'} filter continues to take + * precedence. + * + * Note that an eagerness of 'immediate' is not permitted as a default, since WordPress does not allow it for the + * document-level rules that it generates. + * + * @since 7.1.0 + * @access private + * + * @return array Associative array with 'mode' and 'eagerness' keys. + * @phpstan-return array{ + * mode: 'prefetch'|'prerender', + * eagerness: 'conservative'|'moderate'|'eager', + * } + */ +function wp_get_speculation_rules_default_configuration(): array { + $default_mode = 'prefetch'; + $mode = wp_get_speculative_loading_override( 'WP_SPECULATIVE_LOADING_DEFAULT_MODE' ); + if ( WP_Speculation_Rules::is_valid_mode( $mode ) ) { + $default_mode = $mode; + } + + $default_eagerness = 'conservative'; + $eagerness = wp_get_speculative_loading_override( 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS' ); + if ( + WP_Speculation_Rules::is_valid_eagerness( $eagerness ) && + // 'immediate' is a valid eagerness, but for safety WordPress does not allow it for document-level rules. + 'immediate' !== $eagerness + ) { + $default_eagerness = $eagerness; + } + + return array( + 'mode' => $default_mode, + 'eagerness' => $default_eagerness, + ); +} + +/** + * Returns the value of a speculative loading override, as supplied by a constant or an environment variable. + * + * The constant takes precedence over the environment variable, consistent with {@see wp_get_environment_type()}. + * + * @since 7.1.0 + * @access private + * + * @param string $name Name of the constant and environment variable to look up. + * @return string|null The override value, or null if neither is set. + */ +function wp_get_speculative_loading_override( string $name ): ?string { + $value = null; + + // Check if the environment variable has been set, if `getenv` is available on the system. + if ( function_exists( 'getenv' ) ) { + $has_env = getenv( $name ); + if ( false !== $has_env ) { + $value = $has_env; + } + } + + // Fetch the value from a constant, which overrides the environment variable. + if ( defined( $name ) ) { + $has_constant = constant( $name ); + if ( is_string( $has_constant ) ) { + $value = $has_constant; + } + } + + return $value; +} + /** * Returns the full speculation rules data based on the configuration. * @@ -152,6 +239,7 @@ function wp_get_speculation_rules(): ?WP_Speculation_Rules { * @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 ); + $href_exclude_paths = array_filter( $href_exclude_paths, 'is_string' ); // Ensure that: // 1. There are no duplicates. diff --git a/tests/phpunit/tests/speculative-loading/wpGetSpeculationRules.php b/tests/phpunit/tests/speculative-loading/wpGetSpeculationRules.php index a7b5594bdf0d1..a4854db0e41d6 100644 --- a/tests/phpunit/tests/speculative-loading/wpGetSpeculationRules.php +++ b/tests/phpunit/tests/speculative-loading/wpGetSpeculationRules.php @@ -21,9 +21,20 @@ class Tests_Speculative_Loading_wpGetSpeculationRules extends WP_UnitTestCase { 'eagerness' => 'conservative', ); - public function set_up() { + /** + * Stores the original speculative loading env values for cleanup. + * + * @var array + */ + private array $original_env = array(); + + public function set_up(): void { parent::set_up(); + foreach ( array( 'WP_SPECULATIVE_LOADING_DEFAULT_MODE', 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS' ) as $name ) { + $this->original_env[ $name ] = getenv( $name ); + } + add_filter( 'template_directory_uri', static function () { @@ -41,6 +52,18 @@ static function () { update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' ); } + public function tear_down(): void { + foreach ( $this->original_env as $name => $value ) { + if ( false === $value ) { + putenv( $name ); + } else { + putenv( "{$name}={$value}" ); + } + } + + parent::tear_down(); + } + /** * Tests speculation rules output with prefetch for the different eagerness levels. * @@ -558,4 +581,43 @@ function () { $this->assertSame( 'moderate', $rules['prerender'][0]['eagerness'] ); $this->assertSame( 'eager', $rules['prerender'][1]['eagerness'] ); } + + /** + * Tests that an overridden default eagerness is applied to the main document-level rule. + * + * @ticket 65624 + */ + public function test_wp_get_speculation_rules_with_overridden_default_eagerness(): void { + putenv( 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS=moderate' ); + + $rules = wp_get_speculation_rules(); + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + + $rules = $rules->jsonSerialize(); + + $this->assertArrayHasKey( 'prefetch', $rules ); + $this->assertCount( 1, $rules['prefetch'] ); + $this->assertSame( 'moderate', $rules['prefetch'][0]['eagerness'] ); + } + + /** + * Tests that an eagerness of 'immediate' cannot be forced as the default. + * + * WordPress does not allow 'immediate' for the document-level rules it generates, so allowing it as a default + * would cause the main rule to be rejected, leaving no speculation rules at all. + * + * @ticket 65624 + */ + public function test_wp_get_speculation_rules_with_immediate_default_eagerness(): void { + putenv( 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS=immediate' ); + + $rules = wp_get_speculation_rules(); + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + + $rules = $rules->jsonSerialize(); + + $this->assertArrayHasKey( 'prefetch', $rules ); + $this->assertCount( 1, $rules['prefetch'] ); + $this->assertSame( 'conservative', $rules['prefetch'][0]['eagerness'] ); + } } diff --git a/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesConfiguration.php b/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesConfiguration.php index a4eb9c8a6878b..2fb5c300198f8 100644 --- a/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesConfiguration.php +++ b/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesConfiguration.php @@ -12,12 +12,35 @@ */ class Tests_Speculative_Loading_wpGetSpeculationRulesConfiguration extends WP_UnitTestCase { - public function set_up() { + /** + * Stores the original speculative loading env values for cleanup. + * + * @var array + */ + private array $original_env = array(); + + public function set_up(): void { parent::set_up(); + foreach ( array( 'WP_SPECULATIVE_LOADING_DEFAULT_MODE', 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS' ) as $name ) { + $this->original_env[ $name ] = getenv( $name ); + } + update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' ); } + public function tear_down(): void { + foreach ( $this->original_env as $name => $value ) { + if ( false === $value ) { + putenv( $name ); + } else { + putenv( "{$name}={$value}" ); + } + } + + parent::tear_down(); + } + /** * Tests that the default configuration is the expected value. * @@ -264,4 +287,63 @@ public static function data_wp_get_speculation_rules_configuration_filter(): arr ), ); } + + /** + * Tests that an overridden default is what the 'auto' value resolves to. + * + * @ticket 65624 + */ + public function test_wp_get_speculation_rules_configuration_with_overridden_default(): void { + putenv( 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS=moderate' ); + + $this->assertSame( + array( + 'mode' => 'prefetch', + 'eagerness' => 'moderate', + ), + wp_get_speculation_rules_configuration() + ); + } + + /** + * Tests that an explicit eagerness from the filter takes precedence over an overridden default. + * + * This ensures a plugin such as Speculative Loading continues to win over a hosting provider's default. + * + * @ticket 65624 + */ + public function test_wp_get_speculation_rules_configuration_filter_beats_overridden_default(): void { + putenv( 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS=moderate' ); + + add_filter( + 'wp_speculation_rules_configuration', + static function () { + return array( + 'mode' => 'auto', + 'eagerness' => 'conservative', + ); + } + ); + + $this->assertSame( + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + wp_get_speculation_rules_configuration() + ); + } + + /** + * Tests that speculative loading remains disabled when a default is overridden. + * + * @ticket 65624 + */ + public function test_wp_get_speculation_rules_configuration_with_overridden_default_while_disabled(): void { + putenv( 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS=moderate' ); + + add_filter( 'wp_speculation_rules_configuration', '__return_null' ); + + $this->assertNull( wp_get_speculation_rules_configuration() ); + } } diff --git a/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesDefaultConfiguration.php b/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesDefaultConfiguration.php new file mode 100644 index 0000000000000..59a27818c07f7 --- /dev/null +++ b/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesDefaultConfiguration.php @@ -0,0 +1,251 @@ + + */ + private array $original_env = array(); + + public function set_up(): void { + parent::set_up(); + + foreach ( array( 'WP_SPECULATIVE_LOADING_DEFAULT_MODE', 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS' ) as $name ) { + $this->original_env[ $name ] = getenv( $name ); + } + } + + public function tear_down(): void { + foreach ( $this->original_env as $name => $value ) { + if ( false === $value ) { + putenv( $name ); + } else { + putenv( "{$name}={$value}" ); + } + } + + parent::tear_down(); + } + + /** + * Tests that WordPress Core defaults to a conservative prefetch. + * + * @ticket 65624 + */ + public function test_core_defaults(): void { + $this->assertSame( + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + wp_get_speculation_rules_default_configuration() + ); + } + + /** + * Tests that the defaults can be overridden by environment variables. + * + * @ticket 65624 + * @dataProvider data_environment_variables + * + * @param string|null $mode Value for the mode environment variable, or null to leave it unset. + * @param string|null $eagerness Value for the eagerness environment variable, or null to leave it unset. + * @param array $expected Expected default configuration. + */ + public function test_environment_variables( ?string $mode, ?string $eagerness, array $expected ): void { + if ( null !== $mode ) { + putenv( "WP_SPECULATIVE_LOADING_DEFAULT_MODE={$mode}" ); + } + if ( null !== $eagerness ) { + putenv( "WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS={$eagerness}" ); + } + + $this->assertSame( $expected, wp_get_speculation_rules_default_configuration() ); + } + + /** + * Data provider. + * + * @return array Test parameters, keyed by test case name. Each entry consists of the value for the mode environment + * variable, the value for the eagerness environment variable, and the expected default configuration. A + * `null` environment variable value means the variable is left unset. + */ + public static function data_environment_variables(): array { + return array( + 'moderate eagerness' => array( + null, + 'moderate', + array( + 'mode' => 'prefetch', + 'eagerness' => 'moderate', + ), + ), + 'eager eagerness' => array( + null, + 'eager', + array( + 'mode' => 'prefetch', + 'eagerness' => 'eager', + ), + ), + 'prerender mode' => array( + 'prerender', + null, + array( + 'mode' => 'prerender', + 'eagerness' => 'conservative', + ), + ), + 'both mode and eagerness' => array( + 'prerender', + 'moderate', + array( + 'mode' => 'prerender', + 'eagerness' => 'moderate', + ), + ), + /* + * 'immediate' is a valid eagerness, but for safety WordPress does not allow it for document-level rules, + * so it must not be usable as the default either. + */ + 'immediate eagerness' => array( + null, + 'immediate', + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + 'invalid mode' => array( + 'invalid', + null, + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + 'invalid eagerness' => array( + null, + 'invalid', + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + 'empty string' => array( + '', + '', + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + 'invalid mode, valid eagerness' => array( + 'invalid', + 'moderate', + array( + 'mode' => 'prefetch', + 'eagerness' => 'moderate', + ), + ), + 'valid mode, invalid eagerness' => array( + 'prerender', + 'invalid', + array( + 'mode' => 'prerender', + 'eagerness' => 'conservative', + ), + ), + 'wrong case is not normalized' => array( + 'PREFETCH', + 'MODERATE', + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + 'auto is not a valid env value' => array( + 'auto', + 'auto', + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + ); + } + + /** + * Tests that the defaults can be overridden by constants. + * + * @ticket 65624 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_constants(): void { + define( 'WP_SPECULATIVE_LOADING_DEFAULT_MODE', 'prerender' ); + define( 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS', 'moderate' ); + + $this->assertSame( + array( + 'mode' => 'prerender', + 'eagerness' => 'moderate', + ), + wp_get_speculation_rules_default_configuration() + ); + } + + /** + * Tests that a constant takes precedence over an environment variable. + * + * @ticket 65624 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_constant_overrides_environment_variable(): void { + putenv( 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS=eager' ); + define( 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS', 'moderate' ); + + $config = wp_get_speculation_rules_default_configuration(); + + $this->assertSame( 'moderate', $config['eagerness'] ); + } + + /** + * Tests that an invalid constant falls back to the Core default rather than to the environment variable. + * + * @ticket 65624 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_invalid_constant_falls_back_to_core_default(): void { + putenv( 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS=eager' ); + define( 'WP_SPECULATIVE_LOADING_DEFAULT_EAGERNESS', 'invalid' ); + + $config = wp_get_speculation_rules_default_configuration(); + + $this->assertSame( 'conservative', $config['eagerness'] ); + } +}