Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/wp-includes/class-wp-speculation-rules.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ] );
}

/**
Expand Down
92 changes: 90 additions & 2 deletions src/wp-includes/speculative-loading.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>|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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> 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' ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is getenv sometimes not available?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That's right. Some hosts prevent the function from being available, apparently.

You can see this same logic is being used in wp_get_environment_type():

// Check if the environment variable has been set, if `getenv` is available on the system.
if ( function_exists( 'getenv' ) ) {
$has_env = getenv( 'WP_ENVIRONMENT_TYPE' );
if ( false !== $has_env ) {
$current_env = $has_env;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i noticed that and I also noticed multiple instances where we use getenv without the check so I wondered if that was just old code. I still wonder how/why it would be undefined.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Oh yeah, I see that:

function _wp_connectors_get_api_key_source( string $setting_name, string $env_var_name = '', string $constant_name = '' ): string {
// Check environment variable first.
if ( '' !== $env_var_name ) {
$env_value = getenv( $env_var_name );
if ( false !== $env_value && '' !== $env_value ) {
return 'env';
}
}

It may be a situation similar to Core-65423, then. In any case, I think safer to include it here since it would be in the critical path for all frontend page views, whereas the Connectors may only run when on the Connectors page.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm fine being very defensive here and it is possible to disable this function, it just seems.... highly unlikely.

But it doesn't hurt to have the check, so fine to leave it. I was mostly curious why it was there if there was some reason hosts might disable it in a shared environment for security reasons etc, but I guess thats unlikely really and much more likely plugins use it without checking it exists since its been part of php for a long while.

$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;
}
}
Comment on lines +169 to +175

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'm not sure about this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'd keep the is_string() guard as-is. The docblock intentionally follows wp_get_environment_type(), which resolves precedence as defined( 'WP_ENVIRONMENT_TYPE' ) && WP_ENVIRONMENT_TYPE — a truthy constant wins and a falsy/mis-typed one is ignored in favor of the environment variable. The proposed change would invert that: defining the constant to true/1 would silently suppress a valid env var and force Core defaults, which is more surprising, not less.

In practice these constants are always strings, and the only path this affects is a host misconfiguring the type — which the downstream WP_Speculation_Rules::is_valid_mode() / is_valid_eagerness() validation already handles safely by falling back to Core defaults. So the current behavior is both safe and consistent with the cited precedent.

Reply drafted by Claude Code on behalf of @westonruter.


return $value;
}

/**
* Returns the full speculation rules data based on the configuration.
*
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string|false>
*/
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 () {
Expand All @@ -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();
Comment thread
westonruter marked this conversation as resolved.
}

/**
* Tests speculation rules output with prefetch for the different eagerness levels.
*
Expand Down Expand Up @@ -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'] );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string|false>
*/
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();
Comment thread
westonruter marked this conversation as resolved.
}

/**
* Tests that the default configuration is the expected value.
*
Expand Down Expand Up @@ -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() );
}
}
Loading
Loading