diff --git a/plugins/speculation-rules/helper.php b/plugins/speculation-rules/helper.php index 298599fb63..7996197adb 100644 --- a/plugins/speculation-rules/helper.php +++ b/plugins/speculation-rules/helper.php @@ -29,9 +29,8 @@ 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' ), @@ -39,11 +38,20 @@ function plsr_get_speculation_rules(): array { $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. * diff --git a/plugins/speculation-rules/hooks.php b/plugins/speculation-rules/hooks.php index fa478b9843..86c7fed304 100644 --- a/plugins/speculation-rules/hooks.php +++ b/plugins/speculation-rules/hooks.php @@ -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' ) diff --git a/plugins/speculation-rules/readme.txt b/plugins/speculation-rules/readme.txt index 7802d57bfc..0e665fbfe4 100644 --- a/plugins/speculation-rules/readme.txt +++ b/plugins/speculation-rules/readme.txt @@ -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: + +` +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 ) @@ -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 ) @@ -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 ) @@ -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 ) @@ -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 ) @@ -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 ) diff --git a/plugins/speculation-rules/tests/test-speculation-rules.php b/plugins/speculation-rules/tests/test-speculation-rules.php index caf2d9cb58..d9a3d38382 100644 --- a/plugins/speculation-rules/tests/test-speculation-rules.php +++ b/plugins/speculation-rules/tests/test-speculation-rules.php @@ -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( '