Skip to content

Commit

Permalink
Merge pull request #1784 from WordPress/enhance/1157-speculation-rule…
Browse files Browse the repository at this point in the history
…s-considerations

Implement speculative loading considerations for safer behavior
  • Loading branch information
felixarntz authored Jan 9, 2025
2 parents d656e11 + 4898dfc commit b6e5c5a
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 59 deletions.
14 changes: 11 additions & 3 deletions plugins/speculation-rules/helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,29 @@ function plsr_get_speculation_rules(): array {
$prefixer = new PLSR_URL_Pattern_Prefixer();

$base_href_exclude_paths = array(
$prefixer->prefix_path_pattern( '/wp-login.php', 'site' ),
$prefixer->prefix_path_pattern( '/wp-*.php', 'site' ),
$prefixer->prefix_path_pattern( '/wp-admin/*', 'site' ),
$prefixer->prefix_path_pattern( '/*\\?*(^|&)_wpnonce=*', 'home' ),
$prefixer->prefix_path_pattern( '/*', 'uploads' ),
$prefixer->prefix_path_pattern( '/*', 'content' ),
$prefixer->prefix_path_pattern( '/*', 'plugins' ),
$prefixer->prefix_path_pattern( '/*', 'template' ),
$prefixer->prefix_path_pattern( '/*', 'stylesheet' ),
);

/*
* If pretty permalinks are enabled, exclude any URLs with query parameters.
* Otherwise, exclude specifically the URLs with a `_wpnonce` query parameter.
*/
if ( (bool) get_option( 'permalink_structure' ) ) {
$base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?(.+)', 'home' );
} else {
$base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?*(^|&)_wpnonce=*', 'home' );
}

/**
* Filters the paths for which speculative prerendering should be disabled.
*
* All paths should start in a forward slash, relative to the root document. The `*` can be used as a wildcard.
* By default, the array includes `/wp-login.php` and `/wp-admin/*`.
*
* If the WordPress site is in a subdirectory, the exclude paths will automatically be prefixed as necessary.
*
Expand Down
28 changes: 28 additions & 0 deletions plugins/speculation-rules/hooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,34 @@
* @since 1.0.0
*/
function plsr_print_speculation_rules(): void {
// Skip speculative loading for logged-in users.
if ( is_user_logged_in() ) {
return;
}

// Skip speculative loading for sites without pretty permalinks, unless explicitly enabled.
if ( ! (bool) get_option( 'permalink_structure' ) ) {
/**
* Filters whether speculative loading should be enabled even though the site does not use pretty permalinks.
*
* Since query parameters are commonly used by plugins for dynamic behavior that can change state, ideally any
* such URLs are excluded from speculative loading. If the site does not use pretty permalinks though, they are
* impossible to recognize. Therefore speculative loading is disabled by default for those sites.
*
* For site owners of sites without pretty permalinks that are certain their site is not using such a pattern,
* this filter can be used to still enable speculative loading at their own risk.
*
* @since n.e.x.t
*
* @param bool $enabled Whether speculative loading is enabled even without pretty permalinks.
*/
$enabled = (bool) apply_filters( 'plsr_enabled_without_pretty_permalinks', false );

if ( ! $enabled ) {
return;
}
}

wp_print_inline_script_tag(
(string) wp_json_encode( plsr_get_speculation_rules() ),
array( 'type' => 'speculationrules' )
Expand Down
11 changes: 11 additions & 0 deletions plugins/speculation-rules/readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ add_filter(

As mentioned above, adding the `no-prerender` CSS class to a link will prevent it from being prerendered (but not prefetched). Additionally, links with `rel=nofollow` will neither be prefetched nor prerendered because some plugins add this to non-idempotent links (e.g. add to cart); such links ideally should rather be buttons which trigger a POST request or at least they should use `wp_nonce_url()`.

= Are there any special considerations for speculative loading behavior? =

For safety reasons, the entire speculative loading feature is disabled by default for logged-in users and for sites that do not use pretty permalinks. The latter is the case because plugins often use URLs with custom query parameters to let users perform actions, and such URLs should not be speculatively loaded. For sites without pretty permalinks, it is impossible or at least extremely complex to differentiate between which query parameters are Core defaults and which query parameters are custom.

If you are running this plugin on a site without pretty permalinks and are confident that there are no custom query parameters in use that can cause state changes, you can opt in to enabling speculative loading via the `plsr_enabled_without_pretty_permalinks` filter:

`
<?php
add_filter( 'plsr_enabled_without_pretty_permalinks', '__return_true' );
`

= How will this impact analytics and personalization? =

Prerendering can affect analytics and personalization.
Expand Down
137 changes: 81 additions & 56 deletions plugins/speculation-rules/tests/test-speculation-rules-helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,14 @@ public function test_plsr_get_speculation_rules_href_exclude_paths(): void {

$this->assertSameSets(
array(
0 => '/wp-login.php',
1 => '/wp-admin/*',
2 => '/*\\?*(^|&)_wpnonce=*',
3 => '/wp-content/uploads/*',
4 => '/wp-content/*',
5 => '/wp-content/plugins/*',
6 => '/wp-content/themes/stylesheet/*',
7 => '/wp-content/themes/template/*',
'/wp-*.php',
'/wp-admin/*',
'/wp-content/uploads/*',
'/wp-content/*',
'/wp-content/plugins/*',
'/wp-content/themes/stylesheet/*',
'/wp-content/themes/template/*',
'/*\\?*(^|&)_wpnonce=*',
),
$href_exclude_paths,
'Snapshot: ' . var_export( $href_exclude_paths, true )
Expand All @@ -79,15 +79,40 @@ static function () {
// Ensure the base exclude paths are still present and that the custom path was formatted correctly.
$this->assertSameSets(
array(
0 => '/wp-login.php',
1 => '/wp-admin/*',
2 => '/*\\?*(^|&)_wpnonce=*',
3 => '/wp-content/uploads/*',
4 => '/wp-content/*',
5 => '/wp-content/plugins/*',
6 => '/wp-content/themes/stylesheet/*',
7 => '/wp-content/themes/template/*',
8 => '/custom-file.php',
'/wp-*.php',
'/wp-admin/*',
'/wp-content/uploads/*',
'/wp-content/*',
'/wp-content/plugins/*',
'/wp-content/themes/stylesheet/*',
'/wp-content/themes/template/*',
'/*\\?*(^|&)_wpnonce=*',
'/custom-file.php',
),
$href_exclude_paths,
'Snapshot: ' . var_export( $href_exclude_paths, true )
);
}

/**
* @covers ::plsr_get_speculation_rules
*/
public function test_plsr_get_speculation_rules_href_exclude_paths_with_pretty_permalinks(): void {
update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' );

$rules = plsr_get_speculation_rules();
$href_exclude_paths = $rules['prerender'][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 )
Expand Down Expand Up @@ -118,15 +143,15 @@ static function ( $exclude_paths, $mode ) {
// Also ensure keys are sequential starting from 0 (that is, that array_is_list()).
$this->assertSame(
array(
0 => '/wp-login.php',
1 => '/wp-admin/*',
2 => '/*\\?*(^|&)_wpnonce=*',
3 => '/wp-content/uploads/*',
4 => '/wp-content/*',
5 => '/wp-content/plugins/*',
6 => '/wp-content/themes/stylesheet/*',
7 => '/wp-content/themes/template/*',
8 => '/products/*',
'/wp-*.php',
'/wp-admin/*',
'/wp-content/uploads/*',
'/wp-content/*',
'/wp-content/plugins/*',
'/wp-content/themes/stylesheet/*',
'/wp-content/themes/template/*',
'/*\\?*(^|&)_wpnonce=*',
'/products/*',
),
$href_exclude_paths,
'Snapshot: ' . var_export( $href_exclude_paths, true )
Expand All @@ -141,14 +166,14 @@ static function ( $exclude_paths, $mode ) {
// Ensure the additional exclusion is not present because the mode is 'prefetch'.
$this->assertSame(
array(
0 => '/wp-login.php',
1 => '/wp-admin/*',
2 => '/*\\?*(^|&)_wpnonce=*',
3 => '/wp-content/uploads/*',
4 => '/wp-content/*',
5 => '/wp-content/plugins/*',
6 => '/wp-content/themes/stylesheet/*',
7 => '/wp-content/themes/template/*',
'/wp-*.php',
'/wp-admin/*',
'/wp-content/uploads/*',
'/wp-content/*',
'/wp-content/plugins/*',
'/wp-content/themes/stylesheet/*',
'/wp-content/themes/template/*',
'/*\\?*(^|&)_wpnonce=*',
),
$href_exclude_paths,
'Snapshot: ' . var_export( $href_exclude_paths, true )
Expand Down Expand Up @@ -177,19 +202,19 @@ static function ( array $exclude_paths ): array {
$actual = plsr_get_speculation_rules()['prerender'][0]['where']['and'][1]['not']['href_matches'];
$this->assertSame(
array(
0 => '/wp-login.php',
1 => '/wp-admin/*',
2 => '/*\\?*(^|&)_wpnonce=*',
3 => '/wp-content/uploads/*',
4 => '/wp-content/*',
5 => '/wp-content/plugins/*',
6 => '/wp-content/themes/stylesheet/*',
7 => '/wp-content/themes/template/*',
8 => '/unshifted/',
9 => '/next/',
10 => '/negative-one/',
11 => '/one-hundred/',
12 => '/letter-a/',
'/wp-*.php',
'/wp-admin/*',
'/wp-content/uploads/*',
'/wp-content/*',
'/wp-content/plugins/*',
'/wp-content/themes/stylesheet/*',
'/wp-content/themes/template/*',
'/*\\?*(^|&)_wpnonce=*',
'/unshifted/',
'/next/',
'/negative-one/',
'/one-hundred/',
'/letter-a/',
),
$actual,
'Snapshot: ' . var_export( $actual, true )
Expand Down Expand Up @@ -225,15 +250,15 @@ static function ( array $exclude_paths ): array {
$actual = plsr_get_speculation_rules()['prerender'][0]['where']['and'][1]['not']['href_matches'];
$this->assertSame(
array(
0 => '/wp/wp-login.php',
1 => '/wp/wp-admin/*',
2 => '/blog/*\\?*(^|&)_wpnonce=*',
3 => '/wp-content/uploads/*',
4 => '/wp-content/*',
5 => '/wp-content/plugins/*',
6 => '/wp-content/themes/stylesheet/*',
7 => '/wp-content/themes/template/*',
8 => '/blog/store/*',
'/wp/wp-*.php',
'/wp/wp-admin/*',
'/wp-content/uploads/*',
'/wp-content/*',
'/wp-content/plugins/*',
'/wp-content/themes/stylesheet/*',
'/wp-content/themes/template/*',
'/blog/*\\?*(^|&)_wpnonce=*',
'/blog/store/*',
),
$actual,
'Snapshot: ' . var_export( $actual, true )
Expand Down
42 changes: 42 additions & 0 deletions plugins/speculation-rules/tests/test-speculation-rules.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public function test_hooks(): void {
* @covers ::plsr_print_speculation_rules
*/
public function test_plsr_print_speculation_rules_without_html5_support(): void {
$this->enable_pretty_permalinks();

$output = get_echo( 'plsr_print_speculation_rules' );
$this->assertStringContainsString( '<script type="speculationrules">', $output );

Expand All @@ -38,6 +40,38 @@ public function test_plsr_print_speculation_rules_without_html5_support(): void
$this->assertArrayHasKey( 'prerender', $rules );
}

/**
* @covers ::plsr_print_speculation_rules
*/
public function test_plsr_print_speculation_rules_without_pretty_permalinks(): void {
$this->disable_pretty_permalinks();

$output = get_echo( 'plsr_print_speculation_rules' );
$this->assertSame( '', $output );
}

/**
* @covers ::plsr_print_speculation_rules
*/
public function test_plsr_print_speculation_rules_without_pretty_permalinks_but_opted_in(): void {
$this->disable_pretty_permalinks();
add_filter( 'plsr_enabled_without_pretty_permalinks', '__return_true' );

$output = get_echo( 'plsr_print_speculation_rules' );
$this->assertStringContainsString( '<script type="speculationrules">', $output );
}

/**
* @covers ::plsr_print_speculation_rules
*/
public function test_plsr_print_speculation_rules_for_logged_in_user(): void {
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
$this->enable_pretty_permalinks();

$output = get_echo( 'plsr_print_speculation_rules' );
$this->assertSame( '', $output );
}

/**
* Test printing the meta generator tag.
*
Expand All @@ -49,4 +83,12 @@ public function test_plsr_render_generator_meta_tag(): void {
$this->assertStringContainsString( 'generator', $tag );
$this->assertStringContainsString( 'speculation-rules ' . SPECULATION_RULES_VERSION, $tag );
}

private function enable_pretty_permalinks(): void {
update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' );
}

private function disable_pretty_permalinks(): void {
update_option( 'permalink_structure', '' );
}
}

0 comments on commit b6e5c5a

Please sign in to comment.