Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement new module to use Speculation Rules API for prerendering documents on hover #733

Merged
merged 17 commits into from
Dec 15, 2023
Merged
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
5 changes: 5 additions & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,8 @@
/modules/images/dominant-color-images @pbearne @spacedmonkey
/tests/modules/images/dominant-color-images @pbearne @spacedmonkey
/tests/testdata/modules/images/dominant-color-images @pbearne @spacedmonkey

# Module: Speculation Rules
/modules/js-and-css/speculation-rules @felixarntz
/tests/modules/js-and-css/speculation-rules @felixarntz
/tests/testdata/modules/js-and-css/speculation-rules @felixarntz
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php
/**
* Class 'PLSR_URL_Pattern_Prefixer'.
*
* @package performance-lab
* @since n.e.x.t
*/

/**
* Class for prefixing URL patterns.
*
* @since n.e.x.t
*/
class PLSR_URL_Pattern_Prefixer {

/**
* Map of `$context_string => $base_path` pairs.
*
* @since n.e.x.t
* @var array
*/
private $contexts;

/**
* Constructor.
*
* @since n.e.x.t
*
* @param array $contexts Optional. Map of `$context_string => $base_path` pairs. Default is the contexts returned
* by the {@see PLSR_URL_Pattern_Prefixer::get_default_contexts()} method.
*/
public function __construct( array $contexts = array() ) {
if ( $contexts ) {
$this->contexts = array_map( 'trailingslashit', $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 n.e.x.t
*
* @param string $path_pattern URL pattern starting with the path segment.
* @param string $context Optional. Either 'home' (any frontend content) or 'site' (content relative to the
* directory that WordPress is installed in). 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 context %s.', 'performance-lab' ),
$context
)
),
'Performance Lab n.e.x.t'
);
return $path_pattern;
}

// If the path already starts with the context path (including '/'), there is nothing to prefix.
if ( str_starts_with( $path_pattern, $this->contexts[ $context ] ) ) {
return $path_pattern;
}

return $this->contexts[ $context ] . ltrim( $path_pattern, '/' );
}

/**
* Returns the default contexts used by the class.
*
* @since n.e.x.t
*
* @return array Map of `$context_string => $base_path` pairs.
*/
public static function get_default_contexts(): array {
return array(
'home' => trailingslashit( wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ),
'site' => trailingslashit( wp_parse_url( site_url( '/' ), PHP_URL_PATH ) ),
);
}
}
91 changes: 91 additions & 0 deletions modules/js-and-css/speculation-rules/helper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php
/**
* Helper functions used for Speculation Rules.
*
* @package performance-lab
* @since n.e.x.t
*/

/**
* Returns the speculation rules.
*
* Plugins with features that rely on frontend URLs to exclude from prefetching or prerendering should use the
* {@see 'plsr_speculation_rules_href_exclude_paths'} filter to ensure those URL patterns are excluded.
*
* @since n.e.x.t
*
* @return array Associative array of speculation rules by type.
*/
function plsr_get_speculation_rules() {
$prefixer = new PLSR_URL_Pattern_Prefixer();

$base_href_exclude_paths = array(
$prefixer->prefix_path_pattern( '/wp-login.php', 'site' ),
felixarntz marked this conversation as resolved.
Show resolved Hide resolved
$prefixer->prefix_path_pattern( '/wp-admin/*', 'site' ),
felixarntz marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

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

Why exclude all wp-admin paths, wouldn't you want to enable prefetching on those to speed up wp-admin? or is this intended strictly for front end so far?

Copy link
Member Author

Choose a reason for hiding this comment

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

@adamsilverstein Yes, for now this is only frontend. The whole speculation rules tag is also printed in the frontend only.

Doing it in WP Admin has its own set of complexities, and is less beneficial IMO. Those pages are also a whole lot more dynamic, which makes prerendering difficult.

Copy link
Member

Choose a reason for hiding this comment

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

Preloading the editor when hovering over the link in the admin bar would be interesting in the future.

);
Copy link
Member

Choose a reason for hiding this comment

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

What about the other PHP files outside of the normal frontend:

  • wp-activate.php
  • wp-signup.php

Copy link
Member Author

Choose a reason for hiding this comment

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

I was thinking about those too, but left them out for now. We can probably add them, but I was thinking that it's not critical and could be done in its own issue once we have a first version of the module merged (i.e. this PR).

$href_exclude_paths = $base_href_exclude_paths;

/**
* 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.
*
* @since n.e.x.t
*
* @param array $href_exclude_paths Paths to disable speculative prerendering for.
*/
$href_exclude_paths = (array) apply_filters( 'plsr_speculation_rules_href_exclude_paths', $href_exclude_paths );

// Ensure that there are no duplicates and that the base paths cannot be removed.
$href_exclude_paths = array_unique(
array_map(
static function ( $exclude_path ) use ( $prefixer ) {
$exclude_path = $prefixer->prefix_path_pattern( $exclude_path );

/*
* TODO: Remove this eventually as it's no longer needed in Chrome 121+.
* See:
* * https://github.com/whatwg/urlpattern/issues/179
* * https://chromium-review.googlesource.com/c/chromium/src/+/4975595
*/
return $exclude_path . '\\?*#*';
},
array_merge(
$base_href_exclude_paths,
$href_exclude_paths
)
)
);

$prerender_rules = array(
array(
'source' => 'document',
'where' => array(
'and' => array(
// Prerender any URLs within the same site.
array(
'href_matches' => $prefixer->prefix_path_pattern( '/*\\?*' ),
),
// Except for WP login and admin URLs.
array(
'not' => array(
'href_matches' => $href_exclude_paths,
),
),
// And except for any links marked with a class to not prerender.
array(
'not' => array(
'selector_matches' => '.no-prerender',
felixarntz marked this conversation as resolved.
Show resolved Hide resolved
),
),
),
),
'eagerness' => 'moderate',
),
);

return array( 'prerender' => $prerender_rules );
felixarntz marked this conversation as resolved.
Show resolved Hide resolved
}
49 changes: 49 additions & 0 deletions modules/js-and-css/speculation-rules/hooks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
/**
* Hook callbacks used for Speculation Rules.
*
* @package performance-lab
* @since n.e.x.t
*/

