-
Notifications
You must be signed in to change notification settings - Fork 109
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
Add PHPUnit tests for Image Loading Optimization #898
Changes from all commits
5183678
b1e6f9d
365bd43
2190c28
3b5a049
c0ef937
e1a13d6
5c83545
7d8afd8
00b9ccb
c9d41c3
ea08eda
0f922f3
a6d6997
1d92756
63136ab
746f463
59fb175
3e77988
46080ef
e12b798
0410678
1b16857
9265b9d
1b219f5
e1ac28e
7324ce2
966c5b3
7455a2e
28db827
3709688
1b3b859
fd43aaa
df0d09e
1fa877c
3da1363
2181d88
930d4ed
8dd3283
bf1bd17
54396ec
492fde9
461ca6b
5c009ba
c5053f1
d865bac
ed305a9
e1c05c7
8874d07
06ac5fe
4bb2ca8
d62d710
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 { | ||
|
@@ -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 ); | ||
} | ||
Comment on lines
-194
to
202
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @dmsnell: FYI, I'm pretty happy with how |
||
} else { | ||
|
@@ -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 ); | ||
} | ||
} | ||
|
@@ -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 { | ||
|
@@ -244,12 +241,30 @@ private function get_breadcrumbs(): Generator { | |
} | ||
} | ||
|
||
/** | ||
* Determines whether currently inside a foreign element (MATH or SVG). | ||
* | ||
westonruter marked this conversation as resolved.
Show resolved
Hide resolved
westonruter marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* @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 ) { | ||
mukeshpanchal27 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 { | ||
|
@@ -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. | ||
|
@@ -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. | ||
|
@@ -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. | ||
|
@@ -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. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe a personal preference, but I find this notation overkill. This isn't used anywhere in WordPress. If we want to describe more complex shapes like this, we should probably introduce a class for them. This would be safer in any case than using an associative array which by definition takes any arbitrary data. I think a simple Separate minor question: Why was this renamed to "LCP elements" instead of "LCP images"? Would it ever not be an LCP image? If so, the choice of fields would be a bit odd, since almost all of them are specific to images. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Actually, it is used in WordPress, specifically in the
Yeah, I like that idea. However, believe it or not, I was wanting to limit the refactor of the existing code in this PR once the test coverage was completed. With the coverage in place, refactoring will be safer as I'll be able to detect regressions better.
Yes, for example in #914 this is extended to support elements with CSS background images. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
+1, that makes total sense to me. Maybe open an issue already? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've just now added it as a todo item on the overview issue. |
||
* @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'] ) ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another benefit of using a class to represent LCP elements would be that that would be truly enforced :) |
||
// 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`. | ||
|
@@ -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; | ||
|
@@ -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 | ||
); | ||
Comment on lines
+143
to
+148
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some cool things with PHPStan's static analysis here. If 0 is supplied as the second arg to
This is because from the array shape returned by Conversely, if I supply a higher index like 2 in
Here PHPStan knows that since there are only two items in each tuple, the return of |
||
|
||
$breakpoint_max_widths = ilo_get_breakpoint_max_widths(); | ||
$url_metrics_grouped_by_breakpoint = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths ); | ||
|
@@ -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 ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
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 ) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added because
DOMDocument
is used in tests now.