Skip to content

Commit

Permalink
Merge pull request #898 from WordPress/add/ilo-tests
Browse files Browse the repository at this point in the history
Add PHPUnit tests for Image Loading Optimization
  • Loading branch information
westonruter committed Jan 12, 2024
2 parents f4b13ad + d62d710 commit 6433bc1
Show file tree
Hide file tree
Showing 18 changed files with 2,415 additions and 253 deletions.
1 change: 1 addition & 0 deletions composer.json
Expand Up @@ -28,6 +28,7 @@
"require": {
"composer/installers": "~1.0",
"php": ">=7|^8",
"ext-dom": "*",
"ext-json": "*"
},
"scripts": {
Expand Down
266 changes: 162 additions & 104 deletions composer.lock

Large diffs are not rendered by default.

Expand Up @@ -132,6 +132,8 @@ public function __construct( string $html ) {
* A generator is used so that when iterating at a specific tag, additional information about the tag at that point
* can be queried from the class. Similarly, mutations may be performed when iterating at an open tag.
*
* @since n.e.x.t
*
* @return Generator<string> Tag name of current open tag.
*/
public function open_tags(): Generator {
Expand Down Expand Up @@ -191,7 +193,11 @@ public function open_tags(): Generator {
yield $tag_name;

// Immediately pop off self-closing tags.
if ( in_array( $tag_name, self::VOID_TAGS, true ) ) {
if (
in_array( $tag_name, self::VOID_TAGS, true )
||
( $p->has_self_closing_flag() && $this->is_foreign_element() )
) {
array_pop( $this->open_stack_tags );
}
} else {
Expand All @@ -200,32 +206,21 @@ public function open_tags(): Generator {
continue;
}

// Since SVG and MathML can have a lot more self-closing/empty tags, potentially pop off the stack until getting to the open tag.
$did_splice = false;
if ( 'SVG' === $tag_name || 'MATH' === $tag_name ) {
$i = array_search( $tag_name, $this->open_stack_tags, true );
if ( false !== $i ) {
array_splice( $this->open_stack_tags, $i );
$did_splice = true;
}
}

if ( ! $did_splice ) {
$popped_tag_name = array_pop( $this->open_stack_tags );
if ( $popped_tag_name !== $tag_name && function_exists( 'wp_trigger_error' ) ) {
wp_trigger_error(
__METHOD__,
esc_html(
sprintf(
/* translators: 1: Popped tag name, 2: Closing tag name */
__( 'Expected popped tag stack element %1$s to match the currently visited closing tag %2$s.', 'performance-lab' ),
$popped_tag_name,
$tag_name
)
$popped_tag_name = array_pop( $this->open_stack_tags );
if ( $popped_tag_name !== $tag_name && function_exists( 'wp_trigger_error' ) ) {
wp_trigger_error(
__METHOD__,
esc_html(
sprintf(
/* translators: 1: Popped tag name, 2: Closing tag name */
__( 'Expected popped tag stack element %1$s to match the currently visited closing tag %2$s.', 'performance-lab' ),
$popped_tag_name,
$tag_name
)
);
}
)
);
}

array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) + 1 );
}
}
Expand All @@ -236,6 +231,8 @@ public function open_tags(): Generator {
*
* A breadcrumb consists of a tag name and its sibling index.
*
* @since n.e.x.t
*
* @return Generator<array{string, int}> Breadcrumb.
*/
private function get_breadcrumbs(): Generator {
Expand All @@ -244,12 +241,30 @@ private function get_breadcrumbs(): Generator {
}
}

/**
* Determines whether currently inside a foreign element (MATH or SVG).
*
* @since n.e.x.t
*
* @return bool In foreign element.
*/
private function is_foreign_element(): bool {
foreach ( $this->open_stack_tags as $open_stack_tag ) {
if ( 'MATH' === $open_stack_tag || 'SVG' === $open_stack_tag ) {
return true;
}
}
return false;
}

/**
* Gets XPath for the current open tag.
*
* It would be nicer if this were like `/html[1]/body[2]` but in XPath the position() here refers to the
* index of the preceding node set. So it has to rather be written `/*[1][self::html]/*[2][self::body]`.
*
* @since n.e.x.t
*
* @return string XPath.
*/
public function get_xpath(): string {
Expand All @@ -266,6 +281,7 @@ public function get_xpath(): string {
* This is a wrapper around the underlying HTML_Tag_Processor method of the same name since only a limited number of
* methods can be exposed to prevent moving the pointer in such a way as the breadcrumb calculation is invalidated.
*
* @since n.e.x.t
* @see WP_HTML_Tag_Processor::get_attribute()
*
* @param string $name Name of attribute whose value is requested.
Expand All @@ -281,6 +297,7 @@ public function get_attribute( string $name ) {
* This is a wrapper around the underlying HTML_Tag_Processor method of the same name since only a limited number of
* methods can be exposed to prevent moving the pointer in such a way as the breadcrumb calculation is invalidated.
*
* @since n.e.x.t
* @see WP_HTML_Tag_Processor::set_attribute()
*
* @param string $name The attribute name to target.
Expand All @@ -297,6 +314,7 @@ public function set_attribute( string $name, $value ): bool {
* This is a wrapper around the underlying HTML_Tag_Processor method of the same name since only a limited number of
* methods can be exposed to prevent moving the pointer in such a way as the breadcrumb calculation is invalidated.
*
* @since n.e.x.t
* @see WP_HTML_Tag_Processor::remove_attribute()
*
* @param string $name The attribute name to remove.
Expand All @@ -312,6 +330,7 @@ public function remove_attribute( string $name ): bool {
* This is a wrapper around the underlying HTML_Tag_Processor method of the same name since only a limited number of
* methods can be exposed to prevent moving the pointer in such a way as the breadcrumb calculation is invalidated.
*
* @since n.e.x.t
* @see WP_HTML_Tag_Processor::get_updated_html()
*
* @return string The processed HTML.
Expand Down
2 changes: 1 addition & 1 deletion modules/images/image-loading-optimization/detection.php
Expand Up @@ -33,7 +33,7 @@ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_
*
* @param int $detection_time_window Detection time window in milliseconds.
*/
$detection_time_window = apply_filters( 'perflab_image_loading_detection_time_window', 5000 );
$detection_time_window = apply_filters( 'ilo_detection_time_window', 5000 );

$detect_args = array(
'serveTime' => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript.
Expand Down
67 changes: 54 additions & 13 deletions modules/images/image-loading-optimization/optimization.php
Expand Up @@ -24,32 +24,65 @@ function ilo_maybe_add_template_output_buffer_filter() {
}
add_action( 'wp', 'ilo_maybe_add_template_output_buffer_filter' );

/**
* Determines whether the current response can be optimized.
*
* Only search results are not eligible by default for optimization. This is because there is no predictability in
* whether posts in the loop will have featured images assigned or not. If a theme template for search results doesn't
* even show featured images, then this isn't an issue.
*
* @since n.e.x.t
* @access private
*
* @return bool Whether response can be optimized.
*/
function ilo_can_optimize_response(): bool {
$able = ! (
// Since the URL space is infinite.
is_search() ||
// Since injection of inline-editing controls interfere with breadcrumbs, while also just not necessary in this context.
is_customize_preview() ||
// The images detected in the response body of a POST request cannot, by definition, be cached.
'GET' !== $_SERVER['REQUEST_METHOD']
);

/**
* Filters whether the current response can be optimized.
*
* @since n.e.x.t
*
* @param bool $able Whether response can be optimized.
*/
return (bool) apply_filters( 'ilo_can_optimize_response', $able );
}

/**
* Constructs preload links.
*
* @since n.e.x.t
* @access private
*
* @param array $lcp_images_by_minimum_viewport_widths LCP images keyed by minimum viewport width, amended with attributes key for the IMG attributes.
* @param array<int, array{attributes: array{src?: string, srcset?: string, sizes?: string, crossorigin?: string}}|false> $lcp_elements_by_minimum_viewport_widths LCP images keyed by minimum viewport width, amended with attributes key for the IMG attributes.
* @return string Markup for zero or more preload link tags.
*/
function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widths ): string {
function ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_widths ): string {
$preload_links = array();

// This uses a for loop to be able to access the following element within the iteration, using a numeric index.
$minimum_viewport_widths = array_keys( $lcp_images_by_minimum_viewport_widths );
$minimum_viewport_widths = array_keys( $lcp_elements_by_minimum_viewport_widths );
for ( $i = 0, $len = count( $minimum_viewport_widths ); $i < $len; $i++ ) {
$lcp_element = $lcp_images_by_minimum_viewport_widths[ $minimum_viewport_widths[ $i ] ];
if ( false === $lcp_element || empty( $lcp_element['attributes'] ) ) {
// No LCP element at this breakpoint, so nothing to preload.
$lcp_element = $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_widths[ $i ] ];
if ( false === $lcp_element ) {
// No supported LCP element at this breakpoint, so nothing to preload.
continue;
}

$img_attributes = $lcp_element['attributes'];
// TODO: Add support for background images.
$attributes = $lcp_element['attributes'];

// Prevent preloading src for browsers that don't support imagesrcset on the link element.
if ( isset( $img_attributes['src'], $img_attributes['srcset'] ) ) {
unset( $img_attributes['src'] );
if ( isset( $attributes['src'], $attributes['srcset'] ) ) {
unset( $attributes['src'] );
}

// Add media query if it's going to be something other than just `min-width: 0px`.
Expand All @@ -60,12 +93,12 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt
if ( null !== $maximum_viewport_width ) {
$media_query .= sprintf( ' and ( max-width: %dpx )', $maximum_viewport_width );
}
$img_attributes['media'] = $media_query;
$attributes['media'] = $media_query;
}

// Construct preload link.
$link_tag = '<link data-ilo-added-tag rel="preload" fetchpriority="high" as="image"';
foreach ( array_filter( $img_attributes ) as $name => $value ) {
foreach ( array_filter( $attributes ) as $name => $value ) {
// Map img attribute name to link attribute name.
if ( 'srcset' === $name || 'sizes' === $name ) {
$name = 'image' . $name;
Expand Down Expand Up @@ -107,7 +140,12 @@ function ilo_optimize_template_output_buffer( string $buffer ): string {
);

// Whether we need to add the data-ilo-xpath attribute to elements and whether the detection script should be injected.
$needs_detection = ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths );
$needs_detection = in_array(
true,
// Each array item is array{int, bool}, with the second item being whether the viewport width is needed.
array_column( $needed_minimum_viewport_widths, 1 ),
true
);

$breakpoint_max_widths = ilo_get_breakpoint_max_widths();
$url_metrics_grouped_by_breakpoint = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths );
Expand Down Expand Up @@ -169,10 +207,13 @@ function ilo_optimize_template_output_buffer( string $buffer ): string {
$processor->remove_attribute( 'fetchpriority' );
}

// TODO: If the image is visible (intersectionRatio!=0) in any of the URL metrics, remove loading=lazy.
// TODO: Conversely, if an image is the LCP element for one breakpoint but not another, add loading=lazy. This won't hurt performance since the image is being preloaded.

// Capture the attributes from the LCP elements to use in preload links.
if ( isset( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] ) ) {
$attributes = array();
foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) {
foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin' ) as $attr_name ) {
$attributes[ $attr_name ] = $processor->get_attribute( $attr_name );
}
foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] as $minimum_viewport_width ) {
Expand Down

0 comments on commit 6433bc1

Please sign in to comment.