/**
* Prints the speculation rules.
*
* For browsers that do not support speculation rules yet, the `script[type="speculationrules"]` tag will be ignored.
*
* @since n.e.x.t
*/
function plsr_print_speculation_rules() {
$rules = plsr_get_speculation_rules();
if ( empty( $rules ) ) {
return;
}

wp_print_inline_script_tag(
wp_json_encode( $rules ),
array( 'type' => 'speculationrules' )
);
}
add_action( 'wp_footer', 'plsr_print_speculation_rules' );

/**
* Prints the tag to opt in to the Chrome origin trial if the token constant is defined.
*
* After opting in to the origin trial via https://github.com/WICG/nav-speculation/blob/main/chrome-2023q1-experiment-overview.md,
* please set your token in a `PLSR_ORIGIN_TRIAL_TOKEN` constant, e.g. in `wp-config.php`.
*
* This function is here temporarily and will eventually be removed.
*
* @since n.e.x.t
* @access private
* @ignore
*/
function plsr_print_origin_trial_opt_in() {
if ( ! defined( 'PLSR_ORIGIN_TRIAL_TOKEN' ) || ! PLSR_ORIGIN_TRIAL_TOKEN ) {
return;
}
?>
<meta http-equiv="origin-trial" content="<?php echo esc_attr( PLSR_ORIGIN_TRIAL_TOKEN ); ?>">
<?php
}
add_action( 'wp_head', 'plsr_print_origin_trial_opt_in' );
20 changes: 20 additions & 0 deletions modules/js-and-css/speculation-rules/load.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php
/**
* Module Name: Speculation Rules
* Description: Uses the Speculation Rules API to prerender linked URLs upon hover.
* Experimental: Yes
*
* @package performance-lab
* @since n.e.x.t
*/

// Define the constant.
if ( defined( 'SPECULATION_RULES_VERSION' ) ) {
return;
}

define( 'SPECULATION_RULES_VERSION', 'Performance Lab ' . PERFLAB_VERSION );

require_once __DIR__ . '/class-plsr-url-pattern-prefixer.php';
require_once __DIR__ . '/helper.php';
require_once __DIR__ . '/hooks.php';
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php
/**
* Tests for PLSR_URL_Pattern_Prefixer class.
*
* @package performance-lab
* @group speculation-rules
*/

class PLSR_URL_Pattern_Prefixer_Tests extends WP_UnitTestCase {

/**
* @dataProvider data_prefix_path_pattern
*/
public function test_prefix_path_pattern( $base_path, $path_pattern, $expected ) {
$p = new PLSR_URL_Pattern_Prefixer( array( 'demo' => $base_path ) );

$this->assertSame(
$expected,
$p->prefix_path_pattern( $path_pattern, 'demo' )
);
}

public function data_prefix_path_pattern() {
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/' ),
);
}

public function test_get_default_contexts() {
$contexts = PLSR_URL_Pattern_Prefixer::get_default_contexts();

$this->assertArrayHasKey( 'home', $contexts );
$this->assertArrayHasKey( 'site', $contexts );
$this->assertSame( '/', $contexts['home'] );
$this->assertSame( '/', $contexts['site'] );
}

public function test_get_default_contexts_with_subdirectories() {
add_filter(
'home_url',
static function () {
return 'https://example.com/subdir/';
}
);
add_filter(
'site_url',
static function () {
return 'https://example.com/subdir/wp/';
}
);

$contexts = PLSR_URL_Pattern_Prefixer::get_default_contexts();

$this->assertArrayHasKey( 'home', $contexts );
$this->assertArrayHasKey( 'site', $contexts );
$this->assertSame( '/subdir/', $contexts['home'] );
$this->assertSame( '/subdir/wp/', $contexts['site'] );
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php
/**
* Tests for speculation-rules helper file.
*
* @package performance-lab
* @group speculation-rules
*/

class Speculation_Rules_Helper_Tests extends WP_UnitTestCase {

public function test_plsr_get_speculation_rules() {
$rules = plsr_get_speculation_rules();

$this->assertIsArray( $rules );
$this->assertArrayHasKey( 'prerender', $rules );
$this->assertIsArray( $rules['prerender'] );
foreach ( $rules['prerender'] as $entry ) {
$this->assertIsArray( $entry );
$this->assertArrayHasKey( 'source', $entry );
$this->assertTrue( in_array( $entry['source'], array( 'list', 'document' ), true ) );
}
}

public function test_plsr_get_speculation_rules_href_exclude_paths() {
$rules = plsr_get_speculation_rules();
$href_exclude_paths = $rules['prerender'][0]['where']['and'][1]['not']['href_matches'];

$this->assertSameSets(
array(
'/wp-login.php\\?*#*',
'/wp-admin/*\\?*#*',
),
$href_exclude_paths
);

// Add filter that attempts to replace base exclude paths with a custom path to exclude.
add_filter(
'plsr_speculation_rules_href_exclude_paths',
static function () {
return array( 'custom-file.php' );
}
);

$rules = plsr_get_speculation_rules();
$href_exclude_paths = $rules['prerender'][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-login.php\\?*#*',
'/wp-admin/*\\?*#*',
'/custom-file.php\\?*#*'
),
$href_exclude_paths
);
}
}
Loading