From beabd7a8fba27fd804f0e313ceacd65d487567f1 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Fri, 5 Aug 2022 18:24:42 +0000 Subject: [PATCH] Script loader: enable resource preloading with rel='preload'. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `wp_preload_resources` filter that developers can use to add resource preloading. Preloading helps the browser discover and prioritize important resources earlier during the page load. This ensures that they are available earlier and are less likely to block the page's render. Props nico23, swissspidy, igrigorik, westonruter, azaozz, furi3r, aristath, spacedmonkey, peterwilsoncc, mihai2u, gziolo.  Fixes #42438. git-svn-id: https://develop.svn.wordpress.org/trunk@53846 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/default-filters.php | 1 + src/wp-includes/general-template.php | 114 ++++++++ .../phpunit/tests/general/wpPreloadLinks.php | 253 ++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 tests/phpunit/tests/general/wpPreloadLinks.php diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 25d98ede38..4933e3f88f 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -324,6 +324,7 @@ add_filter( 'rest_authentication_errors', 'rest_cookie_check_errors', 100 ); add_action( 'wp_head', '_wp_render_title_tag', 1 ); add_action( 'wp_head', 'wp_enqueue_scripts', 1 ); add_action( 'wp_head', 'wp_resource_hints', 2 ); +add_action( 'wp_head', 'wp_preload_resources', 1 ); add_action( 'wp_head', 'feed_links', 2 ); add_action( 'wp_head', 'feed_links_extra', 3 ); add_action( 'wp_head', 'rsd_link' ); diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index c91cd48efa..0a12a590cf 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -3423,6 +3423,120 @@ function wp_resource_hints() { } } +/** + * Prints resource preloads directives to browsers. + * + * Gives directive to browsers to preload specific resources that website will + * need very soon, this ensures that they are available earlier and are less + * likely to block the page's render. Preload directives should not be used for + * non-render-blocking elements, as then they would compete with the + * render-blocking ones, slowing down the render. + * + * These performance improving indicators work by using ``. + * + * @link https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/preload + * @link https://web.dev/preload-responsive-images/ + * + * @since 6.1.0 + */ +function wp_preload_resources() { + /** + * Filters domains and URLs for resource preloads. + * + * @since 6.1.0 + * + * @param array $preload_resources { + * Array of resources and their attributes, or URLs to print for resource preloads. + * + * @type array ...$0 { + * Array of resource attributes. + * + * @type string $href URL to include in resource preloads. Required. + * @type string $as How the browser should treat the resource + * (`script`, `style`, `image`, `document`, etc). + * @type string $crossorigin Indicates the CORS policy of the specified resource. + * @type string $type Type of the resource (`text/html`, `text/css`, etc). + * @type string $media Accepts media types or media queries. Allows responsive preloading. + * @type string $imagesizes Responsive source size to the source Set. + * @type string $imagesrcset Responsive image sources to the source set. + * } + * } + */ + $preload_resources = apply_filters( 'wp_preload_resources', array() ); + + if ( ! is_array( $preload_resources ) ) { + return; + } + + $unique_resources = array(); + + // Parse the complete resource list and extract unique resources. + foreach ( $preload_resources as $resource ) { + if ( ! is_array( $resource ) ) { + continue; + } + + $attributes = $resource; + if ( isset( $resource['href'] ) ) { + $href = $resource['href']; + if ( isset( $unique_resources[ $href ] ) ) { + continue; + } + $unique_resources[ $href ] = $attributes; + // Media can use imagesrcset and not href. + } elseif ( ( 'image' === $resource['as'] ) && + ( isset( $resource['imagesrcset'] ) || isset( $resource['imagesizes'] ) ) + ) { + if ( isset( $unique_resources[ $resource['imagesrcset'] ] ) ) { + continue; + } + $unique_resources[ $resource['imagesrcset'] ] = $attributes; + } else { + continue; + } + } + + // Build and output the HTML for each unique resource. + foreach ( $unique_resources as $unique_resource ) { + $html = ''; + + foreach ( $unique_resource as $resource_key => $resource_value ) { + if ( ! is_scalar( $resource_value ) ) { + continue; + } + + // Ignore non-supported attributes. + $non_supported_attributes = array( 'as', 'crossorigin', 'href', 'imagesrcset', 'imagesizes', 'type', 'media' ); + if ( ! in_array( $resource_key, $non_supported_attributes, true ) && ! is_numeric( $resource_key ) ) { + continue; + } + + // imagesrcset only usable when preloading image, ignore otherwise. + if ( ( 'imagesrcset' === $resource_key ) && ( ! isset( $unique_resource['as'] ) || ( 'image' !== $unique_resource['as'] ) ) ) { + continue; + } + + // imagesizes only usable when preloading image and imagesrcset present, ignore otherwise. + if ( ( 'imagesizes' === $resource_key ) && + ( ! isset( $unique_resource['as'] ) || ( 'image' !== $unique_resource['as'] ) || ! isset( $unique_resource['imagesrcset'] ) ) + ) { + continue; + } + + $resource_value = ( 'href' === $resource_key ) ? esc_url( $resource_value, array( 'http', 'https' ) ) : esc_attr( $resource_value ); + + if ( ! is_string( $resource_key ) ) { + $html .= " $resource_value"; + } else { + $html .= " $resource_key='$resource_value'"; + } + } + $html = trim( $html ); + + printf( "\n", $html ); + } +} + /** * Retrieves a list of unique hosts of all enqueued scripts and styles. * diff --git a/tests/phpunit/tests/general/wpPreloadLinks.php b/tests/phpunit/tests/general/wpPreloadLinks.php new file mode 100644 index 0000000000..d9f4150088 --- /dev/null +++ b/tests/phpunit/tests/general/wpPreloadLinks.php @@ -0,0 +1,253 @@ +assertSame( $expected, $actual ); + } + + /** + * Test provider for all preload link possible combinations. + * + * @return array[] + */ + public function data_preload_resources() { + return array( + 'basic_preload' => array( + 'expected' => "\n", + 'urls' => array( + array( + 'href' => 'https://example.com/style.css', + 'as' => 'style', + ), + ), + ), + 'multiple_links' => array( + 'expected' => "\n" . + "\n", + 'urls' => array( + array( + 'href' => 'https://example.com/style.css', + 'as' => 'style', + ), + array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + ), + ), + ), + 'MIME_types' => array( + 'expected' => "\n" . + "\n" . + "\n", + 'urls' => array( + array( + // Should ignore not valid attributes. + 'not' => 'valid', + 'href' => 'https://example.com/style.css', + 'as' => 'style', + ), + array( + 'href' => 'https://example.com/video.mp4', + 'as' => 'video', + 'type' => 'video/mp4', + ), + array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + ), + ), + ), + 'CORS' => array( + 'expected' => "\n" . + "\n" . + "\n" . + "\n", + 'urls' => array( + array( + 'href' => 'https://example.com/style.css', + 'as' => 'style', + 'crossorigin' => 'anonymous', + ), + array( + 'href' => 'https://example.com/video.mp4', + 'as' => 'video', + 'type' => 'video/mp4', + ), + array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + ), + array( + // Should ignore not valid attributes. + 'ignore' => 'ignore', + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'type' => 'font/woff2', + 'crossorigin', + ), + ), + ), + 'media' => array( + 'expected' => "\n" . + "\n" . + "\n" . + "\n" . + "\n" . + "\n", + 'urls' => array( + array( + 'href' => 'https://example.com/style.css', + 'as' => 'style', + 'crossorigin' => 'anonymous', + ), + array( + 'href' => 'https://example.com/video.mp4', + 'as' => 'video', + 'type' => 'video/mp4', + ), + // Duplicated href should be ignored. + array( + 'href' => 'https://example.com/video.mp4', + 'as' => 'video', + 'type' => 'video/mp4', + ), + array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + ), + array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'type' => 'font/woff2', + 'crossorigin', + ), + array( + 'href' => 'https://example.com/image-narrow.png', + 'as' => 'image', + 'media' => '(max-width: 600px)', + ), + array( + 'href' => 'https://example.com/image-wide.png', + 'as' => 'image', + 'media' => '(min-width: 601px)', + ), + + ), + ), + 'media_extra_attributes' => array( + 'expected' => "\n" . + "\n" . + "\n" . + "\n" . + "\n" . + "\n" . + "\n" . + "\n", + 'urls' => array( + array( + 'href' => 'https://example.com/style.css', + 'as' => 'style', + 'crossorigin' => 'anonymous', + ), + array( + 'href' => 'https://example.com/video.mp4', + 'as' => 'video', + 'type' => 'video/mp4', + ), + array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + ), + array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'type' => 'font/woff2', + 'crossorigin', + ), + // imagesrcset only possible when using image, ignore. + array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'type' => 'font/woff2', + 'imagesrcset' => '640.png 640w, 800.png 800w, 1024.png 1024w', + ), + // imagesizes only possible when using image, ignore. + array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'type' => 'font/woff2', + 'imagesizes' => '100vw', + ), + // Duplicated href should be ignored. + array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'type' => 'font/woff2', + 'crossorigin', + ), + array( + 'href' => 'https://example.com/image-640.png', + 'as' => 'image', + 'imagesrcset' => '640.png 640w, 800.png 800w, 1024.png 1024w', + 'imagesizes' => '100vw', + ), + // Omit href so that unsupporting browsers won't request a useless image. + array( + 'as' => 'image', + 'imagesrcset' => '640.png 640w, 800.png 800w, 1024.png 1024w', + 'imagesizes' => '100vw', + ), + // Duplicated imagesrcset should be ignored. + array( + 'as' => 'image', + 'imagesrcset' => '640.png 640w, 800.png 800w, 1024.png 1024w', + 'imagesizes' => '100vw', + ), + array( + 'href' => 'https://example.com/image-wide.png', + 'as' => 'image', + 'media' => '(min-width: 601px)', + ), + // No href but not imagesrcset, should be ignored. + array( + 'as' => 'image', + 'media' => '(min-width: 601px)', + ), + // imagesizes is optional. + array( + 'href' => 'https://example.com/image-800.png', + 'as' => 'image', + 'imagesrcset' => '640.png 640w, 800.png 800w, 1024.png 1024w', + ), + // imagesizes should be ignored since imagesrcset not present. + array( + 'href' => 'https://example.com/image-640.png', + 'as' => 'image', + 'imagesizes' => '100vw', + ), + ), + ), + ); + } + +}