-
Notifications
You must be signed in to change notification settings - Fork 108
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 23 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
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -183,7 +183,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 { | ||
|
@@ -192,32 +196,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 +229,20 @@ 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
|
||
* @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. | ||
* | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -30,26 +30,27 @@ function ilo_maybe_add_template_output_buffer_filter() { | |
* @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 +61,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; | ||
|
@@ -169,10 +170,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 ) { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -150,33 +150,40 @@ function ilo_verify_url_metrics_storage_nonce( string $nonce, string $slug ): in | |
* @since n.e.x.t | ||
* @access private | ||
* | ||
* @param array $url_metrics URL metrics. | ||
* @param array $url_metrics URL metrics. Each URL metric is expected to have a timestamp key. | ||
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. Not a blocker for this PR, but I think it would be useful to follow up once this is merged with another PR to introduce a class to represent a URL metric (e.g. |
||
* @param array $validated_url_metric Validated URL metric. See JSON Schema defined in ilo_register_endpoint(). | ||
* @return array Updated URL metrics. | ||
* @param int[] $breakpoints Breakpoint max widths. | ||
* @param int $sample_size Sample size for URL metrics at a given breakpoint. | ||
* @return array Updated URL metrics, with timestamp key added. | ||
*/ | ||
function ilo_unshift_url_metrics( array $url_metrics, array $validated_url_metric ): array { | ||
function ilo_unshift_url_metrics( array $url_metrics, array $validated_url_metric, array $breakpoints, int $sample_size ): array { | ||
$validated_url_metric['timestamp'] = microtime( true ); | ||
array_unshift( $url_metrics, $validated_url_metric ); | ||
$breakpoints = ilo_get_breakpoint_max_widths(); | ||
$sample_size = ilo_get_url_metrics_breakpoint_sample_size(); | ||
$grouped_url_metrics = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoints ); | ||
|
||
foreach ( $grouped_url_metrics as &$breakpoint_url_metrics ) { | ||
if ( count( $breakpoint_url_metrics ) > $sample_size ) { | ||
|
||
// Sort URL metrics in descending order by timestamp. | ||
usort( | ||
$breakpoint_url_metrics, | ||
static function ( $a, $b ) { | ||
if ( ! isset( $a['timestamp'] ) || ! isset( $b['timestamp'] ) ) { | ||
return 0; | ||
// Make sure there is at most $sample_size number of URL metrics for each breakpoint. | ||
$grouped_url_metrics = array_map( | ||
static function ( $breakpoint_url_metrics ) use ( $sample_size ) { | ||
if ( count( $breakpoint_url_metrics ) > $sample_size ) { | ||
|
||
// Sort URL metrics in descending order by timestamp. | ||
usort( | ||
$breakpoint_url_metrics, | ||
static function ( $a, $b ) { | ||
if ( ! isset( $a['timestamp'] ) || ! isset( $b['timestamp'] ) ) { | ||
return 0; | ||
} | ||
return $b['timestamp'] <=> $a['timestamp']; | ||
} | ||
return $b['timestamp'] <=> $a['timestamp']; | ||
} | ||
); | ||
); | ||
|
||
$breakpoint_url_metrics = array_slice( $breakpoint_url_metrics, 0, $sample_size ); | ||
} | ||
} | ||
// Only keep the sample size of the newest URL metrics. | ||
$breakpoint_url_metrics = array_slice( $breakpoint_url_metrics, 0, $sample_size ); | ||
} | ||
return $breakpoint_url_metrics; | ||
}, | ||
$grouped_url_metrics | ||
); | ||
|
||
return array_merge( ...$grouped_url_metrics ); | ||
} | ||
|
@@ -266,24 +273,23 @@ function ilo_get_url_metrics_breakpoint_sample_size(): int { | |
function ilo_group_url_metrics_by_breakpoint( array $url_metrics, array $breakpoints ): array { | ||
|
||
// Convert breakpoint max widths into viewport minimum widths. | ||
$viewport_minimum_widths = array_map( | ||
$minimum_viewport_widths = array_map( | ||
static function ( $breakpoint ) { | ||
return $breakpoint + 1; | ||
}, | ||
$breakpoints | ||
); | ||
|
||
$grouped = array_fill_keys( array_merge( array( 0 ), $viewport_minimum_widths ), array() ); | ||
$grouped = array_fill_keys( array_merge( array( 0 ), $minimum_viewport_widths ), array() ); | ||
|
||
foreach ( $url_metrics as $url_metric ) { | ||
if ( ! isset( $url_metric['viewport']['width'] ) ) { | ||
continue; | ||
} | ||
$viewport_width = $url_metric['viewport']['width']; | ||
|
||
$current_minimum_viewport = 0; | ||
foreach ( $viewport_minimum_widths as $viewport_minimum_width ) { | ||
if ( $viewport_width > $viewport_minimum_width ) { | ||
foreach ( $minimum_viewport_widths as $viewport_minimum_width ) { | ||
if ( $url_metric['viewport']['width'] > $viewport_minimum_width ) { | ||
$current_minimum_viewport = $viewport_minimum_width; | ||
} else { | ||
break; | ||
|
@@ -299,15 +305,16 @@ static function ( $breakpoint ) { | |
* Gets the LCP element for each breakpoint. | ||
* | ||
* The array keys are the minimum viewport width required for the element to be LCP. If there are URL metrics for a | ||
* given breakpoint and yet there is no LCP element, then the array value is `false`. If there is an LCP element at the | ||
* breakpoint, then the array value is an array representing that element, including its breadcrumbs. If two adjoining | ||
* breakpoints have the same value, then the latter is dropped. | ||
* given breakpoint and yet there is no supported LCP element, then the array value is `false`. (Currently only IMG is | ||
* a supported LCP element.) If there is a supported LCP element at the breakpoint, then the array value is an array | ||
* representing that element, including its breadcrumbs. If two adjoining breakpoints have the same value, then the | ||
* latter is dropped. | ||
* | ||
* @since n.e.x.t | ||
* @access private | ||
* | ||
* @param array $grouped_url_metrics URL metrics grouped by breakpoint. See `ilo_group_url_metrics_by_breakpoint()`. | ||
* @return array LCP elements keyed by its minimum viewport width. If there is no LCP element at a breakpoint, then `false` is used. | ||
* @return array LCP elements keyed by its minimum viewport width. If there is no supported LCP element at a breakpoint, then `false` is used. | ||
*/ | ||
function ilo_get_lcp_elements_by_minimum_viewport_widths( array $grouped_url_metrics ): array { | ||
|
||
|
@@ -382,8 +389,8 @@ static function ( $lcp_element ) use ( &$prev_lcp_element ) { | |
* @since n.e.x.t | ||
* @access private | ||
* | ||
* @param array $url_metrics URL metrics. | ||
* @param float $current_time Current time as returned by microtime(true). | ||
* @param array $url_metrics URL metrics. | ||
* @param float $current_time Current time as returned by `microtime(true)`. | ||
* @param int[] $breakpoint_max_widths Breakpoint max widths. | ||
* @param int $sample_size Sample size for viewports in a breakpoint. | ||
* @param int $freshness_ttl Freshness TTL for a URL metric. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -46,6 +46,8 @@ function ilo_register_url_metrics_post_type() { | |
* @since n.e.x.t | ||
* @access private | ||
* | ||
* @todo Consider returning post ID instead of WP_Post object. | ||
* | ||
* @param string $slug URL metrics slug. | ||
* @return WP_Post|null Post object if exists. | ||
*/ | ||
|
@@ -110,7 +112,40 @@ function ilo_parse_stored_url_metrics( WP_Post $post ): array { | |
); | ||
$url_metrics = array(); | ||
} | ||
return $url_metrics; | ||
|
||
return array_values( | ||
array_filter( | ||
$url_metrics, | ||
static function ( $url_metric ) use ( $trigger_error ) { | ||
// TODO: If we wanted, we could use the JSON Schema to validate the stored metrics. | ||
$is_valid = ( | ||
is_array( $url_metric ) | ||
&& | ||
isset( | ||
$url_metric['viewport']['width'], | ||
$url_metric['viewport']['height'], | ||
$url_metric['elements'] | ||
) | ||
&& | ||
is_int( $url_metric['viewport']['width'] ) | ||
&& | ||
is_array( $url_metric['elements'] ) | ||
); | ||
|
||
if ( ! $is_valid ) { | ||
$trigger_error( | ||
sprintf( | ||
/* translators: %s is post type slug */ | ||
__( 'Unexpected shape to JSON array in post_content of %s post type.', 'performance-lab' ), | ||
ILO_URL_METRICS_POST_TYPE | ||
) | ||
); | ||
} | ||
|
||
return $is_valid; | ||
} | ||
) | ||
); | ||
Comment on lines
+114
to
+146
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. This makes another good case why there should be a class to represent these objects. Validation would belong there, and probably be fewer lines of code as some of those checks would become irrelevant when enforcing proper fields via class properties. 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. Nevertheless, the data would be stored as JSON in the database and not as a serialized class. So this validation would still have to be done somewhere. 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. Right, I think it could then be done in the class. I would expect a class to e.g. receive this associative array as constructor parameter and then validate it. |
||
} | ||
|
||
/** | ||
|
@@ -119,13 +154,12 @@ function ilo_parse_stored_url_metrics( WP_Post $post ): array { | |
* @since n.e.x.t | ||
* @access private | ||
* | ||
* @param string $url URL for the URL metrics. This is used purely as metadata. | ||
* @param string $slug URL metrics slug (computed from query vars). | ||
* @param string $url URL for the URL metrics. This is used purely as metadata. | ||
* @param string $slug URL metrics slug (computed from query vars). | ||
* @param array $validated_url_metric Validated URL metric. See JSON Schema defined in ilo_register_endpoint(). | ||
* @return int|WP_Error Post ID or WP_Error otherwise. | ||
*/ | ||
function ilo_store_url_metric( string $url, string $slug, array $validated_url_metric ) { | ||
$validated_url_metric['timestamp'] = microtime( true ); | ||
|
||
// TODO: What about storing a version identifier? | ||
$post_data = array( | ||
|
@@ -143,7 +177,9 @@ function ilo_store_url_metric( string $url, string $slug, array $validated_url_m | |
$url_metrics = array(); | ||
} | ||
|
||
$url_metrics = ilo_unshift_url_metrics( $url_metrics, $validated_url_metric ); | ||
$breakpoints = ilo_get_breakpoint_max_widths(); | ||
$sample_size = ilo_get_url_metrics_breakpoint_sample_size(); | ||
$url_metrics = ilo_unshift_url_metrics( $url_metrics, $validated_url_metric, $breakpoints, $sample_size ); | ||
|
||
$post_data['post_content'] = wp_json_encode( $url_metrics, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // TODO: No need for pretty-printing. | ||
|
||
|
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.