From bfc451ddf8fd93a99ec5a726f351e0714713c5cb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 25 Oct 2023 11:52:40 -0700 Subject: [PATCH 001/371] Add Image Loading Optimization module --- admin/server-timing.php | 24 +++++---- .../image-loading-optimization/helper.php | 11 ++++ .../image-loading-optimization/hooks.php | 48 +++++++++++++++++ .../image-loading-optimization/load.php | 17 ++++++ server-timing/class-perflab-server-timing.php | 15 ++++++ tests/admin/server-timing-tests.php | 6 ++- .../image-loading-optimization/load-tests.php | 54 +++++++++++++++++++ 7 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 modules/images/image-loading-optimization/helper.php create mode 100644 modules/images/image-loading-optimization/hooks.php create mode 100644 modules/images/image-loading-optimization/load.php create mode 100644 tests/modules/images/image-loading-optimization/load-tests.php diff --git a/admin/server-timing.php b/admin/server-timing.php index 94a520ec2f..46f28df890 100644 --- a/admin/server-timing.php +++ b/admin/server-timing.php @@ -43,16 +43,18 @@ function perflab_add_server_timing_page() { * @since 2.6.0 */ function perflab_load_server_timing_page() { - /* - * This settings section technically includes a field, however it is directly rendered as part of the section - * callback due to requiring custom markup. - */ - add_settings_section( - 'output-buffering', - __( 'Output Buffering', 'performance-lab' ), - 'perflab_render_server_timing_page_output_buffering_section', - PERFLAB_SERVER_TIMING_SCREEN - ); + if ( ! has_filter( 'template_include', 'image_loading_optimization_buffer_output' ) ) { + /* + * This settings section technically includes a field, however it is directly rendered as part of the section + * callback due to requiring custom markup. + */ + add_settings_section( + 'output-buffering', + __( 'Output Buffering', 'performance-lab' ), + 'perflab_render_server_timing_page_output_buffering_section', + PERFLAB_SERVER_TIMING_SCREEN + ); + } // Minor style tweaks to improve appearance similar to other core settings screen instances. add_action( @@ -93,7 +95,7 @@ static function () { ); ?>

- +

send_header(); + return $buffer; + }, + PHP_INT_MAX + ); + return $passthrough; + } + if ( ! $this->use_output_buffer() ) { $this->send_header(); return $passthrough; diff --git a/tests/admin/server-timing-tests.php b/tests/admin/server-timing-tests.php index 0684ff8409..915da3cdc7 100644 --- a/tests/admin/server-timing-tests.php +++ b/tests/admin/server-timing-tests.php @@ -48,8 +48,12 @@ public function test_perflab_load_server_timing_page() { perflab_load_server_timing_page(); $this->assertArrayHasKey( PERFLAB_SERVER_TIMING_SCREEN, $wp_settings_sections ); + $expected_sections = array( 'benchmarking' ); + if ( ! has_filter( 'template_include', 'image_loading_optimization_buffer_output' ) ) { + $expected_sections[] = 'output-buffering'; + } $this->assertEqualSets( - array( 'output-buffering', 'benchmarking' ), + $expected_sections, array_keys( $wp_settings_sections[ PERFLAB_SERVER_TIMING_SCREEN ] ) ); $this->assertEqualSets( diff --git a/tests/modules/images/image-loading-optimization/load-tests.php b/tests/modules/images/image-loading-optimization/load-tests.php new file mode 100644 index 0000000000..a1a9333c05 --- /dev/null +++ b/tests/modules/images/image-loading-optimization/load-tests.php @@ -0,0 +1,54 @@ +assertEquals( PHP_INT_MAX, has_filter( 'template_include', 'image_loading_optimization_buffer_output' ) ); + } + + /** + * Make output is buffered and that it is also filtered. + * + * @test + * @covers ::image_loading_optimization_buffer_output + */ + public function it_buffers_and_filters_output() { + $original = 'Hello World!'; + $expected = '¡Hola Mundo!'; + + // In order to test, a wrapping output buffer is required because ob_get_clean() does not invoke the output + // buffer callback. See . + ob_start(); + + add_filter( + 'perflab_template_output_buffer', + function ( $buffer ) use ( $original, $expected ) { + $this->assertSame( $original, $buffer ); + return $expected; + } + ); + + $original_ob_level = ob_get_level(); + image_loading_optimization_buffer_output(); + $this->assertSame( $original_ob_level + 1, ob_get_level(), 'Expected call to ob_start().' ); + echo $original; + + ob_end_flush(); // Flushing invokes the output buffer callback. + + $buffer = ob_get_clean(); // Get the buffer from our wrapper output buffer. + $this->assertSame( $expected, $buffer ); + } +} From e8b3fdad1e7079bc6d2a2202e19bd178bea7b7ba Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 25 Oct 2023 12:41:13 -0700 Subject: [PATCH 002/371] Add Image Loading Optimization to CODEOWNERS --- .github/CODEOWNERS | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d2939e8d47..5514f71c0a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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: Image Loading Optimization +/modules/images/image-loading-optimization @westonruter +/tests/modules/images/image-loading-optimization @westonruter +/tests/testdata/modules/images/image-loading-optimization @westonruter From 4dcef16be5a8b8e336de4c76c7900d85e7e64072 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 25 Oct 2023 17:26:07 -0700 Subject: [PATCH 003/371] Add skeleton for detection script --- .../image-loading-optimization/detect.js | 22 +++++++++++++ .../image-loading-optimization/hooks.php | 31 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 modules/images/image-loading-optimization/detect.js diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js new file mode 100644 index 0000000000..d7d285bb7e --- /dev/null +++ b/modules/images/image-loading-optimization/detect.js @@ -0,0 +1,22 @@ +/** + * Detect the LCP element, loaded images, client viewport and store for future optimizations. + * + * @param {number} serveTime The serve time of the page in milliseconds from PHP via `ceil( microtime( true ) * 1000 )`. + * @param {number} detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. + * @param {boolean} isDebug Whether to show debug messages. + */ +function detect( serveTime, detectionTimeWindow, isDebug ) { + const runTime = new Date().valueOf(); + + // Abort running detection logic if it was served in a cached page. + if ( runTime - serveTime > detectionTimeWindow ) { + if ( isDebug ) { + console.warn( 'Aborted detection for Image Loading Optimization due to being outside detection time window.' ); + } + return; + } + + if ( isDebug ) { + console.info('Proceeding with detection for Image Loading Optimization.'); + } +} diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 1b2b3de95d..25c56ce706 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -46,3 +46,34 @@ static function ( $output ) { return $passthrough; } add_filter( 'template_include', 'image_loading_optimization_buffer_output', PHP_INT_MAX ); + + +/** + * Prints the script for detecting loaded images and the LCP element. + */ +function image_loading_optimization_print_detection_script() { + $serve_time = ceil( microtime( true ) * 1000 ); + + /** + * Filters the time window between serve time and run time in which loading detection is allowed to run. + * + * Allow this amount of milliseconds between when the page was first generated (and perhaps cached) and when the + * detect function on the page is allowed to perform its detection logic and submit the request to store the results. + * This avoids situations in which there is missing detection metrics in which case a site with page caching which + * also has a lot of traffic could result in a cache stampede. + * + * @since n.e.x.t + * @todo The value should probably be something like the 99th percentile of TTFB for WordPress sites in CrUX. + * + * @param int $detection_time_window Detection time window in milliseconds. + */ + $detection_time_window = apply_filters( 'perflab_image_loading_detection_time_window', 5000 ); + + $detect_function = file_get_contents( __DIR__ . '/detect.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $detect_args = array( $serve_time, $detection_time_window, WP_DEBUG ); + wp_print_inline_script_tag( + sprintf( '( %s )( ...%s )', $detect_function, wp_json_encode( $detect_args ) ), + array( 'type' => 'module' ) + ); +} +add_action( 'wp_print_footer_scripts', 'image_loading_optimization_print_detection_script' ); From 7ca7e67e15f18fec9d6f6eee0cd85362b7f7df81 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 26 Oct 2023 08:58:55 -0700 Subject: [PATCH 004/371] Update comment to reflect TTLB not TTFB --- modules/images/image-loading-optimization/hooks.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 25c56ce706..26aee6b2e9 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -47,7 +47,6 @@ static function ( $output ) { } add_filter( 'template_include', 'image_loading_optimization_buffer_output', PHP_INT_MAX ); - /** * Prints the script for detecting loaded images and the LCP element. */ @@ -63,7 +62,7 @@ function image_loading_optimization_print_detection_script() { * also has a lot of traffic could result in a cache stampede. * * @since n.e.x.t - * @todo The value should probably be something like the 99th percentile of TTFB for WordPress sites in CrUX. + * @todo The value should probably be something like the 99th percentile of Time To Last Byte (TTLB) for WordPress sites in CrUX. * * @param int $detection_time_window Detection time window in milliseconds. */ From 721517a902ad056f4066ff7575fbc144870f2564 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 26 Oct 2023 13:42:54 -0700 Subject: [PATCH 005/371] Import module rather than inlining it --- modules/images/image-loading-optimization/detect.js | 2 +- modules/images/image-loading-optimization/hooks.php | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index d7d285bb7e..e4cab9ab68 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -5,7 +5,7 @@ * @param {number} detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. * @param {boolean} isDebug Whether to show debug messages. */ -function detect( serveTime, detectionTimeWindow, isDebug ) { +export default async function detect( serveTime, detectionTimeWindow, isDebug ) { const runTime = new Date().valueOf(); // Abort running detection logic if it was served in a cached page. diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 26aee6b2e9..09692aa828 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -68,10 +68,13 @@ function image_loading_optimization_print_detection_script() { */ $detection_time_window = apply_filters( 'perflab_image_loading_detection_time_window', 5000 ); - $detect_function = file_get_contents( __DIR__ . '/detect.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $detect_args = array( $serve_time, $detection_time_window, WP_DEBUG ); + $detect_args = array( $serve_time, $detection_time_window, WP_DEBUG ); wp_print_inline_script_tag( - sprintf( '( %s )( ...%s )', $detect_function, wp_json_encode( $detect_args ) ), + sprintf( + 'import detect from %s; detect( ...%s )', + wp_json_encode( add_query_arg( 'ver', PERFLAB_VERSION, plugin_dir_url( __FILE__ ) . 'detect.js' ) ), + wp_json_encode( $detect_args ) + ), array( 'type' => 'module' ) ); } From e2212a870821cf096d6460dedc850cd9e673b349 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 30 Oct 2023 12:19:54 -0700 Subject: [PATCH 006/371] WIP --- .eslintrc.js | 7 + .../image-loading-optimization/detect.js | 140 +++++++++++++++++- 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 86eba448b8..eb23001613 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,7 +8,14 @@ const config = { rules: { ...( wpConfig?.rules || {} ), 'jsdoc/valid-types': 'off', + 'no-console': 'off', }, + env: { + 'browser': true, + }, + globals: { + scheduler: false, + } }; module.exports = config; diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index e4cab9ab68..f4dc98bb1c 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -1,22 +1,150 @@ +/** + * External dependencies + */ + +/** @typedef {import("web-vitals").LCPMetricWithAttribution} LCPMetricWithAttribution */ + +/** + * Yield to the main thread. + * + * @see https://developer.chrome.com/blog/introducing-scheduler-yield-origin-trial/#enter-scheduleryield + * @return {Promise} + */ +function yieldToMain() { + /** @type */ + if ( + typeof scheduler !== 'undefined' && + typeof scheduler.yield === 'function' + ) { + return scheduler.yield(); + } + + // Fall back to setTimeout: + return new Promise( ( resolve ) => { + setTimeout( resolve, 0 ); + } ); +} + /** * Detect the LCP element, loaded images, client viewport and store for future optimizations. * - * @param {number} serveTime The serve time of the page in milliseconds from PHP via `ceil( microtime( true ) * 1000 )`. - * @param {number} detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. - * @param {boolean} isDebug Whether to show debug messages. + * @param {number} serveTime The serve time of the page in milliseconds from PHP via `ceil( microtime( true ) * 1000 )`. + * @param {number} detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. + * @param {boolean} isDebug Whether to show debug messages. */ -export default async function detect( serveTime, detectionTimeWindow, isDebug ) { +export default async function detect( + serveTime, + detectionTimeWindow, + isDebug +) { const runTime = new Date().valueOf(); // Abort running detection logic if it was served in a cached page. if ( runTime - serveTime > detectionTimeWindow ) { if ( isDebug ) { - console.warn( 'Aborted detection for Image Loading Optimization due to being outside detection time window.' ); + console.warn( + 'Aborted detection for Image Loading Optimization due to being outside detection time window.' + ); } return; } if ( isDebug ) { - console.info('Proceeding with detection for Image Loading Optimization.'); + console.info( + 'Proceeding with detection for Image Loading Optimization.' + ); + } + + // TODO: Use a local copy of web-vitals. + const { onLCP } = await import( + // eslint-disable-next-line import/no-unresolved + 'https://unpkg.com/web-vitals@3/dist/web-vitals.attribution.js?module' + ); + + // const perfObserver = new PerformanceObserver( ( list ) => { + // const entries = list.getEntries(); + // for ( const entry of entries ) { + // console.log( 'perfObserver LCP:', entry ); + // } + // } ); + // + // perfObserver.observe( { + // type: 'largest-contentful-paint', + // buffered: true, + // } ); + + /** @type {LCPMetricWithAttribution[]} */ + const lcpCandidates = []; + + // TODO: Obtain other candidates than the LCP? If the LCP is text and there's an image too, we should add fetchpriority to the image still even though it isn't LCP. + const lcpCandidateObtained = new Promise( ( resolve ) => { + onLCP( + ( metric ) => { + lcpCandidates.push( metric ); + resolve(); + }, + { + // This avoids needing to click to finalize LCP candidate. While this is helpful for testing, it also + // ensures that we always get an LCP candidate reported. Otherwise, the callback may never fire if the + // user never does a click or keydown, per . + reportAllChanges: true, + } + ); + } ); + + // Note: We cannot use the window load event because the module may load after it fires. + + // To watch for intersection relative to the device's viewport, specify null for the root option. + console.info( { + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + } ); + + const options = { + root: null, + // rootMargin: "0px", + threshold: 0.0, // As soon as even one pixel is visible. + }; + + const adminBar = document.getElementById( 'wpadminbar' ); + const imageObserver = new IntersectionObserver( ( entries ) => { + for ( const entry of entries ) { + if ( + entry.isIntersecting && + ( ! adminBar || ! adminBar.contains( entry.target ) ) + ) { + console.info( 'Initial image:', entry.target ); + } + } + }, options ); + for ( const img of document.getElementsByTagName( 'img' ) ) { + imageObserver.observe( img ); + } + + // Wait until we have an LCP candidate, although more may come upon the page finishing loading. + await lcpCandidateObtained; + + // Wait until the page has fully loaded. Note that a module is delayed like a script with defer. + await new Promise( ( resolve ) => { + if ( document.readyState === 'complete' ) { + resolve(); + } else { + window.addEventListener( 'load', resolve, { once: true } ); + } + } ); + + // Wait for an additional timer. + // await new Promise( ( resolve ) => { + // setTimeout( resolve, 1000 ); // TODO: What time makes sense? + // } ); + + // Stop observing. + imageObserver.disconnect(); + if ( isDebug ) { + console.info( 'Detection is stopping.' ); } + + console.info( 'lcpCandidates', lcpCandidates ); + + // TODO: Send data to server. } From 37a603cfd3a87978ab77ca77148c8383350194a4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 30 Oct 2023 12:27:16 -0700 Subject: [PATCH 007/371] Improve logging and remove obsolete code --- .../image-loading-optimization/detect.js | 49 +++++++------------ 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index f4dc98bb1c..836723cdaf 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -1,9 +1,15 @@ -/** - * External dependencies - */ - /** @typedef {import("web-vitals").LCPMetricWithAttribution} LCPMetricWithAttribution */ +const consoleLogPrefix = '[Image Loading Optimization]'; + +function log( ...message ) { + console.log( consoleLogPrefix, ...message ); +} + +function warn( ...message ) { + console.warn( consoleLogPrefix, ...message ); +} + /** * Yield to the main thread. * @@ -42,7 +48,7 @@ export default async function detect( // Abort running detection logic if it was served in a cached page. if ( runTime - serveTime > detectionTimeWindow ) { if ( isDebug ) { - console.warn( + warn( 'Aborted detection for Image Loading Optimization due to being outside detection time window.' ); } @@ -50,9 +56,7 @@ export default async function detect( } if ( isDebug ) { - console.info( - 'Proceeding with detection for Image Loading Optimization.' - ); + log( 'Proceeding with detection' ); } // TODO: Use a local copy of web-vitals. @@ -61,22 +65,10 @@ export default async function detect( 'https://unpkg.com/web-vitals@3/dist/web-vitals.attribution.js?module' ); - // const perfObserver = new PerformanceObserver( ( list ) => { - // const entries = list.getEntries(); - // for ( const entry of entries ) { - // console.log( 'perfObserver LCP:', entry ); - // } - // } ); - // - // perfObserver.observe( { - // type: 'largest-contentful-paint', - // buffered: true, - // } ); - /** @type {LCPMetricWithAttribution[]} */ const lcpCandidates = []; - // TODO: Obtain other candidates than the LCP? If the LCP is text and there's an image too, we should add fetchpriority to the image still even though it isn't LCP. + // Obtain at least one LCP candidate. const lcpCandidateObtained = new Promise( ( resolve ) => { onLCP( ( metric ) => { @@ -92,10 +84,8 @@ export default async function detect( ); } ); - // Note: We cannot use the window load event because the module may load after it fires. - // To watch for intersection relative to the device's viewport, specify null for the root option. - console.info( { + log( { viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, } ); @@ -113,7 +103,7 @@ export default async function detect( entry.isIntersecting && ( ! adminBar || ! adminBar.contains( entry.target ) ) ) { - console.info( 'Initial image:', entry.target ); + log( 'Initial image:', entry.target ); } } }, options ); @@ -133,18 +123,13 @@ export default async function detect( } } ); - // Wait for an additional timer. - // await new Promise( ( resolve ) => { - // setTimeout( resolve, 1000 ); // TODO: What time makes sense? - // } ); - // Stop observing. imageObserver.disconnect(); if ( isDebug ) { - console.info( 'Detection is stopping.' ); + log( 'Detection is stopping.' ); } - console.info( 'lcpCandidates', lcpCandidates ); + log( 'lcpCandidates', lcpCandidates ); // TODO: Send data to server. } From 31f5ffe7b1eebec8aea315fb1cc9058e320cbd3e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 30 Oct 2023 15:36:04 -0700 Subject: [PATCH 008/371] WIP2 --- .../image-loading-optimization/detect.js | 95 ++++++++++++++----- package-lock.json | 13 +++ package.json | 3 + 3 files changed, 87 insertions(+), 24 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index 836723cdaf..24c271fe4d 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -31,6 +31,36 @@ function yieldToMain() { } ); } +/** + * @typedef {Object} Breadcrumb + * @property {number} index + * @property {string} tagName + */ + +/** + * Gets breadcrumbs for a given element. + * + * @param {Element} element + * @return {Breadcrumb[]} Breadcrumbs. + */ +function getBreadcrumbs( element ) { + /** @type {Breadcrumb[]} */ + const breadcrumbs = []; + + let node = element; + while ( node instanceof Element ) { + breadcrumbs.unshift( { + tagName: node.tagName, + index: node.parentElement + ? Array.from( node.parentElement.children ).indexOf( node ) + : 0, + } ); + node = node.parentElement; + } + + return breadcrumbs; +} + /** * Detect the LCP element, loaded images, client viewport and store for future optimizations. * @@ -59,6 +89,14 @@ export default async function detect( log( 'Proceeding with detection' ); } + const results = { + viewport: { + width: window.innerWidth, + height: window.innerHeight, + }, + images: [], + }; + // TODO: Use a local copy of web-vitals. const { onLCP } = await import( // eslint-disable-next-line import/no-unresolved @@ -66,13 +104,13 @@ export default async function detect( ); /** @type {LCPMetricWithAttribution[]} */ - const lcpCandidates = []; + const lcpMetricCandidates = []; // Obtain at least one LCP candidate. const lcpCandidateObtained = new Promise( ( resolve ) => { onLCP( ( metric ) => { - lcpCandidates.push( metric ); + lcpMetricCandidates.push( metric ); resolve(); }, { @@ -84,31 +122,29 @@ export default async function detect( ); } ); - // To watch for intersection relative to the device's viewport, specify null for the root option. - log( { - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - } ); - - const options = { - root: null, - // rootMargin: "0px", - threshold: 0.0, // As soon as even one pixel is visible. - }; + /** @type {IntersectionObserverEntry[]} */ + const imageIntersections = []; - const adminBar = document.getElementById( 'wpadminbar' ); - const imageObserver = new IntersectionObserver( ( entries ) => { - for ( const entry of entries ) { - if ( - entry.isIntersecting && - ( ! adminBar || ! adminBar.contains( entry.target ) ) - ) { - log( 'Initial image:', entry.target ); + const imageObserver = new IntersectionObserver( + ( entries ) => { + for ( const entry of entries ) { + //if ( entry.isIntersecting ) { + console.info( 'interesecting!', entry ); + imageIntersections.push( entry ); + //} } + }, + { + root: null, // To watch for intersection relative to the device's viewport, specify null for the root option. + threshold: 0.0, // As soon as even one pixel is visible. } - }, options ); + ); + + const adminBar = document.getElementById( 'wpadminbar' ); for ( const img of document.getElementsByTagName( 'img' ) ) { - imageObserver.observe( img ); + if ( ! adminBar || ! adminBar.contains( img ) ) { + imageObserver.observe( img ); + } } // Wait until we have an LCP candidate, although more may come upon the page finishing loading. @@ -129,7 +165,18 @@ export default async function detect( log( 'Detection is stopping.' ); } - log( 'lcpCandidates', lcpCandidates ); + console.info( imageIntersections ); + const lcpMetric = lcpMetricCandidates.at( -1 ); + for ( const imageIntersection of imageIntersections ) { + log( + 'imageIntersection.target', + imageIntersection.target, + getBreadcrumbs( imageIntersection.target ) + ); + } + // lcpMetric.attribution.element + + log( 'lcpCandidates', lcpMetricCandidates ); // TODO: Send data to server. } diff --git a/package-lock.json b/package-lock.json index a324943f09..f5354ef3ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,9 @@ "": { "name": "performance", "license": "GPL-2.0-or-later", + "dependencies": { + "web-vitals": "3.5.0" + }, "devDependencies": { "@octokit/rest": "^19.0.5", "@wordpress/env": "^5.7.0", @@ -17103,6 +17106,11 @@ "defaults": "^1.0.3" } }, + "node_modules/web-vitals": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.0.tgz", + "integrity": "sha512-f5YnCHVG9Y6uLCePD4tY8bO/Ge15NPEQWtvm3tPzDKygloiqtb4SVqRHBcrIAqo2ztqX5XueqDn97zHF0LdT6w==" + }, "node_modules/webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", @@ -30546,6 +30554,11 @@ "defaults": "^1.0.3" } }, + "web-vitals": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.0.tgz", + "integrity": "sha512-f5YnCHVG9Y6uLCePD4tY8bO/Ge15NPEQWtvm3tPzDKygloiqtb4SVqRHBcrIAqo2ztqX5XueqDn97zHF0LdT6w==" + }, "webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", diff --git a/package.json b/package.json index d88d3f9bf8..d111960285 100644 --- a/package.json +++ b/package.json @@ -45,5 +45,8 @@ "composer run-script lint", "composer run-script phpstan" ] + }, + "dependencies": { + "web-vitals": "3.5.0" } } From d700943939267121bd01e511771da5381f5ac9b7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 30 Oct 2023 15:52:35 -0700 Subject: [PATCH 009/371] WIP3 --- .../image-loading-optimization/detect.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index 24c271fe4d..bc78cc2ec4 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -126,12 +126,16 @@ export default async function detect( const imageIntersections = []; const imageObserver = new IntersectionObserver( - ( entries ) => { + ( entries, observer ) => { + consl.info('callback!'); for ( const entry of entries ) { - //if ( entry.isIntersecting ) { - console.info( 'interesecting!', entry ); - imageIntersections.push( entry ); - //} + if ( entry.isIntersecting ) { + console.info( 'interesecting!', entry ); + imageIntersections.push( entry ); + } else { + console.info( 'npt interesecting!', entry ); + } + observer.unobserve( entry.target ); } }, { @@ -141,8 +145,10 @@ export default async function detect( ); const adminBar = document.getElementById( 'wpadminbar' ); - for ( const img of document.getElementsByTagName( 'img' ) ) { + const imgCollection = document.body.getElementsByTagName( 'img' ); + for ( /** @type {HTMLImageElement} */ const img of imgCollection ) { if ( ! adminBar || ! adminBar.contains( img ) ) { + console.info( 'observe', img ); imageObserver.observe( img ); } } @@ -179,4 +185,5 @@ export default async function detect( log( 'lcpCandidates', lcpMetricCandidates ); // TODO: Send data to server. + log( results ); } From e9aee7ea9b023ac34f86b7ab5a2eb0bdba0fc445 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 30 Oct 2023 16:52:39 -0700 Subject: [PATCH 010/371] WIP4 --- .../image-loading-optimization/detect.js | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index bc78cc2ec4..4992764743 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -17,7 +17,6 @@ function warn( ...message ) { * @return {Promise} */ function yieldToMain() { - /** @type */ if ( typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function' @@ -122,24 +121,28 @@ export default async function detect( ); } ); + // Ensure the DOM is loaded (although it surely already is since we're executing in a module). + await new Promise( ( resolve ) => { + if ( document.readyState !== 'loading' ) { + resolve(); + } else { + document.addEventListener( 'DOMContentLoaded', resolve ); + } + } ); + /** @type {IntersectionObserverEntry[]} */ const imageIntersections = []; const imageObserver = new IntersectionObserver( - ( entries, observer ) => { - consl.info('callback!'); + ( entries ) => { for ( const entry of entries ) { if ( entry.isIntersecting ) { - console.info( 'interesecting!', entry ); imageIntersections.push( entry ); - } else { - console.info( 'npt interesecting!', entry ); } - observer.unobserve( entry.target ); } }, { - root: null, // To watch for intersection relative to the device's viewport, specify null for the root option. + root: null, // To watch for intersection relative to the device's viewport. threshold: 0.0, // As soon as even one pixel is visible. } ); @@ -148,7 +151,6 @@ export default async function detect( const imgCollection = document.body.getElementsByTagName( 'img' ); for ( /** @type {HTMLImageElement} */ const img of imgCollection ) { if ( ! adminBar || ! adminBar.contains( img ) ) { - console.info( 'observe', img ); imageObserver.observe( img ); } } @@ -156,7 +158,7 @@ export default async function detect( // Wait until we have an LCP candidate, although more may come upon the page finishing loading. await lcpCandidateObtained; - // Wait until the page has fully loaded. Note that a module is delayed like a script with defer. + // Wait until the images on the page have fully loaded. await new Promise( ( resolve ) => { if ( document.readyState === 'complete' ) { resolve(); @@ -165,22 +167,35 @@ export default async function detect( } } ); + // Give the image intersection observer a chance to report back. + // TODO: This needs to be hardened. How long to wait for callback? What about when there are no images in the page? + await new Promise( async ( resolve ) => { + if ( window.requestIdleCallback ) { + window.requestIdleCallback( resolve ); + } else { + setTimeout( resolve, 1 ); + } + } ); + // Stop observing. imageObserver.disconnect(); if ( isDebug ) { log( 'Detection is stopping.' ); } - console.info( imageIntersections ); const lcpMetric = lcpMetricCandidates.at( -1 ); for ( const imageIntersection of imageIntersections ) { log( 'imageIntersection.target', imageIntersection.target, - getBreadcrumbs( imageIntersection.target ) + getBreadcrumbs( imageIntersection.target ), + lcpMetric && + imageIntersection.target === + lcpMetric.attribution.lcpEntry.element + ? 'is LCP' + : 'is NOT LCP' ); } - // lcpMetric.attribution.element log( 'lcpCandidates', lcpMetricCandidates ); From 685c25e5938a793a021b03b07dcc34599bfa17ce Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 31 Oct 2023 10:34:57 -0700 Subject: [PATCH 011/371] Capture image breadcrumbs early; improve waiting for intersection observer --- .../image-loading-optimization/detect.js | 137 +++++++++++------- 1 file changed, 81 insertions(+), 56 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index 4992764743..c9bf335957 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -32,8 +32,14 @@ function yieldToMain() { /** * @typedef {Object} Breadcrumb - * @property {number} index - * @property {string} tagName + * @property {number} index - Index of element among sibling elements. + * @property {string} tagName - Tag name. + */ + +/** + * @typedef {Object} ElementBreadcrumb + * @property {Element} element - Element node. + * @property {Breadcrumb} breadcrumb - Breadcrumb for the element. */ /** @@ -73,6 +79,8 @@ export default async function detect( isDebug ) { const runTime = new Date().valueOf(); + const doc = document; + const win = window; // Abort running detection logic if it was served in a cached page. if ( runTime - serveTime > detectionTimeWindow ) { @@ -88,14 +96,76 @@ export default async function detect( log( 'Proceeding with detection' ); } + // Obtain the admin bar element because we don't want to detect elements inside of it. + const adminBar = + /** @type {?HTMLDivElement} */ doc.getElementById( 'wpadminbar' ); + + // Note that we capture an array of image elements because getElementsByTagName() returns a live HTMLCollection. + // We also need to capture the original elements and their breadcrumbs as early as possible in case JavaScript is + // mutating the DOM from the original HTML rendered by the server, in which case the breadcrumbs obtained from the + // client will no longer be valid on the server. + const breadcrumbedImages = /** @type {ElementBreadcrumb[]} */ Array.from( + doc.body.getElementsByTagName( 'img' ) + ).map( ( img ) => { + return { + element: img, + breadcrumb: getBreadcrumbs( img ), + }; + } ); + const results = { viewport: { - width: window.innerWidth, - height: window.innerHeight, + width: win.innerWidth, + height: win.innerHeight, }, images: [], }; + // Ensure the DOM is loaded (although it surely already is since we're executing in a module). + await new Promise( ( resolve ) => { + if ( doc.readyState !== 'loading' ) { + resolve(); + } else { + doc.addEventListener( 'DOMContentLoaded', resolve, { once: true } ); + } + } ); + + /** @type {IntersectionObserverEntry[]} */ + const imageIntersections = []; + + /** @type {?IntersectionObserver} */ + let imageObserver; + + // Wait for the intersection observer to report back on the initially-visible images. + // Note that the first callback will include _all_ observed entries per . + if ( breadcrumbedImages.length > 0 ) { + await new Promise( ( resolve ) => { + imageObserver = new IntersectionObserver( + ( entries ) => { + for ( const entry of entries ) { + if ( entry.isIntersecting ) { + imageIntersections.push( entry ); + } + } + resolve(); + }, + { + root: null, // To watch for intersection relative to the device's viewport. + threshold: 0.0, // As soon as even one pixel is visible. + } + ); + + for ( const breadcrumbedImage of breadcrumbedImages ) { + if ( + ! adminBar || + ! adminBar.contains( breadcrumbedImage.element ) + ) { + imageObserver.observe( breadcrumbedImage.element ); + } + } + } ); + } + // TODO: Use a local copy of web-vitals. const { onLCP } = await import( // eslint-disable-next-line import/no-unresolved @@ -105,8 +175,8 @@ export default async function detect( /** @type {LCPMetricWithAttribution[]} */ const lcpMetricCandidates = []; - // Obtain at least one LCP candidate. - const lcpCandidateObtained = new Promise( ( resolve ) => { + // Obtain at least one LCP candidate. More may be reported before the page finishes loading. + await new Promise( ( resolve ) => { onLCP( ( metric ) => { lcpMetricCandidates.push( metric ); @@ -121,64 +191,19 @@ export default async function detect( ); } ); - // Ensure the DOM is loaded (although it surely already is since we're executing in a module). - await new Promise( ( resolve ) => { - if ( document.readyState !== 'loading' ) { - resolve(); - } else { - document.addEventListener( 'DOMContentLoaded', resolve ); - } - } ); - - /** @type {IntersectionObserverEntry[]} */ - const imageIntersections = []; - - const imageObserver = new IntersectionObserver( - ( entries ) => { - for ( const entry of entries ) { - if ( entry.isIntersecting ) { - imageIntersections.push( entry ); - } - } - }, - { - root: null, // To watch for intersection relative to the device's viewport. - threshold: 0.0, // As soon as even one pixel is visible. - } - ); - - const adminBar = document.getElementById( 'wpadminbar' ); - const imgCollection = document.body.getElementsByTagName( 'img' ); - for ( /** @type {HTMLImageElement} */ const img of imgCollection ) { - if ( ! adminBar || ! adminBar.contains( img ) ) { - imageObserver.observe( img ); - } - } - - // Wait until we have an LCP candidate, although more may come upon the page finishing loading. - await lcpCandidateObtained; - // Wait until the images on the page have fully loaded. await new Promise( ( resolve ) => { - if ( document.readyState === 'complete' ) { + if ( doc.readyState === 'complete' ) { resolve(); } else { - window.addEventListener( 'load', resolve, { once: true } ); - } - } ); - - // Give the image intersection observer a chance to report back. - // TODO: This needs to be hardened. How long to wait for callback? What about when there are no images in the page? - await new Promise( async ( resolve ) => { - if ( window.requestIdleCallback ) { - window.requestIdleCallback( resolve ); - } else { - setTimeout( resolve, 1 ); + win.addEventListener( 'load', resolve, { once: true } ); } } ); // Stop observing. - imageObserver.disconnect(); + if ( imageObserver ) { + imageObserver.disconnect(); + } if ( isDebug ) { log( 'Detection is stopping.' ); } From 6d90d75f11b4a9f905f20ed4497c125f5e871beb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 31 Oct 2023 10:58:02 -0700 Subject: [PATCH 012/371] Add getBreadcrumbedElements() function --- .../image-loading-optimization/detect.js | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index c9bf335957..4491cd24d7 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -2,6 +2,9 @@ const consoleLogPrefix = '[Image Loading Optimization]'; +const win = window; +const doc = win.document; + function log( ...message ) { console.log( consoleLogPrefix, ...message ); } @@ -38,9 +41,31 @@ function yieldToMain() { /** * @typedef {Object} ElementBreadcrumb - * @property {Element} element - Element node. - * @property {Breadcrumb} breadcrumb - Breadcrumb for the element. + * @property {Element} element - Element node. + * @property {Breadcrumb[]} breadcrumbs - Breadcrumb for the element. + */ + +/** + * Get breadcrumbed elements. + * + * @param {string} selector + * @return {ElementBreadcrumb[]} Breadcrumbed elements. */ +function getBreadcrumbedElements( selector ) { + /** @type {ElementBreadcrumb[]} */ + const breadcrumbedElements = []; + + /** @type {HTMLCollection} */ + const elements = doc.body.querySelectorAll( selector ); + for ( const element of elements ) { + breadcrumbedElements.push( { + element, + breadcrumb: getBreadcrumbs( element ), + } ); + } + + return breadcrumbedElements; +} /** * Gets breadcrumbs for a given element. @@ -79,8 +104,6 @@ export default async function detect( isDebug ) { const runTime = new Date().valueOf(); - const doc = document; - const win = window; // Abort running detection logic if it was served in a cached page. if ( runTime - serveTime > detectionTimeWindow ) { @@ -104,14 +127,7 @@ export default async function detect( // We also need to capture the original elements and their breadcrumbs as early as possible in case JavaScript is // mutating the DOM from the original HTML rendered by the server, in which case the breadcrumbs obtained from the // client will no longer be valid on the server. - const breadcrumbedImages = /** @type {ElementBreadcrumb[]} */ Array.from( - doc.body.getElementsByTagName( 'img' ) - ).map( ( img ) => { - return { - element: img, - breadcrumb: getBreadcrumbs( img ), - }; - } ); + const breadcrumbedImages = getBreadcrumbedElements( 'img' ); const results = { viewport: { From 00be1c72d66ab6d09a7ce7b989d38e56cf56c7a1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 31 Oct 2023 11:10:36 -0700 Subject: [PATCH 013/371] Add missing lint-js to lint-staged and update lint-js/format-js to find all JS files --- .eslintrc.js | 6 ++++-- .gitignore | 3 +++ package.json | 7 +++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index eb23001613..66fac527a8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -11,11 +11,13 @@ const config = { 'no-console': 'off', }, env: { - 'browser': true, + browser: true, }, globals: { scheduler: false, - } + }, + // Note: The '/wp-*' pattern is to ignore symlinks which may be added for local development. + ignorePatterns: [ '/vendor', '/node_modules', '/wp-*' ], }; module.exports = config; diff --git a/.gitignore b/.gitignore index 1889d02de2..6d54c0c132 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ temp/ ._* .Trashes .svn + +# Possible symlinks to wp-env install-path directories. +/wp-* diff --git a/package.json b/package.json index d111960285..1518df73c7 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "test-plugins": "./bin/plugin/cli.js test-plugins", "test-plugins-multisite": "./bin/plugin/cli.js test-plugins --sitetype=multi", "enabled-modules": "./bin/plugin/cli.js enabled-modules", - "format-js": "wp-scripts format ./bin", - "lint-js": "wp-scripts lint-js ./bin", + "format-js": "wp-scripts format", + "lint-js": "wp-scripts lint-js", "format-php": "wp-env run composer run-script format", "phpstan": "wp-env run composer run-script phpstan", "prelint-php": "wp-env run composer 'install --no-interaction'", @@ -44,6 +44,9 @@ "*.php": [ "composer run-script lint", "composer run-script phpstan" + ], + "*.js": [ + "npm run lint-js" ] }, "dependencies": { From c9518a1ac58d297493fa1d3b40b9699de03dcaa6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 31 Oct 2023 11:12:30 -0700 Subject: [PATCH 014/371] Remove yet unused yieldToMain --- .../image-loading-optimization/detect.js | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index 4491cd24d7..9183e2e5ce 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -13,26 +13,6 @@ function warn( ...message ) { console.warn( consoleLogPrefix, ...message ); } -/** - * Yield to the main thread. - * - * @see https://developer.chrome.com/blog/introducing-scheduler-yield-origin-trial/#enter-scheduleryield - * @return {Promise} - */ -function yieldToMain() { - if ( - typeof scheduler !== 'undefined' && - typeof scheduler.yield === 'function' - ) { - return scheduler.yield(); - } - - // Fall back to setTimeout: - return new Promise( ( resolve ) => { - setTimeout( resolve, 0 ); - } ); -} - /** * @typedef {Object} Breadcrumb * @property {number} index - Index of element among sibling elements. From 898417691aba919c2d67bbb5738b87483f787feb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 1 Nov 2023 13:33:11 -0700 Subject: [PATCH 015/371] Prevent detection if page is not scrolled to top --- modules/images/image-loading-optimization/detect.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index 9183e2e5ce..c173f87e12 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -89,7 +89,17 @@ export default async function detect( if ( runTime - serveTime > detectionTimeWindow ) { if ( isDebug ) { warn( - 'Aborted detection for Image Loading Optimization due to being outside detection time window.' + 'Aborted detection due to being outside detection time window.' + ); + } + return; + } + + // Prevent detection when page is not scrolled to the initial viewport. + if ( doc.documentElement.scrollTop > 0 ) { + if ( isDebug ) { + warn( + 'Aborted detection since initial scroll position of page is not at the top.' ); } return; From 62be41bc48cf940d22f05d523606c47ae7a30c4a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 1 Nov 2023 13:33:53 -0700 Subject: [PATCH 016/371] Stop observing images as soon as scroll happens or detection is stopped --- .../images/image-loading-optimization/detect.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index c173f87e12..33f8170fbb 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -142,6 +142,13 @@ export default async function detect( /** @type {?IntersectionObserver} */ let imageObserver; + function disconnectImageObserver() { + if ( imageObserver instanceof IntersectionObserver ) { + imageObserver.disconnect(); + win.removeEventListener( 'scroll', disconnectImageObserver ); // Clean up, even though this is registered with once:true. + } + } + // Wait for the intersection observer to report back on the initially-visible images. // Note that the first callback will include _all_ observed entries per . if ( breadcrumbedImages.length > 0 ) { @@ -170,6 +177,12 @@ export default async function detect( } } } ); + + // Stop observing images as soon as the page scrolls since we only want initial-viewport images. + win.addEventListener( 'scroll', disconnectImageObserver, { + once: true, + passive: true, + } ); } // TODO: Use a local copy of web-vitals. @@ -207,9 +220,7 @@ export default async function detect( } ); // Stop observing. - if ( imageObserver ) { - imageObserver.disconnect(); - } + disconnectImageObserver(); if ( isDebug ) { log( 'Detection is stopping.' ); } From eb1d93fa581280afff9866a820b5a0a1d6857989 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 1 Nov 2023 13:59:50 -0700 Subject: [PATCH 017/371] Extend observation to elements with background images --- .../image-loading-optimization/detect.js | 81 +++++++++++-------- 1 file changed, 48 insertions(+), 33 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index 33f8170fbb..07c8efb7c4 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -20,7 +20,7 @@ function warn( ...message ) { */ /** - * @typedef {Object} ElementBreadcrumb + * @typedef {Object} ElementBreadcrumbs * @property {Element} element - Element node. * @property {Breadcrumb[]} breadcrumbs - Breadcrumb for the element. */ @@ -28,19 +28,18 @@ function warn( ...message ) { /** * Get breadcrumbed elements. * - * @param {string} selector - * @return {ElementBreadcrumb[]} Breadcrumbed elements. + * @param {HTMLCollection|Element[]} elements Elements. + * @return {ElementBreadcrumbs[]} Breadcrumbed elements. */ -function getBreadcrumbedElements( selector ) { - /** @type {ElementBreadcrumb[]} */ +function getBreadcrumbedElements( elements ) { + /** @type {ElementBreadcrumbs[]} */ const breadcrumbedElements = []; /** @type {HTMLCollection} */ - const elements = doc.body.querySelectorAll( selector ); for ( const element of elements ) { breadcrumbedElements.push( { element, - breadcrumb: getBreadcrumbs( element ), + breadcrumbs: getBreadcrumbs( element ), } ); } @@ -96,6 +95,7 @@ export default async function detect( } // Prevent detection when page is not scrolled to the initial viewport. + // TODO: Does this cause layout/reflow? https://gist.github.com/paulirish/5d52fb081b3570c81e3a if ( doc.documentElement.scrollTop > 0 ) { if ( isDebug ) { warn( @@ -113,11 +113,26 @@ export default async function detect( const adminBar = /** @type {?HTMLDivElement} */ doc.getElementById( 'wpadminbar' ); - // Note that we capture an array of image elements because getElementsByTagName() returns a live HTMLCollection. - // We also need to capture the original elements and their breadcrumbs as early as possible in case JavaScript is + // We need to capture the original elements and their breadcrumbs as early as possible in case JavaScript is // mutating the DOM from the original HTML rendered by the server, in which case the breadcrumbs obtained from the - // client will no longer be valid on the server. - const breadcrumbedImages = getBreadcrumbedElements( 'img' ); + // client will no longer be valid on the server. As such, the results are stored in an array and not any live list. + const breadcrumbedImages = getBreadcrumbedElements( + doc.body.querySelectorAll( 'img' ) + ); + + // We do the same for elements with background images which are not data: URLs. + const breadcrumbedElementsWithBackgrounds = getBreadcrumbedElements( + Array.from( + doc.body.querySelectorAll( '[style*="background"]' ) + ).filter( ( /** @type {Element} */ el ) => + /url\(\s*['"](?!=data:)/.test( el.style.backgroundImage ) + ) + ); + + const breadcrumbedOptimizableElements = [ + ...breadcrumbedImages, + ...breadcrumbedElementsWithBackgrounds, + ]; const results = { viewport: { @@ -137,27 +152,27 @@ export default async function detect( } ); /** @type {IntersectionObserverEntry[]} */ - const imageIntersections = []; + const elementIntersections = []; /** @type {?IntersectionObserver} */ - let imageObserver; + let intersectionObserver; - function disconnectImageObserver() { - if ( imageObserver instanceof IntersectionObserver ) { - imageObserver.disconnect(); - win.removeEventListener( 'scroll', disconnectImageObserver ); // Clean up, even though this is registered with once:true. + function disconnectIntersectionObserver() { + if ( intersectionObserver instanceof IntersectionObserver ) { + intersectionObserver.disconnect(); + win.removeEventListener( 'scroll', disconnectIntersectionObserver ); // Clean up, even though this is registered with once:true. } } - // Wait for the intersection observer to report back on the initially-visible images. + // Wait for the intersection observer to report back on the initially-visible elements. // Note that the first callback will include _all_ observed entries per . - if ( breadcrumbedImages.length > 0 ) { + if ( breadcrumbedOptimizableElements.length > 0 ) { await new Promise( ( resolve ) => { - imageObserver = new IntersectionObserver( + intersectionObserver = new IntersectionObserver( ( entries ) => { for ( const entry of entries ) { if ( entry.isIntersecting ) { - imageIntersections.push( entry ); + elementIntersections.push( entry ); } } resolve(); @@ -168,18 +183,18 @@ export default async function detect( } ); - for ( const breadcrumbedImage of breadcrumbedImages ) { + for ( const breadcrumbedElement of breadcrumbedOptimizableElements ) { if ( ! adminBar || - ! adminBar.contains( breadcrumbedImage.element ) + ! adminBar.contains( breadcrumbedElement.element ) ) { - imageObserver.observe( breadcrumbedImage.element ); + intersectionObserver.observe( breadcrumbedElement.element ); } } } ); - // Stop observing images as soon as the page scrolls since we only want initial-viewport images. - win.addEventListener( 'scroll', disconnectImageObserver, { + // Stop observing as soon as the page scrolls since we only want initial-viewport elements. + win.addEventListener( 'scroll', disconnectIntersectionObserver, { once: true, passive: true, } ); @@ -210,7 +225,7 @@ export default async function detect( ); } ); - // Wait until the images on the page have fully loaded. + // Wait until the resources on the page have fully loaded. await new Promise( ( resolve ) => { if ( doc.readyState === 'complete' ) { resolve(); @@ -220,19 +235,19 @@ export default async function detect( } ); // Stop observing. - disconnectImageObserver(); + disconnectIntersectionObserver(); if ( isDebug ) { log( 'Detection is stopping.' ); } const lcpMetric = lcpMetricCandidates.at( -1 ); - for ( const imageIntersection of imageIntersections ) { + for ( const elementIntersection of elementIntersections ) { log( - 'imageIntersection.target', - imageIntersection.target, - getBreadcrumbs( imageIntersection.target ), + 'elementIntersection.target', + elementIntersection.target, + getBreadcrumbs( elementIntersection.target ), lcpMetric && - imageIntersection.target === + elementIntersection.target === lcpMetric.attribution.lcpEntry.element ? 'is LCP' : 'is NOT LCP' From ad2e46c058fe5a409c2d18a9a92b06e3c3d6cec1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 1 Nov 2023 14:33:44 -0700 Subject: [PATCH 018/371] Leverage Map --- .../image-loading-optimization/detect.js | 44 ++++++++++++++----- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index 07c8efb7c4..7754e23eb3 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -129,17 +129,25 @@ export default async function detect( ) ); - const breadcrumbedOptimizableElements = [ + // Create a mapping of element to + /** @type {Map} */ + const breadcrumbedElementsMap = new Map(); + for ( const breadcrumbedElement of [ ...breadcrumbedImages, ...breadcrumbedElementsWithBackgrounds, - ]; + ] ) { + breadcrumbedElementsMap.set( + breadcrumbedElement.element, + breadcrumbedElement.breadcrumbs + ); + } const results = { viewport: { width: win.innerWidth, height: win.innerHeight, }, - images: [], + elements: [], }; // Ensure the DOM is loaded (although it surely already is since we're executing in a module). @@ -166,7 +174,7 @@ export default async function detect( // Wait for the intersection observer to report back on the initially-visible elements. // Note that the first callback will include _all_ observed entries per . - if ( breadcrumbedOptimizableElements.length > 0 ) { + if ( breadcrumbedElementsMap.size > 0 ) { await new Promise( ( resolve ) => { intersectionObserver = new IntersectionObserver( ( entries ) => { @@ -183,12 +191,9 @@ export default async function detect( } ); - for ( const breadcrumbedElement of breadcrumbedOptimizableElements ) { - if ( - ! adminBar || - ! adminBar.contains( breadcrumbedElement.element ) - ) { - intersectionObserver.observe( breadcrumbedElement.element ); + for ( const element of breadcrumbedElementsMap.keys() ) { + if ( ! adminBar || ! adminBar.contains( element ) ) { + intersectionObserver.observe( element ); } } } ); @@ -242,10 +247,22 @@ export default async function detect( const lcpMetric = lcpMetricCandidates.at( -1 ); for ( const elementIntersection of elementIntersections ) { + // const elementInfo = { + // ... + // }; + + const breadcrumbs = breadcrumbedElementsMap.get( + elementIntersection.target + ); + if ( ! breadcrumbs ) { + warn( 'Unable to look up breadcrumbs for element' ); + continue; + } + log( 'elementIntersection.target', elementIntersection.target, - getBreadcrumbs( elementIntersection.target ), + breadcrumbs, lcpMetric && elementIntersection.target === lcpMetric.attribution.lcpEntry.element @@ -258,4 +275,9 @@ export default async function detect( // TODO: Send data to server. log( results ); + + // Clean up. + breadcrumbedElementsMap.clear(); + breadcrumbedElementsWithBackgrounds.length = 0; + breadcrumbedImages.length = 0; } From 9a50aac83dd3ec0a3626765c815fc94d844750cf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 1 Nov 2023 15:08:22 -0700 Subject: [PATCH 019/371] Assemble page metrics to send; remove unneeded attribution build --- .../image-loading-optimization/detect.js | 82 ++++++++++++------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index 7754e23eb3..03c5d0678f 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -1,4 +1,4 @@ -/** @typedef {import("web-vitals").LCPMetricWithAttribution} LCPMetricWithAttribution */ +/** @typedef {import("web-vitals").LCPMetric} LCPMetric */ const consoleLogPrefix = '[Image Loading Optimization]'; @@ -25,9 +25,29 @@ function warn( ...message ) { * @property {Breadcrumb[]} breadcrumbs - Breadcrumb for the element. */ +/** + * @typedef {Object} ElementMetrics + * @property {boolean} isLCP - Whether it is the LCP candidate. + * @property {boolean} isLCPCandidate - Whether it is among the LCP candidates. + * @property {Breadcrumb[]} breadcrumbs - Breadcrumbs. + * @property {number} intersectionRatio - Intersection ratio. + * @property {DOMRectReadOnly} intersectionRect - Intersection rectangle. + * @property {DOMRectReadOnly} boundingClientRect - Bounding client rectangle. + */ + +/** + * @typedef {Object} PageMetrics + * @property {Object} viewport - Viewport. + * @property {number} viewport.width - Viewport width. + * @property {number} viewport.height - Viewport height. + * @property {ElementMetrics[]} elements - Metrics for the elements observed on the page. + */ + /** * Get breadcrumbed elements. * + * @todo We probably don't need this. + * * @param {HTMLCollection|Element[]} elements Elements. * @return {ElementBreadcrumbs[]} Breadcrumbed elements. */ @@ -142,14 +162,6 @@ export default async function detect( ); } - const results = { - viewport: { - width: win.innerWidth, - height: win.innerHeight, - }, - elements: [], - }; - // Ensure the DOM is loaded (although it surely already is since we're executing in a module). await new Promise( ( resolve ) => { if ( doc.readyState !== 'loading' ) { @@ -208,10 +220,10 @@ export default async function detect( // TODO: Use a local copy of web-vitals. const { onLCP } = await import( // eslint-disable-next-line import/no-unresolved - 'https://unpkg.com/web-vitals@3/dist/web-vitals.attribution.js?module' + 'https://unpkg.com/web-vitals@3/dist/web-vitals.js?module' ); - /** @type {LCPMetricWithAttribution[]} */ + /** @type {LCPMetric[]} */ const lcpMetricCandidates = []; // Obtain at least one LCP candidate. More may be reported before the page finishes loading. @@ -245,36 +257,50 @@ export default async function detect( log( 'Detection is stopping.' ); } + /** @type {PageMetrics} */ + const pageMetrics = { + viewport: { + width: win.innerWidth, + height: win.innerHeight, + }, + elements: [], + }; + const lcpMetric = lcpMetricCandidates.at( -1 ); - for ( const elementIntersection of elementIntersections ) { - // const elementInfo = { - // ... - // }; + for ( const elementIntersection of elementIntersections ) { const breadcrumbs = breadcrumbedElementsMap.get( elementIntersection.target ); if ( ! breadcrumbs ) { - warn( 'Unable to look up breadcrumbs for element' ); + if ( isDebug ) { + warn( 'Unable to look up breadcrumbs for element' ); + } continue; } - log( - 'elementIntersection.target', - elementIntersection.target, + const isLCP = + elementIntersection.target === lcpMetric?.entries[ 0 ]?.element; + + /** @type {ElementMetrics} */ + const elementMetrics = { + isLCP, + isLCPCandidate: !! lcpMetricCandidates.find( + ( lcpMetricCandidate ) => + lcpMetricCandidate.entries[ 0 ]?.element === + elementIntersection.target + ), breadcrumbs, - lcpMetric && - elementIntersection.target === - lcpMetric.attribution.lcpEntry.element - ? 'is LCP' - : 'is NOT LCP' - ); - } + intersectionRatio: elementIntersection.intersectionRatio, + intersectionRect: elementIntersection.intersectionRect, + boundingClientRect: elementIntersection.boundingClientRect, + }; - log( 'lcpCandidates', lcpMetricCandidates ); + pageMetrics.elements.push( elementMetrics ); + } // TODO: Send data to server. - log( results ); + log( pageMetrics ); // Clean up. breadcrumbedElementsMap.clear(); From a5eaf3ac3bc4ccc6331268b89f23500fb2f59256 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 1 Nov 2023 15:19:10 -0700 Subject: [PATCH 020/371] Remove unused getElementBreadcrumbsMap --- .../image-loading-optimization/detect.js | 59 ++++--------------- 1 file changed, 10 insertions(+), 49 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index 03c5d0678f..7b93d1b1bf 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -19,12 +19,6 @@ function warn( ...message ) { * @property {string} tagName - Tag name. */ -/** - * @typedef {Object} ElementBreadcrumbs - * @property {Element} element - Element node. - * @property {Breadcrumb[]} breadcrumbs - Breadcrumb for the element. - */ - /** * @typedef {Object} ElementMetrics * @property {boolean} isLCP - Whether it is the LCP candidate. @@ -43,29 +37,6 @@ function warn( ...message ) { * @property {ElementMetrics[]} elements - Metrics for the elements observed on the page. */ -/** - * Get breadcrumbed elements. - * - * @todo We probably don't need this. - * - * @param {HTMLCollection|Element[]} elements Elements. - * @return {ElementBreadcrumbs[]} Breadcrumbed elements. - */ -function getBreadcrumbedElements( elements ) { - /** @type {ElementBreadcrumbs[]} */ - const breadcrumbedElements = []; - - /** @type {HTMLCollection} */ - for ( const element of elements ) { - breadcrumbedElements.push( { - element, - breadcrumbs: getBreadcrumbs( element ), - } ); - } - - return breadcrumbedElements; -} - /** * Gets breadcrumbs for a given element. * @@ -136,31 +107,21 @@ export default async function detect( // We need to capture the original elements and their breadcrumbs as early as possible in case JavaScript is // mutating the DOM from the original HTML rendered by the server, in which case the breadcrumbs obtained from the // client will no longer be valid on the server. As such, the results are stored in an array and not any live list. - const breadcrumbedImages = getBreadcrumbedElements( - doc.body.querySelectorAll( 'img' ) - ); + const breadcrumbedImages = doc.body.querySelectorAll( 'img' ); // We do the same for elements with background images which are not data: URLs. - const breadcrumbedElementsWithBackgrounds = getBreadcrumbedElements( - Array.from( - doc.body.querySelectorAll( '[style*="background"]' ) - ).filter( ( /** @type {Element} */ el ) => - /url\(\s*['"](?!=data:)/.test( el.style.backgroundImage ) - ) + const breadcrumbedElementsWithBackgrounds = Array.from( + doc.body.querySelectorAll( '[style*="background"]' ) + ).filter( ( /** @type {Element} */ el ) => + /url\(\s*['"](?!=data:)/.test( el.style.backgroundImage ) ); - // Create a mapping of element to /** @type {Map} */ - const breadcrumbedElementsMap = new Map(); - for ( const breadcrumbedElement of [ - ...breadcrumbedImages, - ...breadcrumbedElementsWithBackgrounds, - ] ) { - breadcrumbedElementsMap.set( - breadcrumbedElement.element, - breadcrumbedElement.breadcrumbs - ); - } + const breadcrumbedElementsMap = new Map( + [ ...breadcrumbedImages, ...breadcrumbedElementsWithBackgrounds ].map( + ( element ) => [ element, getBreadcrumbs( element ) ] + ) + ); // Ensure the DOM is loaded (although it surely already is since we're executing in a module). await new Promise( ( resolve ) => { From 0fcbc69c889101b336cb3ea0965a059cc4eba764 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 1 Nov 2023 15:20:53 -0700 Subject: [PATCH 021/371] Remove obsolete cleanup code --- modules/images/image-loading-optimization/detect.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index 7b93d1b1bf..bd20d2f753 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -265,6 +265,4 @@ export default async function detect( // Clean up. breadcrumbedElementsMap.clear(); - breadcrumbedElementsWithBackgrounds.length = 0; - breadcrumbedImages.length = 0; } From c1e6d3523a9de1763c29df200972cbf51fb3fd53 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 1 Nov 2023 15:41:05 -0700 Subject: [PATCH 022/371] Move logic into getElementIndex helper function --- .../image-loading-optimization/detect.js | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index bd20d2f753..5e6d3d9cb3 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -1,10 +1,10 @@ /** @typedef {import("web-vitals").LCPMetric} LCPMetric */ -const consoleLogPrefix = '[Image Loading Optimization]'; - const win = window; const doc = win.document; +const consoleLogPrefix = '[Image Loading Optimization]'; + function log( ...message ) { console.log( consoleLogPrefix, ...message ); } @@ -37,25 +37,36 @@ function warn( ...message ) { * @property {ElementMetrics[]} elements - Metrics for the elements observed on the page. */ +/** + * Gets element index among siblings. + * + * @param {Element} element Element. + * @return {number} Index. + */ +function getElementIndex( element ) { + if ( ! element.parentElement ) { + return 0; + } + return [ ...element.parentElement.children ].indexOf( element ); +} + /** * Gets breadcrumbs for a given element. * - * @param {Element} element + * @param {Element} leafElement * @return {Breadcrumb[]} Breadcrumbs. */ -function getBreadcrumbs( element ) { +function getBreadcrumbs( leafElement ) { /** @type {Breadcrumb[]} */ const breadcrumbs = []; - let node = element; - while ( node instanceof Element ) { + let element = leafElement; + while ( element instanceof Element ) { breadcrumbs.unshift( { - tagName: node.tagName, - index: node.parentElement - ? Array.from( node.parentElement.children ).indexOf( node ) - : 0, + tagName: element.tagName, + index: getElementIndex( element ), } ); - node = node.parentElement; + element = element.parentElement; } return breadcrumbs; From 3871670b40ab99cdb194c6b699dd765e11d67667 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 1 Nov 2023 15:45:06 -0700 Subject: [PATCH 023/371] Use 3rd person singular for jsdoc --- modules/images/image-loading-optimization/detect.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index 5e6d3d9cb3..c279a7ad93 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -73,7 +73,7 @@ function getBreadcrumbs( leafElement ) { } /** - * Detect the LCP element, loaded images, client viewport and store for future optimizations. + * Detects the LCP element, loaded images, client viewport and store for future optimizations. * * @param {number} serveTime The serve time of the page in milliseconds from PHP via `ceil( microtime( true ) * 1000 )`. * @param {number} detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. From 68a01be56d4b3e25f3c2a5e9f692543752d9a3f1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 1 Nov 2023 17:06:00 -0700 Subject: [PATCH 024/371] Add initial REST API endpoint for storing page metrics --- .../image-loading-optimization/detect.js | 17 ++- .../image-loading-optimization/hooks.php | 8 +- .../image-loading-optimization/load.php | 1 + .../image-loading-optimization/rest-api.php | 115 ++++++++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 modules/images/image-loading-optimization/rest-api.php diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index c279a7ad93..a0e7a3ce8c 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -78,11 +78,15 @@ function getBreadcrumbs( leafElement ) { * @param {number} serveTime The serve time of the page in milliseconds from PHP via `ceil( microtime( true ) * 1000 )`. * @param {number} detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. * @param {boolean} isDebug Whether to show debug messages. + * @param {string} restApiEndpoint URL for where to send the detection data. + * @param {string} restApiNonce Nonce for writing to the REST API. */ export default async function detect( serveTime, detectionTimeWindow, - isDebug + isDebug, + restApiEndpoint, + restApiNonce ) { const runTime = new Date().valueOf(); @@ -271,6 +275,17 @@ export default async function detect( pageMetrics.elements.push( elementMetrics ); } + // TODO: Wait until idle. + const response = await fetch( restApiEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': restApiNonce, + }, + body: JSON.stringify( pageMetrics ), + } ); + log( 'response:', await response.json() ); + // TODO: Send data to server. log( pageMetrics ); diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 09692aa828..620c780a03 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -68,7 +68,13 @@ function image_loading_optimization_print_detection_script() { */ $detection_time_window = apply_filters( 'perflab_image_loading_detection_time_window', 5000 ); - $detect_args = array( $serve_time, $detection_time_window, WP_DEBUG ); + $detect_args = array( + $serve_time, + $detection_time_window, + WP_DEBUG, + rest_url( '/perflab/v1/image-loading-optimization/metrics-storage' ), + wp_create_nonce( 'wp_rest' ), + ); wp_print_inline_script_tag( sprintf( 'import detect from %s; detect( ...%s )', diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index fb6e31c2bf..6f271a96d2 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -15,3 +15,4 @@ require_once __DIR__ . '/helper.php'; require_once __DIR__ . '/hooks.php'; +require_once __DIR__ . '/rest-api.php'; diff --git a/modules/images/image-loading-optimization/rest-api.php b/modules/images/image-loading-optimization/rest-api.php new file mode 100644 index 0000000000..bef2555c2c --- /dev/null +++ b/modules/images/image-loading-optimization/rest-api.php @@ -0,0 +1,115 @@ + 'object', + 'properties' => array( + 'width' => array( + 'type' => 'number', + 'minimum' => 0, + ), + 'height' => array( + 'type' => 'number', + 'minimum' => 0, + ), + // TODO: There are other properties to define if we need them: x, y, top, right, bottom, left. + ), + ); + + register_rest_route( + 'perflab/v1', + '/image-loading-optimization/metrics-storage', + array( + 'methods' => 'POST', + 'callback' => 'image_loading_optimization_handle_rest_request', + 'permission_callback' => '__return_true', // Needs to be available to unauthenticated visitors. + 'args' => array( + 'viewport' => array( + 'description' => __( 'Viewport dimensions', 'performance-lab' ), + 'type' => 'object', + 'required' => true, + 'properties' => array( + 'width' => array( + 'type' => 'int', + 'minimum' => 0, + ), + 'height' => array( + 'type' => 'int', + 'minimum' => 0, + ), + ), + ), + 'elements' => array( + 'description' => __( 'Element metrics', 'performance-lab' ), + 'type' => 'array', + 'items' => array( + // See the ElementMetrics in detect.js. + 'type' => 'object', + 'properties' => array( + 'isLCP' => array( + 'type' => 'bool', + ), + 'isLCPCandidate' => array( + 'type' => 'bool', + ), + 'breadcrumbs' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'tagName' => array( + 'type' => 'string', + // TODO: Pattern? + ), + 'index' => array( + 'type' => 'int', + 'minimum' => 0, + ), + ), + ), + ), + 'intersectionRatio' => array( + 'type' => 'number', + 'minimum' => 0.0, + 'maximum' => 1.0, + ), + 'intersectionRect' => $dom_rect_schema, + 'boundingClientRect' => $dom_rect_schema, + ), + ), + ), + ), + ) + ); +} +add_action( 'rest_api_init', 'image_loading_optimization_register_endpoint' ); + +/** + * Handle REST API request to store metrics. + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response Response. + */ +function image_loading_optimization_handle_rest_request( WP_REST_Request $request ) { + + return new WP_REST_Response( + array( + 'success' => true, + 'body' => $request->get_json_params(), + ) + ); +} From 73dbfd1675d114f8989ef6e41ce1a38f914278f6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 2 Nov 2023 11:44:49 -0700 Subject: [PATCH 025/371] Remove accounting for /wp-* symlinks in local dev --- .eslintrc.js | 3 +-- .gitignore | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 66fac527a8..3627d09917 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,8 +16,7 @@ const config = { globals: { scheduler: false, }, - // Note: The '/wp-*' pattern is to ignore symlinks which may be added for local development. - ignorePatterns: [ '/vendor', '/node_modules', '/wp-*' ], + ignorePatterns: [ '/vendor', '/node_modules' ], }; module.exports = config; diff --git a/.gitignore b/.gitignore index 6d54c0c132..1889d02de2 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,3 @@ temp/ ._* .Trashes .svn - -# Possible symlinks to wp-env install-path directories. -/wp-* From 66284bb3cd5fbab0fc402ed20a82d43aed5d00cb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 2 Nov 2023 13:45:16 -0700 Subject: [PATCH 026/371] Add IMAGE_LOADING_OPTIMIZATION_VERSION constant --- modules/images/image-loading-optimization/load.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index fb6e31c2bf..1798dcc440 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -8,6 +8,13 @@ * @since n.e.x.t */ +// Define the constant. +if ( defined( 'IMAGE_LOADING_OPTIMIZATION_VERSION' ) ) { + return; +} + +define( 'IMAGE_LOADING_OPTIMIZATION_VERSION', 'Performance Lab ' . PERFLAB_VERSION ); + // Do not load the code if it is already loaded through another means. if ( function_exists( 'image_loading_optimization_buffer_output' ) ) { return; From b4e29d609647c8e23fc4db6532e50ba8692e82c5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 2 Nov 2023 16:06:26 -0700 Subject: [PATCH 027/371] Include url in PageMetrics and further define schema --- .../image-loading-optimization/detect.js | 2 ++ .../image-loading-optimization/hooks.php | 3 ++ .../image-loading-optimization/rest-api.php | 34 ++++++++++++------- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index a0e7a3ce8c..129079b4a7 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -31,6 +31,7 @@ function warn( ...message ) { /** * @typedef {Object} PageMetrics + * @property {string} url - URL of the page. * @property {Object} viewport - Viewport. * @property {number} viewport.width - Viewport width. * @property {number} viewport.height - Viewport height. @@ -235,6 +236,7 @@ export default async function detect( /** @type {PageMetrics} */ const pageMetrics = { + url: win.location.href, // TODO: Consider sending canonical URL instead. viewport: { width: win.innerWidth, height: win.innerHeight, diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 620c780a03..666c795214 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -49,6 +49,9 @@ static function ( $output ) { /** * Prints the script for detecting loaded images and the LCP element. + * + * @todo This should eventually only print the script if metrics are needed. + * @todo This script should not be printed if the page was requested with non-removal (non-canonical) query args. */ function image_loading_optimization_print_detection_script() { $serve_time = ceil( microtime( true ) * 1000 ); diff --git a/modules/images/image-loading-optimization/rest-api.php b/modules/images/image-loading-optimization/rest-api.php index bef2555c2c..69519ec562 100644 --- a/modules/images/image-loading-optimization/rest-api.php +++ b/modules/images/image-loading-optimization/rest-api.php @@ -38,18 +38,25 @@ function image_loading_optimization_register_endpoint() { 'callback' => 'image_loading_optimization_handle_rest_request', 'permission_callback' => '__return_true', // Needs to be available to unauthenticated visitors. 'args' => array( + 'url' => array( + 'type' => 'string', + 'required' => true, + 'format' => 'uri', + ), 'viewport' => array( 'description' => __( 'Viewport dimensions', 'performance-lab' ), 'type' => 'object', 'required' => true, 'properties' => array( 'width' => array( - 'type' => 'int', - 'minimum' => 0, + 'type' => 'int', + 'required' => true, + 'minimum' => 0, ), 'height' => array( - 'type' => 'int', - 'minimum' => 0, + 'type' => 'int', + 'required' => true, + 'minimum' => 0, ), ), ), @@ -61,19 +68,21 @@ function image_loading_optimization_register_endpoint() { 'type' => 'object', 'properties' => array( 'isLCP' => array( - 'type' => 'bool', + 'type' => 'bool', + 'required' => true, ), 'isLCPCandidate' => array( 'type' => 'bool', ), 'breadcrumbs' => array( - 'type' => 'array', - 'items' => array( + 'type' => 'array', + 'required' => true, + 'items' => array( 'type' => 'object', 'properties' => array( 'tagName' => array( - 'type' => 'string', - // TODO: Pattern? + 'type' => 'string', + 'pattern' => '^[a-zA-Z0-9-]+$', ), 'index' => array( 'type' => 'int', @@ -83,9 +92,10 @@ function image_loading_optimization_register_endpoint() { ), ), 'intersectionRatio' => array( - 'type' => 'number', - 'minimum' => 0.0, - 'maximum' => 1.0, + 'type' => 'number', + 'required' => true, + 'minimum' => 0.0, + 'maximum' => 1.0, ), 'intersectionRect' => $dom_rect_schema, 'boundingClientRect' => $dom_rect_schema, From ffcae75d01ea982722fa217c336c76be0d2d1ccc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 2 Nov 2023 16:12:06 -0700 Subject: [PATCH 028/371] Validate that the provided URL is for this site --- .../images/image-loading-optimization/rest-api.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/rest-api.php b/modules/images/image-loading-optimization/rest-api.php index 69519ec562..bcb764643a 100644 --- a/modules/images/image-loading-optimization/rest-api.php +++ b/modules/images/image-loading-optimization/rest-api.php @@ -39,9 +39,15 @@ function image_loading_optimization_register_endpoint() { 'permission_callback' => '__return_true', // Needs to be available to unauthenticated visitors. 'args' => array( 'url' => array( - 'type' => 'string', - 'required' => true, - 'format' => 'uri', + 'type' => 'string', + 'required' => true, + 'format' => 'uri', + 'validate_callback' => static function ( $url ) { + if ( ! wp_validate_redirect( $url ) ) { + return new WP_Error( 'non_origin_url', __( 'URL for another site provided.', 'performance-lab' ) ); + } + return true; + }, ), 'viewport' => array( 'description' => __( 'Viewport dimensions', 'performance-lab' ), From 1634fb9a5be0f8e22e4950c086b94e36e52f76cb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 2 Nov 2023 17:18:32 -0700 Subject: [PATCH 029/371] Ensure both tagName and index are required --- modules/images/image-loading-optimization/rest-api.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/rest-api.php b/modules/images/image-loading-optimization/rest-api.php index bcb764643a..335a70aa05 100644 --- a/modules/images/image-loading-optimization/rest-api.php +++ b/modules/images/image-loading-optimization/rest-api.php @@ -87,12 +87,14 @@ function image_loading_optimization_register_endpoint() { 'type' => 'object', 'properties' => array( 'tagName' => array( - 'type' => 'string', - 'pattern' => '^[a-zA-Z0-9-]+$', + 'type' => 'string', + 'required' => true, + 'pattern' => '^[a-zA-Z0-9-]+$', ), 'index' => array( - 'type' => 'int', - 'minimum' => 0, + 'type' => 'int', + 'required' => true, + 'minimum' => 0, ), ), ), From 534eba2d1378970b5a535739190bcc459c6f8fd6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 2 Nov 2023 17:57:58 -0700 Subject: [PATCH 030/371] Add initial storage locking to protect against flooding --- .../image-loading-optimization/helper.php | 56 +++++++++++++++++++ .../image-loading-optimization/hooks.php | 5 ++ .../image-loading-optimization/rest-api.php | 16 +++++- 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/helper.php b/modules/images/image-loading-optimization/helper.php index 77e6b12b10..59e47f937a 100644 --- a/modules/images/image-loading-optimization/helper.php +++ b/modules/images/image-loading-optimization/helper.php @@ -9,3 +9,59 @@ if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. } + +/** + * Gets the TTL for the metrics storage lock. + * + * @return int TTL. + */ +function image_loading_optimization_get_metrics_storage_lock_ttl() { + + /** + * Filters how long a given IP is locked from submitting another metrics-storage REST API request. + * + * @param int $ttl TTL. + */ + return (int) apply_filters( 'perflab_image_loading_detection_lock_ttl', 10 * MINUTE_IN_SECONDS ); +} + +/** + * Gets transient key for locking metrics storage (for the current IP). + * + * @return string Transient key. + */ +function image_loading_optimization_get_metrics_storage_lock_transient_key() { + $ip_address = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR']; + return 'page_metrics_storage_lock_' . wp_hash( $ip_address ); +} + +/** + * Sets metrics storage lock (for the current IP). + */ +function image_loading_optimization_set_metrics_storage_lock() { + $ttl = image_loading_optimization_get_metrics_storage_lock_ttl(); + $key = image_loading_optimization_get_metrics_storage_lock_transient_key(); + if ( 0 === $ttl ) { + delete_transient( $key ); + } else { + set_transient( $key, time(), $ttl ); + } +} + +/** + * Checks whether metrics storage is locked (for the current IP). + * + * @todo This isn't working properly? + * @return bool Whether locked. + */ +function image_loading_optimization_is_metrics_storage_locked() { + $ttl = image_loading_optimization_get_metrics_storage_lock_ttl(); + if ( 0 === $ttl ) { + return false; + } + $transient = (int) get_transient( image_loading_optimization_get_metrics_storage_lock_transient_key() ); + if ( 0 === $transient ) { + return false; + } + return time() - $transient < $ttl; +} diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 666c795214..88dd70635b 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -54,6 +54,11 @@ static function ( $output ) { * @todo This script should not be printed if the page was requested with non-removal (non-canonical) query args. */ function image_loading_optimization_print_detection_script() { + + if ( image_loading_optimization_is_metrics_storage_locked() ) { + return; + } + $serve_time = ceil( microtime( true ) * 1000 ); /** diff --git a/modules/images/image-loading-optimization/rest-api.php b/modules/images/image-loading-optimization/rest-api.php index 335a70aa05..0746ff1315 100644 --- a/modules/images/image-loading-optimization/rest-api.php +++ b/modules/images/image-loading-optimization/rest-api.php @@ -36,7 +36,17 @@ function image_loading_optimization_register_endpoint() { array( 'methods' => 'POST', 'callback' => 'image_loading_optimization_handle_rest_request', - 'permission_callback' => '__return_true', // Needs to be available to unauthenticated visitors. + 'permission_callback' => static function () { + // Needs to be available to unauthenticated visitors. + if ( image_loading_optimization_is_metrics_storage_locked() ) { + return new WP_Error( + 'metrics_storage_locked', + __( 'Metrics storage is presently locked for the current IP.', 'performance-lab' ), + array( 'status' => 403 ) + ); + } + return true; + }, 'args' => array( 'url' => array( 'type' => 'string', @@ -124,6 +134,10 @@ function image_loading_optimization_register_endpoint() { */ function image_loading_optimization_handle_rest_request( WP_REST_Request $request ) { + // TODO: We need storage. + + image_loading_optimization_set_metrics_storage_lock(); + return new WP_REST_Response( array( 'success' => true, From ba30471f82bc97fae8eb9788375d81d2d2bdabaa Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 3 Nov 2023 11:44:18 -0700 Subject: [PATCH 031/371] Update metrics storage locking --- modules/images/image-loading-optimization/helper.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/images/image-loading-optimization/helper.php b/modules/images/image-loading-optimization/helper.php index 59e47f937a..e55f0bb992 100644 --- a/modules/images/image-loading-optimization/helper.php +++ b/modules/images/image-loading-optimization/helper.php @@ -20,14 +20,17 @@ function image_loading_optimization_get_metrics_storage_lock_ttl() { /** * Filters how long a given IP is locked from submitting another metrics-storage REST API request. * + * Filtering the TTL to zero will disable any metrics storage locking. This is useful during development. + * * @param int $ttl TTL. */ - return (int) apply_filters( 'perflab_image_loading_detection_lock_ttl', 10 * MINUTE_IN_SECONDS ); + return (int) apply_filters( 'perflab_image_loading_optimization_metrics_storage_lock_ttl', MINUTE_IN_SECONDS ); } /** * Gets transient key for locking metrics storage (for the current IP). * + * @todo Should the URL be included in the key? Or should a user only be allowed to store one metric? * @return string Transient key. */ function image_loading_optimization_get_metrics_storage_lock_transient_key() { @@ -51,7 +54,6 @@ function image_loading_optimization_set_metrics_storage_lock() { /** * Checks whether metrics storage is locked (for the current IP). * - * @todo This isn't working properly? * @return bool Whether locked. */ function image_loading_optimization_is_metrics_storage_locked() { @@ -59,9 +61,9 @@ function image_loading_optimization_is_metrics_storage_locked() { if ( 0 === $ttl ) { return false; } - $transient = (int) get_transient( image_loading_optimization_get_metrics_storage_lock_transient_key() ); - if ( 0 === $transient ) { + $locked_time = (int) get_transient( image_loading_optimization_get_metrics_storage_lock_transient_key() ); + if ( 0 === $locked_time ) { return false; } - return time() - $transient < $ttl; + return time() - $locked_time < $ttl; } From 4e0d7de6cc43c0d7bd9e89c4352b754239fcda79 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 3 Nov 2023 11:54:32 -0700 Subject: [PATCH 032/371] Move storage helper functions into storage.php --- .../image-loading-optimization/helper.php | 58 ---------------- .../image-loading-optimization/load.php | 1 + .../image-loading-optimization/storage.php | 69 +++++++++++++++++++ 3 files changed, 70 insertions(+), 58 deletions(-) create mode 100644 modules/images/image-loading-optimization/storage.php diff --git a/modules/images/image-loading-optimization/helper.php b/modules/images/image-loading-optimization/helper.php index e55f0bb992..77e6b12b10 100644 --- a/modules/images/image-loading-optimization/helper.php +++ b/modules/images/image-loading-optimization/helper.php @@ -9,61 +9,3 @@ if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. } - -/** - * Gets the TTL for the metrics storage lock. - * - * @return int TTL. - */ -function image_loading_optimization_get_metrics_storage_lock_ttl() { - - /** - * Filters how long a given IP is locked from submitting another metrics-storage REST API request. - * - * Filtering the TTL to zero will disable any metrics storage locking. This is useful during development. - * - * @param int $ttl TTL. - */ - return (int) apply_filters( 'perflab_image_loading_optimization_metrics_storage_lock_ttl', MINUTE_IN_SECONDS ); -} - -/** - * Gets transient key for locking metrics storage (for the current IP). - * - * @todo Should the URL be included in the key? Or should a user only be allowed to store one metric? - * @return string Transient key. - */ -function image_loading_optimization_get_metrics_storage_lock_transient_key() { - $ip_address = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR']; - return 'page_metrics_storage_lock_' . wp_hash( $ip_address ); -} - -/** - * Sets metrics storage lock (for the current IP). - */ -function image_loading_optimization_set_metrics_storage_lock() { - $ttl = image_loading_optimization_get_metrics_storage_lock_ttl(); - $key = image_loading_optimization_get_metrics_storage_lock_transient_key(); - if ( 0 === $ttl ) { - delete_transient( $key ); - } else { - set_transient( $key, time(), $ttl ); - } -} - -/** - * Checks whether metrics storage is locked (for the current IP). - * - * @return bool Whether locked. - */ -function image_loading_optimization_is_metrics_storage_locked() { - $ttl = image_loading_optimization_get_metrics_storage_lock_ttl(); - if ( 0 === $ttl ) { - return false; - } - $locked_time = (int) get_transient( image_loading_optimization_get_metrics_storage_lock_transient_key() ); - if ( 0 === $locked_time ) { - return false; - } - return time() - $locked_time < $ttl; -} diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index 6f271a96d2..e760b0a6e9 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -15,4 +15,5 @@ require_once __DIR__ . '/helper.php'; require_once __DIR__ . '/hooks.php'; +require_once __DIR__ . '/storage.php'; require_once __DIR__ . '/rest-api.php'; diff --git a/modules/images/image-loading-optimization/storage.php b/modules/images/image-loading-optimization/storage.php new file mode 100644 index 0000000000..e3032c28bb --- /dev/null +++ b/modules/images/image-loading-optimization/storage.php @@ -0,0 +1,69 @@ + Date: Fri, 3 Nov 2023 12:15:23 -0700 Subject: [PATCH 033/371] Add post type for page metrics storage --- .../image-loading-optimization/storage.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/modules/images/image-loading-optimization/storage.php b/modules/images/image-loading-optimization/storage.php index e3032c28bb..49a33ccd75 100644 --- a/modules/images/image-loading-optimization/storage.php +++ b/modules/images/image-loading-optimization/storage.php @@ -10,6 +10,8 @@ exit; // Exit if accessed directly. } +define( 'IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE', 'ilo_page_metrics' ); + /** * Gets the TTL for the metrics storage lock. * @@ -67,3 +69,28 @@ function image_loading_optimization_is_metrics_storage_locked() { } return time() - $locked_time < $ttl; } + +/** + * Register post type for metrics storage. + * + * This the configuration for this post type is similar to the oembed_cache in core. + */ +function image_loading_optimization_register_page_metrics_post_type() { + register_post_type( + IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE, + array( + 'labels' => array( + 'name' => __( 'Page Metrics', 'performance-lab' ), + 'singular_name' => __( 'Page Metrics', 'performance-lab' ), + ), + 'public' => false, + 'hierarchical' => false, + 'rewrite' => false, + 'query_var' => false, + 'delete_with_user' => false, + 'can_export' => false, + 'supports' => array(), + ) + ); +} +add_action( 'init', 'image_loading_optimization_register_page_metrics_post_type' ); From b7396c8106c5d224bc1853aa14a4a0144ac0da4e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 6 Nov 2023 17:15:32 -0800 Subject: [PATCH 034/371] WIP --- composer.json | 3 +- .../image-loading-optimization/rest-api.php | 2 +- .../image-loading-optimization/storage.php | 189 +++++++++++++++++- 3 files changed, 191 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 8ddca6af09..27cd7d9afc 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,8 @@ }, "require": { "composer/installers": "~1.0", - "php": ">=7|^8" + "php": ">=7|^8", + "ext-json": "*" }, "scripts": { "phpstan": "phpstan analyze --ansi --memory-limit=2048M", diff --git a/modules/images/image-loading-optimization/rest-api.php b/modules/images/image-loading-optimization/rest-api.php index 0746ff1315..a347f75d29 100644 --- a/modules/images/image-loading-optimization/rest-api.php +++ b/modules/images/image-loading-optimization/rest-api.php @@ -32,7 +32,7 @@ function image_loading_optimization_register_endpoint() { register_rest_route( 'perflab/v1', - '/image-loading-optimization/metrics-storage', + '/image-loading-optimization/metrics-storage', // @todo or rather metric-storage? array( 'methods' => 'POST', 'callback' => 'image_loading_optimization_handle_rest_request', diff --git a/modules/images/image-loading-optimization/storage.php b/modules/images/image-loading-optimization/storage.php index 49a33ccd75..c9413609b3 100644 --- a/modules/images/image-loading-optimization/storage.php +++ b/modules/images/image-loading-optimization/storage.php @@ -29,6 +29,22 @@ function image_loading_optimization_get_metrics_storage_lock_ttl() { return (int) apply_filters( 'perflab_image_loading_optimization_metrics_storage_lock_ttl', MINUTE_IN_SECONDS ); } +/** + * Gets the maximum width for a viewport to be considered as a mobile device. + * + * @todo This could instead return an array of thresholds, like [ 320, 480, 576 ] which would add additional buckets for small smartphones and phablets in addition to normal smartphones and desktops. + * @return int Viewport width. + */ +function image_loading_optimization_get_max_mobile_viewport_width() { + + /** + * Filters the maximum width for a viewport to be considered as a mobile device. + * + * @param int $mobile_max_width Mobile max width. + */ + return (int) apply_filters( 'perflab_image_loading_optimization_max_mobile_viewport_with', 480 ); +} + /** * Gets transient key for locking metrics storage (for the current IP). * @@ -89,8 +105,179 @@ function image_loading_optimization_register_page_metrics_post_type() { 'query_var' => false, 'delete_with_user' => false, 'can_export' => false, - 'supports' => array(), + 'supports' => array( 'title' ), // The original URL is stored in the post_title, and the MD5 hash in the post_name. ) ); } add_action( 'init', 'image_loading_optimization_register_page_metrics_post_type' ); + +/** + * Gets desired sample size for a viewport's page metrics. + * + * @return int + */ +function image_loading_optimization_get_page_metrics_viewport_sample_size() { + /** + * Filters desired sample size for a viewport's page metrics. + * + * @param int $sample_size Sample size. + */ + return (int) apply_filters( 'perflab_image_loading_optimization_page_metrics_viewport_sample_size', 10 ); +} + +/** + * Get slug for page metrics post. + * + * @param string $url URL. + * @return string Slug for URL. + */ +function image_loading_optimization_get_page_metrics_slug( $url ) { + return md5( $url ); +} + +/** + * Get page metrics post. + * + * @param string $url URL. + * @return WP_Post|null Post object if exists. + */ +function image_loading_optimization_get_page_metrics_post( $url ) { + $post_query = new WP_Query( + array( + 'post_type' => IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE, + 'post_status' => 'publish', + 'name' => image_loading_optimization_get_page_metrics_slug( $url ), + 'posts_per_page' => 1, + 'no_found_rows' => true, + 'cache_results' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'lazy_load_term_meta' => false, + ) + ); + + $post = array_shift( $post_query->posts ); + if ( $post instanceof WP_Post ) { + return $post; + } else { + return null; + } +} + +/** + * Store page metrics. + * + * @param WP_Post $post Page metrics post. + * @return array|WP_Error Page metrics when valid, or WP_Error otherwise. + */ +function image_loading_optimization_parse_stored_page_metrics( WP_Post $post ) { + $page_metrics = json_decode( $post->post_content, true ); + if ( json_last_error() ) { + return new WP_Error( + 'page_metrics_json_parse_error', + sprintf( + /* translators: 1: Post type slug, 2: JSON error message */ + __( 'Contents of %1$s post type not valid JSON: %2$s', 'performance-lab' ), + IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE, + json_last_error_msg() + ) + ); + } + if ( ! is_array( $page_metrics ) ) { + return new WP_Error( + 'page_metrics_invalid_data_format', + sprintf( + /* translators: %s is post type slug */ + __( 'Contents of %s post type was not a JSON array.', 'performance-lab' ), + IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE + ) + ); + } + return $page_metrics; +} + +/** + * + * @todo This needs to take a set of page metrics and segment the individual metrics into breakpoints. + * + * @return void + */ +function image_loading_optimization_segment_stored_page_metrics() { + +} + +/** + * Store page metrics. + * + * The $validated_page_metrics parameter has the following array shape: + * + * { + * 'url': string, + * 'viewport': array{ + * 'width': int, + * 'height': int + * }, + * 'elements': array + * } + * + * @param array $validated_page_metrics Page metrics, already validated by REST API. + * @return true|WP_Error True on success or WP_Error otherwise. + */ +function image_loading_optimization_store_page_metrics( array $validated_page_metrics ) { + $url = $validated_page_metrics['url']; + unset( $validated_page_metrics['url'] ); // Not stored in post_content but rather in post_title/post_name. + + // TODO: What about storing a version identifier? + $post_data = array( + 'post_title' => $url, + ); + + $post = image_loading_optimization_get_page_metrics_post( $url ); + + if ( $post instanceof WP_Post ) { + $post_data['ID'] = $post->ID; + $post_data['post_name'] = $post->post_name; + + $page_metrics = image_loading_optimization_parse_stored_page_metrics( $post ); + if ( $page_metrics instanceof WP_Error ) { + if ( function_exists( 'wp_trigger_error' ) ) { + wp_trigger_error( __FUNCTION__, esc_html( $page_metrics->get_error_message() ) ); + } + $page_metrics = array(); + } + } else { + $post_data['post_name'] = image_loading_optimization_get_page_metrics_slug( $url ); + $page_metrics = array(); + } + + // TODO: Unshift the first metrics entry if we are currently at the max allowed. + $segmented_page_metrics = + + $mobile_max_width = image_loading_optimization_get_max_mobile_viewport_width(); + $viewport_sample_size = image_loading_optimization_get_page_metrics_viewport_sample_size(); + + $viewport_page_metrics = array(); + + $existing_storage[] = $validated_page_metrics; + + + $post_data['post_content'] = wp_json_encode( $validated_page_metrics ); + + $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); + if ( $has_kses ) { + // Prevent KSES from corrupting JSON in post_content. + kses_remove_filters(); + } + + if ( isset( $post_data['ID'] ) ) { + $result = wp_update_post( wp_slash( $post_data ), true ); + } else { + $result = wp_insert_post( wp_slash( $post_data ), true ); + } + + if ( $has_kses ) { + kses_init_filters(); + } + + return $result instanceof WP_Error ? $result : true; +} From 912abef650c67866fe14ef2bd3d782d320de9adc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 6 Nov 2023 17:18:39 -0800 Subject: [PATCH 035/371] Undo turning off no-console eslint rule --- .eslintrc.js | 1 - modules/images/image-loading-optimization/detect.js | 12 ++++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.eslintrc.js b/.eslintrc.js index 3627d09917..4a43261aee 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,7 +8,6 @@ const config = { rules: { ...( wpConfig?.rules || {} ), 'jsdoc/valid-types': 'off', - 'no-console': 'off', }, env: { browser: true, diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index c279a7ad93..f6f0677a09 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -5,11 +5,23 @@ const doc = win.document; const consoleLogPrefix = '[Image Loading Optimization]'; +/** + * Log a message. + * + * @param {...*} message + */ function log( ...message ) { + // eslint-disable-next-line no-console console.log( consoleLogPrefix, ...message ); } +/** + * Log a warning. + * + * @param {...*} message + */ function warn( ...message ) { + // eslint-disable-next-line no-console console.warn( consoleLogPrefix, ...message ); } From 01115e5f749dd34a74fdb13f24cd551b92915ff2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 6 Nov 2023 17:27:06 -0800 Subject: [PATCH 036/371] Ignore webp-uploads/fallback.js from linting for now --- .eslintrc.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.eslintrc.js b/.eslintrc.js index 4a43261aee..d2607fb45e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,7 +15,11 @@ const config = { globals: { scheduler: false, }, - ignorePatterns: [ '/vendor', '/node_modules' ], + ignorePatterns: [ + '/vendor', + '/node_modules', + '/modules/images/webp-uploads/fallback.js', // TODO: Issues need to be fixed here. + ], }; module.exports = config; From 0e5b79f660970bfa1d4dffdf2bcdebfb0099bb3f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 6 Nov 2023 19:53:06 -0800 Subject: [PATCH 037/371] Fix storage and improve naming --- .../image-loading-optimization/hooks.php | 4 +- .../image-loading-optimization/rest-api.php | 31 +++++--- .../image-loading-optimization/storage.php | 74 ++++++++++--------- 3 files changed, 61 insertions(+), 48 deletions(-) diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 88dd70635b..43fa5a1686 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -55,7 +55,7 @@ static function ( $output ) { */ function image_loading_optimization_print_detection_script() { - if ( image_loading_optimization_is_metrics_storage_locked() ) { + if ( image_loading_optimization_is_page_metric_storage_locked() ) { return; } @@ -80,7 +80,7 @@ function image_loading_optimization_print_detection_script() { $serve_time, $detection_time_window, WP_DEBUG, - rest_url( '/perflab/v1/image-loading-optimization/metrics-storage' ), + rest_url( IMAGE_LOADING_OPTIMIZATION_REST_API_NAMESPACE . IMAGE_LOADING_OPTIMIZATION_PAGE_METRIC_STORAGE_ROUTE ), wp_create_nonce( 'wp_rest' ), ); wp_print_inline_script_tag( diff --git a/modules/images/image-loading-optimization/rest-api.php b/modules/images/image-loading-optimization/rest-api.php index a347f75d29..6f33a8c17b 100644 --- a/modules/images/image-loading-optimization/rest-api.php +++ b/modules/images/image-loading-optimization/rest-api.php @@ -10,8 +10,11 @@ exit; // Exit if accessed directly. } +define( 'IMAGE_LOADING_OPTIMIZATION_REST_API_NAMESPACE', 'image-loading-optimization/v1' ); +define( 'IMAGE_LOADING_OPTIMIZATION_PAGE_METRIC_STORAGE_ROUTE', '/image-loading-optimization/page-metric-storage' ); + /** - * Register endpoint for storage of metrics. + * Register endpoint for storage of page metric. */ function image_loading_optimization_register_endpoint() { @@ -31,17 +34,17 @@ function image_loading_optimization_register_endpoint() { ); register_rest_route( - 'perflab/v1', - '/image-loading-optimization/metrics-storage', // @todo or rather metric-storage? + IMAGE_LOADING_OPTIMIZATION_REST_API_NAMESPACE, + IMAGE_LOADING_OPTIMIZATION_PAGE_METRIC_STORAGE_ROUTE, array( 'methods' => 'POST', 'callback' => 'image_loading_optimization_handle_rest_request', 'permission_callback' => static function () { // Needs to be available to unauthenticated visitors. - if ( image_loading_optimization_is_metrics_storage_locked() ) { + if ( image_loading_optimization_is_page_metric_storage_locked() ) { return new WP_Error( - 'metrics_storage_locked', - __( 'Metrics storage is presently locked for the current IP.', 'performance-lab' ), + 'page_metric_storage_locked', + __( 'Page metric storage is presently locked for the current IP.', 'performance-lab' ), array( 'status' => 403 ) ); } @@ -130,18 +133,26 @@ function image_loading_optimization_register_endpoint() { * Handle REST API request to store metrics. * * @param WP_REST_Request $request Request. - * @return WP_REST_Response Response. + * @return WP_REST_Response|WP_Error Response. */ function image_loading_optimization_handle_rest_request( WP_REST_Request $request ) { // TODO: We need storage. - image_loading_optimization_set_metrics_storage_lock(); + image_loading_optimization_set_page_metric_storage_lock(); + + $result = image_loading_optimization_store_page_metric( $request->get_json_params() ); + + if ( $result instanceof WP_Error ) { + return $result; + } - return new WP_REST_Response( + $response = new WP_REST_Response( array( 'success' => true, - 'body' => $request->get_json_params(), + 'post_id' => $result, ) ); + $response->set_status( 201 ); + return $response; } diff --git a/modules/images/image-loading-optimization/storage.php b/modules/images/image-loading-optimization/storage.php index c9413609b3..9f430b9dd4 100644 --- a/modules/images/image-loading-optimization/storage.php +++ b/modules/images/image-loading-optimization/storage.php @@ -13,16 +13,16 @@ define( 'IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE', 'ilo_page_metrics' ); /** - * Gets the TTL for the metrics storage lock. + * Gets the TTL for the page metric storage lock. * * @return int TTL. */ -function image_loading_optimization_get_metrics_storage_lock_ttl() { +function image_loading_optimization_get_page_metric_storage_lock_ttl() { /** - * Filters how long a given IP is locked from submitting another metrics-storage REST API request. + * Filters how long a given IP is locked from submitting another metric-storage REST API request. * - * Filtering the TTL to zero will disable any metrics storage locking. This is useful during development. + * Filtering the TTL to zero will disable any metric storage locking. This is useful during development. * * @param int $ttl TTL. */ @@ -46,22 +46,22 @@ function image_loading_optimization_get_max_mobile_viewport_width() { } /** - * Gets transient key for locking metrics storage (for the current IP). + * Gets transient key for locking page metric storage (for the current IP). * * @todo Should the URL be included in the key? Or should a user only be allowed to store one metric? * @return string Transient key. */ -function image_loading_optimization_get_metrics_storage_lock_transient_key() { +function image_loading_optimization_get_page_metric_storage_lock_transient_key() { $ip_address = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR']; return 'page_metrics_storage_lock_' . wp_hash( $ip_address ); } /** - * Sets metrics storage lock (for the current IP). + * Sets page metric storage lock (for the current IP). */ -function image_loading_optimization_set_metrics_storage_lock() { - $ttl = image_loading_optimization_get_metrics_storage_lock_ttl(); - $key = image_loading_optimization_get_metrics_storage_lock_transient_key(); +function image_loading_optimization_set_page_metric_storage_lock() { + $ttl = image_loading_optimization_get_page_metric_storage_lock_ttl(); + $key = image_loading_optimization_get_page_metric_storage_lock_transient_key(); if ( 0 === $ttl ) { delete_transient( $key ); } else { @@ -70,16 +70,16 @@ function image_loading_optimization_set_metrics_storage_lock() { } /** - * Checks whether metrics storage is locked (for the current IP). + * Checks whether page metric storage is locked (for the current IP). * * @return bool Whether locked. */ -function image_loading_optimization_is_metrics_storage_locked() { - $ttl = image_loading_optimization_get_metrics_storage_lock_ttl(); +function image_loading_optimization_is_page_metric_storage_locked() { + $ttl = image_loading_optimization_get_page_metric_storage_lock_ttl(); if ( 0 === $ttl ) { return false; } - $locked_time = (int) get_transient( image_loading_optimization_get_metrics_storage_lock_transient_key() ); + $locked_time = (int) get_transient( image_loading_optimization_get_page_metric_storage_lock_transient_key() ); if ( 0 === $locked_time ) { return false; } @@ -87,7 +87,7 @@ function image_loading_optimization_is_metrics_storage_locked() { } /** - * Register post type for metrics storage. + * Register post type for page metrics storage. * * This the configuration for this post type is similar to the oembed_cache in core. */ @@ -126,7 +126,7 @@ function image_loading_optimization_get_page_metrics_viewport_sample_size() { } /** - * Get slug for page metrics post. + * Gets slug for page metrics post. * * @param string $url URL. * @return string Slug for URL. @@ -165,7 +165,7 @@ function image_loading_optimization_get_page_metrics_post( $url ) { } /** - * Store page metrics. + * Parses post content in page metrics post. * * @param WP_Post $post Page metrics post. * @return array|WP_Error Page metrics when valid, or WP_Error otherwise. @@ -202,14 +202,14 @@ function image_loading_optimization_parse_stored_page_metrics( WP_Post $post ) { * * @return void */ -function image_loading_optimization_segment_stored_page_metrics() { +function image_loading_optimization_segment_stored_page_metrics( array $page_metrics, array $breakpoints ) { } /** - * Store page metrics. + * Stores page metric by merging it with the other page metrics for a given URL. * - * The $validated_page_metrics parameter has the following array shape: + * The $validated_page_metric parameter has the following array shape: * * { * 'url': string, @@ -220,12 +220,14 @@ function image_loading_optimization_segment_stored_page_metrics() { * 'elements': array * } * - * @param array $validated_page_metrics Page metrics, already validated by REST API. - * @return true|WP_Error True on success or WP_Error otherwise. + * @param array $validated_page_metric Page metric, already validated by REST API. + * + * @return int|WP_Error Post ID or WP_Error otherwise. */ -function image_loading_optimization_store_page_metrics( array $validated_page_metrics ) { - $url = $validated_page_metrics['url']; - unset( $validated_page_metrics['url'] ); // Not stored in post_content but rather in post_title/post_name. +function image_loading_optimization_store_page_metric( array $validated_page_metric ) { + $url = $validated_page_metric['url']; + unset( $validated_page_metric['url'] ); // Not stored in post_content but rather in post_title/post_name. + $validated_page_metric['timestamp'] = time(); // TODO: What about storing a version identifier? $post_data = array( @@ -250,18 +252,16 @@ function image_loading_optimization_store_page_metrics( array $validated_page_me $page_metrics = array(); } - // TODO: Unshift the first metrics entry if we are currently at the max allowed. - $segmented_page_metrics = - - $mobile_max_width = image_loading_optimization_get_max_mobile_viewport_width(); + // Add the provided page metric to the page metrics. + // TODO: Need to implement viewport breakpoint segmenting. + // $segmented_page_metrics = + // $mobile_max_width = image_loading_optimization_get_max_mobile_viewport_width(); $viewport_sample_size = image_loading_optimization_get_page_metrics_viewport_sample_size(); + // $viewport_page_metrics = array(); + $page_metrics = array_slice( $page_metrics, 0, $viewport_sample_size - 1 ); // Make room for the additional page metric. + array_unshift( $page_metrics, $validated_page_metric ); - $viewport_page_metrics = array(); - - $existing_storage[] = $validated_page_metrics; - - - $post_data['post_content'] = wp_json_encode( $validated_page_metrics ); + $post_data['post_content'] = wp_json_encode( $page_metrics, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // TODO: No need for pretty-printing. $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); if ( $has_kses ) { @@ -269,6 +269,8 @@ function image_loading_optimization_store_page_metrics( array $validated_page_me kses_remove_filters(); } + $post_data['post_type'] = IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE; + $post_data['post_status'] = 'publish'; if ( isset( $post_data['ID'] ) ) { $result = wp_update_post( wp_slash( $post_data ), true ); } else { @@ -279,5 +281,5 @@ function image_loading_optimization_store_page_metrics( array $validated_page_me kses_init_filters(); } - return $result instanceof WP_Error ? $result : true; + return $result; } From 739a53e55f461cada695d4a24e9dccfc95f6567b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 6 Nov 2023 20:01:40 -0800 Subject: [PATCH 038/371] Use ILO consistently as the prefix --- .../image-loading-optimization/hooks.php | 12 ++-- .../image-loading-optimization/load.php | 6 +- .../image-loading-optimization/rest-api.php | 22 +++---- .../image-loading-optimization/storage.php | 64 +++++++++---------- 4 files changed, 52 insertions(+), 52 deletions(-) diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 43fa5a1686..718cfe8853 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -31,7 +31,7 @@ * @param mixed $passthrough Optional. Filter value. Default null. * @return mixed Unmodified value of $passthrough. */ -function image_loading_optimization_buffer_output( $passthrough = null ) { +function ilo_buffer_output( $passthrough = null ) { ob_start( static function ( $output ) { /** @@ -45,7 +45,7 @@ static function ( $output ) { ); return $passthrough; } -add_filter( 'template_include', 'image_loading_optimization_buffer_output', PHP_INT_MAX ); +add_filter( 'template_include', 'ilo_buffer_output', PHP_INT_MAX ); /** * Prints the script for detecting loaded images and the LCP element. @@ -53,9 +53,9 @@ static function ( $output ) { * @todo This should eventually only print the script if metrics are needed. * @todo This script should not be printed if the page was requested with non-removal (non-canonical) query args. */ -function image_loading_optimization_print_detection_script() { +function ilo_print_detection_script() { - if ( image_loading_optimization_is_page_metric_storage_locked() ) { + if ( ilo_is_page_metric_storage_locked() ) { return; } @@ -80,7 +80,7 @@ function image_loading_optimization_print_detection_script() { $serve_time, $detection_time_window, WP_DEBUG, - rest_url( IMAGE_LOADING_OPTIMIZATION_REST_API_NAMESPACE . IMAGE_LOADING_OPTIMIZATION_PAGE_METRIC_STORAGE_ROUTE ), + rest_url( ILO_REST_API_NAMESPACE . ILO_PAGE_METRIC_STORAGE_ROUTE ), wp_create_nonce( 'wp_rest' ), ); wp_print_inline_script_tag( @@ -92,4 +92,4 @@ function image_loading_optimization_print_detection_script() { array( 'type' => 'module' ) ); } -add_action( 'wp_print_footer_scripts', 'image_loading_optimization_print_detection_script' ); +add_action( 'wp_print_footer_scripts', 'ilo_print_detection_script' ); diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index 0828ab47bb..2aaace8afa 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -9,14 +9,14 @@ */ // Define the constant. -if ( defined( 'IMAGE_LOADING_OPTIMIZATION_VERSION' ) ) { +if ( defined( 'ILO_VERSION' ) ) { return; } -define( 'IMAGE_LOADING_OPTIMIZATION_VERSION', 'Performance Lab ' . PERFLAB_VERSION ); +define( 'ILO_VERSION', 'Performance Lab ' . PERFLAB_VERSION ); // Do not load the code if it is already loaded through another means. -if ( function_exists( 'image_loading_optimization_buffer_output' ) ) { +if ( function_exists( 'ilo_buffer_output' ) ) { return; } diff --git a/modules/images/image-loading-optimization/rest-api.php b/modules/images/image-loading-optimization/rest-api.php index 6f33a8c17b..672f5cf9e8 100644 --- a/modules/images/image-loading-optimization/rest-api.php +++ b/modules/images/image-loading-optimization/rest-api.php @@ -10,13 +10,13 @@ exit; // Exit if accessed directly. } -define( 'IMAGE_LOADING_OPTIMIZATION_REST_API_NAMESPACE', 'image-loading-optimization/v1' ); -define( 'IMAGE_LOADING_OPTIMIZATION_PAGE_METRIC_STORAGE_ROUTE', '/image-loading-optimization/page-metric-storage' ); +define( 'ILO_REST_API_NAMESPACE', 'image-loading-optimization/v1' ); +define( 'ILO_PAGE_METRIC_STORAGE_ROUTE', '/image-loading-optimization/page-metric-storage' ); /** * Register endpoint for storage of page metric. */ -function image_loading_optimization_register_endpoint() { +function ilo_register_endpoint() { $dom_rect_schema = array( 'type' => 'object', @@ -34,14 +34,14 @@ function image_loading_optimization_register_endpoint() { ); register_rest_route( - IMAGE_LOADING_OPTIMIZATION_REST_API_NAMESPACE, - IMAGE_LOADING_OPTIMIZATION_PAGE_METRIC_STORAGE_ROUTE, + ILO_REST_API_NAMESPACE, + ILO_PAGE_METRIC_STORAGE_ROUTE, array( 'methods' => 'POST', - 'callback' => 'image_loading_optimization_handle_rest_request', + 'callback' => 'ilo_handle_rest_request', 'permission_callback' => static function () { // Needs to be available to unauthenticated visitors. - if ( image_loading_optimization_is_page_metric_storage_locked() ) { + if ( ilo_is_page_metric_storage_locked() ) { return new WP_Error( 'page_metric_storage_locked', __( 'Page metric storage is presently locked for the current IP.', 'performance-lab' ), @@ -127,7 +127,7 @@ function image_loading_optimization_register_endpoint() { ) ); } -add_action( 'rest_api_init', 'image_loading_optimization_register_endpoint' ); +add_action( 'rest_api_init', 'ilo_register_endpoint' ); /** * Handle REST API request to store metrics. @@ -135,13 +135,13 @@ function image_loading_optimization_register_endpoint() { * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error Response. */ -function image_loading_optimization_handle_rest_request( WP_REST_Request $request ) { +function ilo_handle_rest_request( WP_REST_Request $request ) { // TODO: We need storage. - image_loading_optimization_set_page_metric_storage_lock(); + ilo_set_page_metric_storage_lock(); - $result = image_loading_optimization_store_page_metric( $request->get_json_params() ); + $result = ilo_store_page_metric( $request->get_json_params() ); if ( $result instanceof WP_Error ) { return $result; diff --git a/modules/images/image-loading-optimization/storage.php b/modules/images/image-loading-optimization/storage.php index 9f430b9dd4..8e92fc2e01 100644 --- a/modules/images/image-loading-optimization/storage.php +++ b/modules/images/image-loading-optimization/storage.php @@ -10,14 +10,14 @@ exit; // Exit if accessed directly. } -define( 'IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE', 'ilo_page_metrics' ); +define( 'ILO_PAGE_METRICS_POST_TYPE', 'ilo_page_metrics' ); /** * Gets the TTL for the page metric storage lock. * * @return int TTL. */ -function image_loading_optimization_get_page_metric_storage_lock_ttl() { +function ilo_get_page_metric_storage_lock_ttl() { /** * Filters how long a given IP is locked from submitting another metric-storage REST API request. @@ -26,7 +26,7 @@ function image_loading_optimization_get_page_metric_storage_lock_ttl() { * * @param int $ttl TTL. */ - return (int) apply_filters( 'perflab_image_loading_optimization_metrics_storage_lock_ttl', MINUTE_IN_SECONDS ); + return (int) apply_filters( 'ilo_metrics_storage_lock_ttl', MINUTE_IN_SECONDS ); } /** @@ -35,14 +35,14 @@ function image_loading_optimization_get_page_metric_storage_lock_ttl() { * @todo This could instead return an array of thresholds, like [ 320, 480, 576 ] which would add additional buckets for small smartphones and phablets in addition to normal smartphones and desktops. * @return int Viewport width. */ -function image_loading_optimization_get_max_mobile_viewport_width() { +function ilo_get_max_mobile_viewport_width() { /** * Filters the maximum width for a viewport to be considered as a mobile device. * * @param int $mobile_max_width Mobile max width. */ - return (int) apply_filters( 'perflab_image_loading_optimization_max_mobile_viewport_with', 480 ); + return (int) apply_filters( 'ilo_max_mobile_viewport_with', 480 ); } /** @@ -51,7 +51,7 @@ function image_loading_optimization_get_max_mobile_viewport_width() { * @todo Should the URL be included in the key? Or should a user only be allowed to store one metric? * @return string Transient key. */ -function image_loading_optimization_get_page_metric_storage_lock_transient_key() { +function ilo_get_page_metric_storage_lock_transient_key() { $ip_address = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR']; return 'page_metrics_storage_lock_' . wp_hash( $ip_address ); } @@ -59,9 +59,9 @@ function image_loading_optimization_get_page_metric_storage_lock_transient_key() /** * Sets page metric storage lock (for the current IP). */ -function image_loading_optimization_set_page_metric_storage_lock() { - $ttl = image_loading_optimization_get_page_metric_storage_lock_ttl(); - $key = image_loading_optimization_get_page_metric_storage_lock_transient_key(); +function ilo_set_page_metric_storage_lock() { + $ttl = ilo_get_page_metric_storage_lock_ttl(); + $key = ilo_get_page_metric_storage_lock_transient_key(); if ( 0 === $ttl ) { delete_transient( $key ); } else { @@ -74,12 +74,12 @@ function image_loading_optimization_set_page_metric_storage_lock() { * * @return bool Whether locked. */ -function image_loading_optimization_is_page_metric_storage_locked() { - $ttl = image_loading_optimization_get_page_metric_storage_lock_ttl(); +function ilo_is_page_metric_storage_locked() { + $ttl = ilo_get_page_metric_storage_lock_ttl(); if ( 0 === $ttl ) { return false; } - $locked_time = (int) get_transient( image_loading_optimization_get_page_metric_storage_lock_transient_key() ); + $locked_time = (int) get_transient( ilo_get_page_metric_storage_lock_transient_key() ); if ( 0 === $locked_time ) { return false; } @@ -91,9 +91,9 @@ function image_loading_optimization_is_page_metric_storage_locked() { * * This the configuration for this post type is similar to the oembed_cache in core. */ -function image_loading_optimization_register_page_metrics_post_type() { +function ilo_register_page_metrics_post_type() { register_post_type( - IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE, + ILO_PAGE_METRICS_POST_TYPE, array( 'labels' => array( 'name' => __( 'Page Metrics', 'performance-lab' ), @@ -109,20 +109,20 @@ function image_loading_optimization_register_page_metrics_post_type() { ) ); } -add_action( 'init', 'image_loading_optimization_register_page_metrics_post_type' ); +add_action( 'init', 'ilo_register_page_metrics_post_type' ); /** * Gets desired sample size for a viewport's page metrics. * * @return int */ -function image_loading_optimization_get_page_metrics_viewport_sample_size() { +function ilo_get_page_metrics_viewport_sample_size() { /** * Filters desired sample size for a viewport's page metrics. * * @param int $sample_size Sample size. */ - return (int) apply_filters( 'perflab_image_loading_optimization_page_metrics_viewport_sample_size', 10 ); + return (int) apply_filters( 'ilo_page_metrics_viewport_sample_size', 10 ); } /** @@ -131,7 +131,7 @@ function image_loading_optimization_get_page_metrics_viewport_sample_size() { * @param string $url URL. * @return string Slug for URL. */ -function image_loading_optimization_get_page_metrics_slug( $url ) { +function ilo_get_page_metrics_slug( $url ) { return md5( $url ); } @@ -141,12 +141,12 @@ function image_loading_optimization_get_page_metrics_slug( $url ) { * @param string $url URL. * @return WP_Post|null Post object if exists. */ -function image_loading_optimization_get_page_metrics_post( $url ) { +function ilo_get_page_metrics_post( $url ) { $post_query = new WP_Query( array( - 'post_type' => IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE, + 'post_type' => ILO_PAGE_METRICS_POST_TYPE, 'post_status' => 'publish', - 'name' => image_loading_optimization_get_page_metrics_slug( $url ), + 'name' => ilo_get_page_metrics_slug( $url ), 'posts_per_page' => 1, 'no_found_rows' => true, 'cache_results' => true, @@ -170,7 +170,7 @@ function image_loading_optimization_get_page_metrics_post( $url ) { * @param WP_Post $post Page metrics post. * @return array|WP_Error Page metrics when valid, or WP_Error otherwise. */ -function image_loading_optimization_parse_stored_page_metrics( WP_Post $post ) { +function ilo_parse_stored_page_metrics( WP_Post $post ) { $page_metrics = json_decode( $post->post_content, true ); if ( json_last_error() ) { return new WP_Error( @@ -178,7 +178,7 @@ function image_loading_optimization_parse_stored_page_metrics( WP_Post $post ) { sprintf( /* translators: 1: Post type slug, 2: JSON error message */ __( 'Contents of %1$s post type not valid JSON: %2$s', 'performance-lab' ), - IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE, + ILO_PAGE_METRICS_POST_TYPE, json_last_error_msg() ) ); @@ -189,7 +189,7 @@ function image_loading_optimization_parse_stored_page_metrics( WP_Post $post ) { sprintf( /* translators: %s is post type slug */ __( 'Contents of %s post type was not a JSON array.', 'performance-lab' ), - IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE + ILO_PAGE_METRICS_POST_TYPE ) ); } @@ -202,7 +202,7 @@ function image_loading_optimization_parse_stored_page_metrics( WP_Post $post ) { * * @return void */ -function image_loading_optimization_segment_stored_page_metrics( array $page_metrics, array $breakpoints ) { +function ilo_segment_stored_page_metrics( array $page_metrics, array $breakpoints ) { } @@ -224,7 +224,7 @@ function image_loading_optimization_segment_stored_page_metrics( array $page_met * * @return int|WP_Error Post ID or WP_Error otherwise. */ -function image_loading_optimization_store_page_metric( array $validated_page_metric ) { +function ilo_store_page_metric( array $validated_page_metric ) { $url = $validated_page_metric['url']; unset( $validated_page_metric['url'] ); // Not stored in post_content but rather in post_title/post_name. $validated_page_metric['timestamp'] = time(); @@ -234,13 +234,13 @@ function image_loading_optimization_store_page_metric( array $validated_page_met 'post_title' => $url, ); - $post = image_loading_optimization_get_page_metrics_post( $url ); + $post = ilo_get_page_metrics_post( $url ); if ( $post instanceof WP_Post ) { $post_data['ID'] = $post->ID; $post_data['post_name'] = $post->post_name; - $page_metrics = image_loading_optimization_parse_stored_page_metrics( $post ); + $page_metrics = ilo_parse_stored_page_metrics( $post ); if ( $page_metrics instanceof WP_Error ) { if ( function_exists( 'wp_trigger_error' ) ) { wp_trigger_error( __FUNCTION__, esc_html( $page_metrics->get_error_message() ) ); @@ -248,15 +248,15 @@ function image_loading_optimization_store_page_metric( array $validated_page_met $page_metrics = array(); } } else { - $post_data['post_name'] = image_loading_optimization_get_page_metrics_slug( $url ); + $post_data['post_name'] = ilo_get_page_metrics_slug( $url ); $page_metrics = array(); } // Add the provided page metric to the page metrics. // TODO: Need to implement viewport breakpoint segmenting. // $segmented_page_metrics = - // $mobile_max_width = image_loading_optimization_get_max_mobile_viewport_width(); - $viewport_sample_size = image_loading_optimization_get_page_metrics_viewport_sample_size(); + // $mobile_max_width = ilo_get_max_mobile_viewport_width(); + $viewport_sample_size = ilo_get_page_metrics_viewport_sample_size(); // $viewport_page_metrics = array(); $page_metrics = array_slice( $page_metrics, 0, $viewport_sample_size - 1 ); // Make room for the additional page metric. array_unshift( $page_metrics, $validated_page_metric ); @@ -269,7 +269,7 @@ function image_loading_optimization_store_page_metric( array $validated_page_met kses_remove_filters(); } - $post_data['post_type'] = IMAGE_LOADING_OPTIMIZATION_PAGE_METRICS_POST_TYPE; + $post_data['post_type'] = ILO_PAGE_METRICS_POST_TYPE; $post_data['post_status'] = 'publish'; if ( isset( $post_data['ID'] ) ) { $result = wp_update_post( wp_slash( $post_data ), true ); From e4d1578188f2b2b11f0ea1dc8d2d9868eaf78eae Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 6 Nov 2023 21:04:57 -0800 Subject: [PATCH 039/371] Segment page metrics by breakpoint and constrain sample size --- .../image-loading-optimization/storage.php | 78 ++++++++++++++----- 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/modules/images/image-loading-optimization/storage.php b/modules/images/image-loading-optimization/storage.php index 8e92fc2e01..fd49729f28 100644 --- a/modules/images/image-loading-optimization/storage.php +++ b/modules/images/image-loading-optimization/storage.php @@ -30,19 +30,35 @@ function ilo_get_page_metric_storage_lock_ttl() { } /** - * Gets the maximum width for a viewport to be considered as a mobile device. + * Gets the breakpoint max widths to group page metrics for various viewports. * - * @todo This could instead return an array of thresholds, like [ 320, 480, 576 ] which would add additional buckets for small smartphones and phablets in addition to normal smartphones and desktops. - * @return int Viewport width. + * Each max with represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then + * this means there will be two viewport groupings, one for 0<=480, and another >480. If instead there were three + * provided breakpoints (320, 480, 576) then this means there will be four viewport groupings: + * + * 1. 0-320 (small smartphone) + * 2. 321-480 (normal smartphone) + * 3. 481-576 (phablets) + * 4. >576 (desktop) + * + * @return int[] Breakpoint max widths, sorted in ascending order. */ -function ilo_get_max_mobile_viewport_width() { +function ilo_get_breakpoint_max_widths() { /** - * Filters the maximum width for a viewport to be considered as a mobile device. + * Filters the breakpoint max widths to group page metrics for various viewports. * - * @param int $mobile_max_width Mobile max width. + * @param int[] $breakpoint_max_widths Max widths for viewport breakpoints. */ - return (int) apply_filters( 'ilo_max_mobile_viewport_with', 480 ); + $breakpoint_max_widths = array_map( + static function ( $breakpoint_max_width ) { + return (int) $breakpoint_max_width; + }, + (array) apply_filters( 'ilo_viewport_breakpoint_max_widths', array( 480 ) ) + ); + + sort( $breakpoint_max_widths ); + return $breakpoint_max_widths; } /** @@ -116,7 +132,7 @@ function ilo_register_page_metrics_post_type() { * * @return int */ -function ilo_get_page_metrics_viewport_sample_size() { +function ilo_get_page_metrics_breakpoint_sample_size() { /** * Filters desired sample size for a viewport's page metrics. * @@ -197,13 +213,31 @@ function ilo_parse_stored_page_metrics( WP_Post $post ) { } /** + * Groups page metrics by breakpoint. * - * @todo This needs to take a set of page metrics and segment the individual metrics into breakpoints. - * - * @return void + * @param array $page_metrics Page metrics. + * @param int[] $breakpoints Viewport breakpoint max widths, sorted in ascending order. + * @return array Grouped page metrics. */ -function ilo_segment_stored_page_metrics( array $page_metrics, array $breakpoints ) { - +function ilo_group_page_metrics_by_breakpoint( array $page_metrics, array $breakpoints ) { + $max_index = count( $breakpoints ); + $groups = array_fill( 0, $max_index + 1, array() ); + $largest_breakpoint = $breakpoints[ $max_index - 1 ]; + foreach ( $page_metrics as $page_metric ) { + if ( ! isset( $page_metric['viewport']['width'] ) ) { + continue; + } + $viewport_width = $page_metric['viewport']['width']; + if ( $viewport_width > $largest_breakpoint ) { + $groups[ $max_index ][] = $page_metric; + } + foreach ( $breakpoints as $group => $breakpoint ) { + if ( $viewport_width <= $breakpoint ) { + $groups[ $group ][] = $page_metric; + } + } + } + return $groups; } /** @@ -253,14 +287,20 @@ function ilo_store_page_metric( array $validated_page_metric ) { } // Add the provided page metric to the page metrics. - // TODO: Need to implement viewport breakpoint segmenting. - // $segmented_page_metrics = - // $mobile_max_width = ilo_get_max_mobile_viewport_width(); - $viewport_sample_size = ilo_get_page_metrics_viewport_sample_size(); - // $viewport_page_metrics = array(); - $page_metrics = array_slice( $page_metrics, 0, $viewport_sample_size - 1 ); // Make room for the additional page metric. array_unshift( $page_metrics, $validated_page_metric ); + $breakpoints = ilo_get_breakpoint_max_widths(); + $sample_size = ilo_get_page_metrics_breakpoint_sample_size(); + $grouped_page_metrics = ilo_group_page_metrics_by_breakpoint( $page_metrics, $breakpoints ); + + foreach ( $grouped_page_metrics as &$breakpoint_page_metrics ) { + if ( count( $breakpoint_page_metrics ) > $sample_size ) { + $breakpoint_page_metrics = array_slice( $breakpoint_page_metrics, 0, $sample_size ); + } + } + + $page_metrics = array_merge( ...$grouped_page_metrics ); + // TODO: Also need to capture the current theme and template which can be used to invalidate the cached page metrics. $post_data['post_content'] = wp_json_encode( $page_metrics, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // TODO: No need for pretty-printing. $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); From 2b1a15b519d5e62883c5161a7d8c72b1fb9828e6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 7 Nov 2023 11:26:37 -0800 Subject: [PATCH 040/371] Improve function ordering --- .../image-loading-optimization/detect.js | 5 +- .../image-loading-optimization/rest-api.php | 3 - .../image-loading-optimization/storage.php | 62 +++++++++---------- 3 files changed, 33 insertions(+), 37 deletions(-) diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detect.js index 1bc18b8db2..5971637f13 100644 --- a/modules/images/image-loading-optimization/detect.js +++ b/modules/images/image-loading-optimization/detect.js @@ -289,6 +289,8 @@ export default async function detect( pageMetrics.elements.push( elementMetrics ); } + log( pageMetrics ); + // TODO: Wait until idle. const response = await fetch( restApiEndpoint, { method: 'POST', @@ -300,9 +302,6 @@ export default async function detect( } ); log( 'response:', await response.json() ); - // TODO: Send data to server. - log( pageMetrics ); - // Clean up. breadcrumbedElementsMap.clear(); } diff --git a/modules/images/image-loading-optimization/rest-api.php b/modules/images/image-loading-optimization/rest-api.php index 672f5cf9e8..74967a0331 100644 --- a/modules/images/image-loading-optimization/rest-api.php +++ b/modules/images/image-loading-optimization/rest-api.php @@ -136,9 +136,6 @@ function ilo_register_endpoint() { * @return WP_REST_Response|WP_Error Response. */ function ilo_handle_rest_request( WP_REST_Request $request ) { - - // TODO: We need storage. - ilo_set_page_metric_storage_lock(); $result = ilo_store_page_metric( $request->get_json_params() ); diff --git a/modules/images/image-loading-optimization/storage.php b/modules/images/image-loading-optimization/storage.php index fd49729f28..fd7ea9fd88 100644 --- a/modules/images/image-loading-optimization/storage.php +++ b/modules/images/image-loading-optimization/storage.php @@ -12,23 +12,6 @@ define( 'ILO_PAGE_METRICS_POST_TYPE', 'ilo_page_metrics' ); -/** - * Gets the TTL for the page metric storage lock. - * - * @return int TTL. - */ -function ilo_get_page_metric_storage_lock_ttl() { - - /** - * Filters how long a given IP is locked from submitting another metric-storage REST API request. - * - * Filtering the TTL to zero will disable any metric storage locking. This is useful during development. - * - * @param int $ttl TTL. - */ - return (int) apply_filters( 'ilo_metrics_storage_lock_ttl', MINUTE_IN_SECONDS ); -} - /** * Gets the breakpoint max widths to group page metrics for various viewports. * @@ -61,6 +44,37 @@ static function ( $breakpoint_max_width ) { return $breakpoint_max_widths; } +/** + * Gets desired sample size for a viewport's page metrics. + * + * @return int Sample size. + */ +function ilo_get_page_metrics_breakpoint_sample_size() { + /** + * Filters desired sample size for a viewport's page metrics. + * + * @param int $sample_size Sample size. + */ + return (int) apply_filters( 'ilo_page_metrics_viewport_sample_size', 10 ); +} + +/** + * Gets the TTL for the page metric storage lock. + * + * @return int TTL. + */ +function ilo_get_page_metric_storage_lock_ttl() { + + /** + * Filters how long a given IP is locked from submitting another metric-storage REST API request. + * + * Filtering the TTL to zero will disable any metric storage locking. This is useful during development. + * + * @param int $ttl TTL. + */ + return (int) apply_filters( 'ilo_metrics_storage_lock_ttl', MINUTE_IN_SECONDS ); +} + /** * Gets transient key for locking page metric storage (for the current IP). * @@ -127,20 +141,6 @@ function ilo_register_page_metrics_post_type() { } add_action( 'init', 'ilo_register_page_metrics_post_type' ); -/** - * Gets desired sample size for a viewport's page metrics. - * - * @return int - */ -function ilo_get_page_metrics_breakpoint_sample_size() { - /** - * Filters desired sample size for a viewport's page metrics. - * - * @param int $sample_size Sample size. - */ - return (int) apply_filters( 'ilo_page_metrics_viewport_sample_size', 10 ); -} - /** * Gets slug for page metrics post. * From f516af85f463f36cf009a76ab8075475599a268a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 7 Nov 2023 20:03:46 -0800 Subject: [PATCH 041/371] Add ilo_get_page_metric_ttl --- .../image-loading-optimization/hooks.php | 1 + .../image-loading-optimization/storage.php | 25 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 718cfe8853..59b98e61a7 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -55,6 +55,7 @@ static function ( $output ) { */ function ilo_print_detection_script() { + // TODO: Also abort if we don't need any new page metrics due to the sample size being full. if ( ilo_is_page_metric_storage_locked() ) { return; } diff --git a/modules/images/image-loading-optimization/storage.php b/modules/images/image-loading-optimization/storage.php index fd7ea9fd88..606200553a 100644 --- a/modules/images/image-loading-optimization/storage.php +++ b/modules/images/image-loading-optimization/storage.php @@ -37,7 +37,7 @@ function ilo_get_breakpoint_max_widths() { static function ( $breakpoint_max_width ) { return (int) $breakpoint_max_width; }, - (array) apply_filters( 'ilo_viewport_breakpoint_max_widths', array( 480 ) ) + (array) apply_filters( 'ilo_breakpoint_max_widths', array( 480 ) ) ); sort( $breakpoint_max_widths ); @@ -45,7 +45,7 @@ static function ( $breakpoint_max_width ) { } /** - * Gets desired sample size for a viewport's page metrics. + * Gets desired sample size for a breakpoint's page metrics. * * @return int Sample size. */ @@ -55,7 +55,25 @@ function ilo_get_page_metrics_breakpoint_sample_size() { * * @param int $sample_size Sample size. */ - return (int) apply_filters( 'ilo_page_metrics_viewport_sample_size', 10 ); + return (int) apply_filters( 'ilo_page_metrics_breakpoint_sample_size', 10 ); +} + +/** + * Gets the expiration age for a given page metric. + * + * When a page metric expires it is eligible to be replaced by a newer one. + * + * TODO: However, we keep viewport-specific page metrics regardless of TTL. + * + * @return int Expiration age in seconds. + */ +function ilo_get_page_metric_ttl() { + /** + * Filters the expiration age for a given page metric. + * + * @param int $ttl TTL. + */ + return (int) apply_filters( 'ilo_page_metric_ttl', MONTH_IN_SECONDS ); } /** @@ -300,7 +318,6 @@ function ilo_store_page_metric( array $validated_page_metric ) { $page_metrics = array_merge( ...$grouped_page_metrics ); - // TODO: Also need to capture the current theme and template which can be used to invalidate the cached page metrics. $post_data['post_content'] = wp_json_encode( $page_metrics, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // TODO: No need for pretty-printing. $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); From 2f804613a2dbdfd6a23746bf4d770b5d675de24e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 7 Nov 2023 20:08:12 -0800 Subject: [PATCH 042/371] Move lock functions into separate file --- .../image-loading-optimization/storage.php | 60 +--------------- .../storage/lock.php | 69 +++++++++++++++++++ 2 files changed, 71 insertions(+), 58 deletions(-) create mode 100644 modules/images/image-loading-optimization/storage/lock.php diff --git a/modules/images/image-loading-optimization/storage.php b/modules/images/image-loading-optimization/storage.php index 606200553a..b5d1d37be8 100644 --- a/modules/images/image-loading-optimization/storage.php +++ b/modules/images/image-loading-optimization/storage.php @@ -10,6 +10,8 @@ exit; // Exit if accessed directly. } +require_once __DIR__ . '/storage/lock.php'; + define( 'ILO_PAGE_METRICS_POST_TYPE', 'ilo_page_metrics' ); /** @@ -76,64 +78,6 @@ function ilo_get_page_metric_ttl() { return (int) apply_filters( 'ilo_page_metric_ttl', MONTH_IN_SECONDS ); } -/** - * Gets the TTL for the page metric storage lock. - * - * @return int TTL. - */ -function ilo_get_page_metric_storage_lock_ttl() { - - /** - * Filters how long a given IP is locked from submitting another metric-storage REST API request. - * - * Filtering the TTL to zero will disable any metric storage locking. This is useful during development. - * - * @param int $ttl TTL. - */ - return (int) apply_filters( 'ilo_metrics_storage_lock_ttl', MINUTE_IN_SECONDS ); -} - -/** - * Gets transient key for locking page metric storage (for the current IP). - * - * @todo Should the URL be included in the key? Or should a user only be allowed to store one metric? - * @return string Transient key. - */ -function ilo_get_page_metric_storage_lock_transient_key() { - $ip_address = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR']; - return 'page_metrics_storage_lock_' . wp_hash( $ip_address ); -} - -/** - * Sets page metric storage lock (for the current IP). - */ -function ilo_set_page_metric_storage_lock() { - $ttl = ilo_get_page_metric_storage_lock_ttl(); - $key = ilo_get_page_metric_storage_lock_transient_key(); - if ( 0 === $ttl ) { - delete_transient( $key ); - } else { - set_transient( $key, time(), $ttl ); - } -} - -/** - * Checks whether page metric storage is locked (for the current IP). - * - * @return bool Whether locked. - */ -function ilo_is_page_metric_storage_locked() { - $ttl = ilo_get_page_metric_storage_lock_ttl(); - if ( 0 === $ttl ) { - return false; - } - $locked_time = (int) get_transient( ilo_get_page_metric_storage_lock_transient_key() ); - if ( 0 === $locked_time ) { - return false; - } - return time() - $locked_time < $ttl; -} - /** * Register post type for page metrics storage. * diff --git a/modules/images/image-loading-optimization/storage/lock.php b/modules/images/image-loading-optimization/storage/lock.php new file mode 100644 index 0000000000..efbb89424c --- /dev/null +++ b/modules/images/image-loading-optimization/storage/lock.php @@ -0,0 +1,69 @@ + Date: Tue, 7 Nov 2023 20:22:11 -0800 Subject: [PATCH 043/371] Reorganize functions into files --- .../image-loading-optimization/load.php | 1 - .../image-loading-optimization/storage.php | 276 +----------------- .../storage/data.php | 125 ++++++++ .../storage/lock.php | 2 +- .../storage/post-type.php | 180 ++++++++++++ .../{ => storage}/rest-api.php | 3 +- 6 files changed, 311 insertions(+), 276 deletions(-) create mode 100644 modules/images/image-loading-optimization/storage/data.php create mode 100644 modules/images/image-loading-optimization/storage/post-type.php rename modules/images/image-loading-optimization/{ => storage}/rest-api.php (97%) diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index 2aaace8afa..68f6287195 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -23,4 +23,3 @@ require_once __DIR__ . '/helper.php'; require_once __DIR__ . '/hooks.php'; require_once __DIR__ . '/storage.php'; -require_once __DIR__ . '/rest-api.php'; diff --git a/modules/images/image-loading-optimization/storage.php b/modules/images/image-loading-optimization/storage.php index b5d1d37be8..5ac9fb9220 100644 --- a/modules/images/image-loading-optimization/storage.php +++ b/modules/images/image-loading-optimization/storage.php @@ -11,276 +11,6 @@ } require_once __DIR__ . '/storage/lock.php'; - -define( 'ILO_PAGE_METRICS_POST_TYPE', 'ilo_page_metrics' ); - -/** - * Gets the breakpoint max widths to group page metrics for various viewports. - * - * Each max with represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then - * this means there will be two viewport groupings, one for 0<=480, and another >480. If instead there were three - * provided breakpoints (320, 480, 576) then this means there will be four viewport groupings: - * - * 1. 0-320 (small smartphone) - * 2. 321-480 (normal smartphone) - * 3. 481-576 (phablets) - * 4. >576 (desktop) - * - * @return int[] Breakpoint max widths, sorted in ascending order. - */ -function ilo_get_breakpoint_max_widths() { - - /** - * Filters the breakpoint max widths to group page metrics for various viewports. - * - * @param int[] $breakpoint_max_widths Max widths for viewport breakpoints. - */ - $breakpoint_max_widths = array_map( - static function ( $breakpoint_max_width ) { - return (int) $breakpoint_max_width; - }, - (array) apply_filters( 'ilo_breakpoint_max_widths', array( 480 ) ) - ); - - sort( $breakpoint_max_widths ); - return $breakpoint_max_widths; -} - -/** - * Gets desired sample size for a breakpoint's page metrics. - * - * @return int Sample size. - */ -function ilo_get_page_metrics_breakpoint_sample_size() { - /** - * Filters desired sample size for a viewport's page metrics. - * - * @param int $sample_size Sample size. - */ - return (int) apply_filters( 'ilo_page_metrics_breakpoint_sample_size', 10 ); -} - -/** - * Gets the expiration age for a given page metric. - * - * When a page metric expires it is eligible to be replaced by a newer one. - * - * TODO: However, we keep viewport-specific page metrics regardless of TTL. - * - * @return int Expiration age in seconds. - */ -function ilo_get_page_metric_ttl() { - /** - * Filters the expiration age for a given page metric. - * - * @param int $ttl TTL. - */ - return (int) apply_filters( 'ilo_page_metric_ttl', MONTH_IN_SECONDS ); -} - -/** - * Register post type for page metrics storage. - * - * This the configuration for this post type is similar to the oembed_cache in core. - */ -function ilo_register_page_metrics_post_type() { - register_post_type( - ILO_PAGE_METRICS_POST_TYPE, - array( - 'labels' => array( - 'name' => __( 'Page Metrics', 'performance-lab' ), - 'singular_name' => __( 'Page Metrics', 'performance-lab' ), - ), - 'public' => false, - 'hierarchical' => false, - 'rewrite' => false, - 'query_var' => false, - 'delete_with_user' => false, - 'can_export' => false, - 'supports' => array( 'title' ), // The original URL is stored in the post_title, and the MD5 hash in the post_name. - ) - ); -} -add_action( 'init', 'ilo_register_page_metrics_post_type' ); - -/** - * Gets slug for page metrics post. - * - * @param string $url URL. - * @return string Slug for URL. - */ -function ilo_get_page_metrics_slug( $url ) { - return md5( $url ); -} - -/** - * Get page metrics post. - * - * @param string $url URL. - * @return WP_Post|null Post object if exists. - */ -function ilo_get_page_metrics_post( $url ) { - $post_query = new WP_Query( - array( - 'post_type' => ILO_PAGE_METRICS_POST_TYPE, - 'post_status' => 'publish', - 'name' => ilo_get_page_metrics_slug( $url ), - 'posts_per_page' => 1, - 'no_found_rows' => true, - 'cache_results' => true, - 'update_post_meta_cache' => false, - 'update_post_term_cache' => false, - 'lazy_load_term_meta' => false, - ) - ); - - $post = array_shift( $post_query->posts ); - if ( $post instanceof WP_Post ) { - return $post; - } else { - return null; - } -} - -/** - * Parses post content in page metrics post. - * - * @param WP_Post $post Page metrics post. - * @return array|WP_Error Page metrics when valid, or WP_Error otherwise. - */ -function ilo_parse_stored_page_metrics( WP_Post $post ) { - $page_metrics = json_decode( $post->post_content, true ); - if ( json_last_error() ) { - return new WP_Error( - 'page_metrics_json_parse_error', - sprintf( - /* translators: 1: Post type slug, 2: JSON error message */ - __( 'Contents of %1$s post type not valid JSON: %2$s', 'performance-lab' ), - ILO_PAGE_METRICS_POST_TYPE, - json_last_error_msg() - ) - ); - } - if ( ! is_array( $page_metrics ) ) { - return new WP_Error( - 'page_metrics_invalid_data_format', - sprintf( - /* translators: %s is post type slug */ - __( 'Contents of %s post type was not a JSON array.', 'performance-lab' ), - ILO_PAGE_METRICS_POST_TYPE - ) - ); - } - return $page_metrics; -} - -/** - * Groups page metrics by breakpoint. - * - * @param array $page_metrics Page metrics. - * @param int[] $breakpoints Viewport breakpoint max widths, sorted in ascending order. - * @return array Grouped page metrics. - */ -function ilo_group_page_metrics_by_breakpoint( array $page_metrics, array $breakpoints ) { - $max_index = count( $breakpoints ); - $groups = array_fill( 0, $max_index + 1, array() ); - $largest_breakpoint = $breakpoints[ $max_index - 1 ]; - foreach ( $page_metrics as $page_metric ) { - if ( ! isset( $page_metric['viewport']['width'] ) ) { - continue; - } - $viewport_width = $page_metric['viewport']['width']; - if ( $viewport_width > $largest_breakpoint ) { - $groups[ $max_index ][] = $page_metric; - } - foreach ( $breakpoints as $group => $breakpoint ) { - if ( $viewport_width <= $breakpoint ) { - $groups[ $group ][] = $page_metric; - } - } - } - return $groups; -} - -/** - * Stores page metric by merging it with the other page metrics for a given URL. - * - * The $validated_page_metric parameter has the following array shape: - * - * { - * 'url': string, - * 'viewport': array{ - * 'width': int, - * 'height': int - * }, - * 'elements': array - * } - * - * @param array $validated_page_metric Page metric, already validated by REST API. - * - * @return int|WP_Error Post ID or WP_Error otherwise. - */ -function ilo_store_page_metric( array $validated_page_metric ) { - $url = $validated_page_metric['url']; - unset( $validated_page_metric['url'] ); // Not stored in post_content but rather in post_title/post_name. - $validated_page_metric['timestamp'] = time(); - - // TODO: What about storing a version identifier? - $post_data = array( - 'post_title' => $url, - ); - - $post = ilo_get_page_metrics_post( $url ); - - if ( $post instanceof WP_Post ) { - $post_data['ID'] = $post->ID; - $post_data['post_name'] = $post->post_name; - - $page_metrics = ilo_parse_stored_page_metrics( $post ); - if ( $page_metrics instanceof WP_Error ) { - if ( function_exists( 'wp_trigger_error' ) ) { - wp_trigger_error( __FUNCTION__, esc_html( $page_metrics->get_error_message() ) ); - } - $page_metrics = array(); - } - } else { - $post_data['post_name'] = ilo_get_page_metrics_slug( $url ); - $page_metrics = array(); - } - - // Add the provided page metric to the page metrics. - array_unshift( $page_metrics, $validated_page_metric ); - $breakpoints = ilo_get_breakpoint_max_widths(); - $sample_size = ilo_get_page_metrics_breakpoint_sample_size(); - $grouped_page_metrics = ilo_group_page_metrics_by_breakpoint( $page_metrics, $breakpoints ); - - foreach ( $grouped_page_metrics as &$breakpoint_page_metrics ) { - if ( count( $breakpoint_page_metrics ) > $sample_size ) { - $breakpoint_page_metrics = array_slice( $breakpoint_page_metrics, 0, $sample_size ); - } - } - - $page_metrics = array_merge( ...$grouped_page_metrics ); - - $post_data['post_content'] = wp_json_encode( $page_metrics, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // TODO: No need for pretty-printing. - - $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); - if ( $has_kses ) { - // Prevent KSES from corrupting JSON in post_content. - kses_remove_filters(); - } - - $post_data['post_type'] = ILO_PAGE_METRICS_POST_TYPE; - $post_data['post_status'] = 'publish'; - if ( isset( $post_data['ID'] ) ) { - $result = wp_update_post( wp_slash( $post_data ), true ); - } else { - $result = wp_insert_post( wp_slash( $post_data ), true ); - } - - if ( $has_kses ) { - kses_init_filters(); - } - - return $result; -} +require_once __DIR__ . '/storage/post-type.php'; +require_once __DIR__ . '/storage/data.php'; +require_once __DIR__ . '/storage/rest-api.php'; diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php new file mode 100644 index 0000000000..f042422c4b --- /dev/null +++ b/modules/images/image-loading-optimization/storage/data.php @@ -0,0 +1,125 @@ + $sample_size ) { + $breakpoint_page_metrics = array_slice( $breakpoint_page_metrics, 0, $sample_size ); + } + } + + return array_merge( ...$grouped_page_metrics ); +} + +/** + * Gets the breakpoint max widths to group page metrics for various viewports. + * + * Each max with represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then + * this means there will be two viewport groupings, one for 0<=480, and another >480. If instead there were three + * provided breakpoints (320, 480, 576) then this means there will be four viewport groupings: + * + * 1. 0-320 (small smartphone) + * 2. 321-480 (normal smartphone) + * 3. 481-576 (phablets) + * 4. >576 (desktop) + * + * @return int[] Breakpoint max widths, sorted in ascending order. + */ +function ilo_get_breakpoint_max_widths() { + + /** + * Filters the breakpoint max widths to group page metrics for various viewports. + * + * @param int[] $breakpoint_max_widths Max widths for viewport breakpoints. + */ + $breakpoint_max_widths = array_map( + static function ( $breakpoint_max_width ) { + return (int) $breakpoint_max_width; + }, + (array) apply_filters( 'ilo_breakpoint_max_widths', array( 480 ) ) + ); + + sort( $breakpoint_max_widths ); + return $breakpoint_max_widths; +} + +/** + * Gets desired sample size for a breakpoint's page metrics. + * + * @return int Sample size. + */ +function ilo_get_page_metrics_breakpoint_sample_size() { + /** + * Filters desired sample size for a viewport's page metrics. + * + * @param int $sample_size Sample size. + */ + return (int) apply_filters( 'ilo_page_metrics_breakpoint_sample_size', 10 ); +} + +/** + * Groups page metrics by breakpoint. + * + * @param array $page_metrics Page metrics. + * @param int[] $breakpoints Viewport breakpoint max widths, sorted in ascending order. + * @return array Grouped page metrics. + */ +function ilo_group_page_metrics_by_breakpoint( array $page_metrics, array $breakpoints ) { + $max_index = count( $breakpoints ); + $groups = array_fill( 0, $max_index + 1, array() ); + $largest_breakpoint = $breakpoints[ $max_index - 1 ]; + foreach ( $page_metrics as $page_metric ) { + if ( ! isset( $page_metric['viewport']['width'] ) ) { + continue; + } + $viewport_width = $page_metric['viewport']['width']; + if ( $viewport_width > $largest_breakpoint ) { + $groups[ $max_index ][] = $page_metric; + } + foreach ( $breakpoints as $group => $breakpoint ) { + if ( $viewport_width <= $breakpoint ) { + $groups[ $group ][] = $page_metric; + } + } + } + return $groups; +} diff --git a/modules/images/image-loading-optimization/storage/lock.php b/modules/images/image-loading-optimization/storage/lock.php index efbb89424c..1ae536397b 100644 --- a/modules/images/image-loading-optimization/storage/lock.php +++ b/modules/images/image-loading-optimization/storage/lock.php @@ -1,6 +1,6 @@ array( + 'name' => __( 'Page Metrics', 'performance-lab' ), + 'singular_name' => __( 'Page Metrics', 'performance-lab' ), + ), + 'public' => false, + 'hierarchical' => false, + 'rewrite' => false, + 'query_var' => false, + 'delete_with_user' => false, + 'can_export' => false, + 'supports' => array( 'title' ), // The original URL is stored in the post_title, and the MD5 hash in the post_name. + ) + ); +} +add_action( 'init', 'ilo_register_page_metrics_post_type' ); + +/** + * Gets slug for page metrics post. + * + * @param string $url URL. + * @return string Slug for URL. + */ +function ilo_get_page_metrics_slug( $url ) { + return md5( $url ); +} + +/** + * Get page metrics post. + * + * @param string $url URL. + * @return WP_Post|null Post object if exists. + */ +function ilo_get_page_metrics_post( $url ) { + $post_query = new WP_Query( + array( + 'post_type' => ILO_PAGE_METRICS_POST_TYPE, + 'post_status' => 'publish', + 'name' => ilo_get_page_metrics_slug( $url ), + 'posts_per_page' => 1, + 'no_found_rows' => true, + 'cache_results' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'lazy_load_term_meta' => false, + ) + ); + + $post = array_shift( $post_query->posts ); + if ( $post instanceof WP_Post ) { + return $post; + } else { + return null; + } +} + +/** + * Parses post content in page metrics post. + * + * @param WP_Post $post Page metrics post. + * @return array|WP_Error Page metrics when valid, or WP_Error otherwise. + */ +function ilo_parse_stored_page_metrics( WP_Post $post ) { + $page_metrics = json_decode( $post->post_content, true ); + if ( json_last_error() ) { + return new WP_Error( + 'page_metrics_json_parse_error', + sprintf( + /* translators: 1: Post type slug, 2: JSON error message */ + __( 'Contents of %1$s post type not valid JSON: %2$s', 'performance-lab' ), + ILO_PAGE_METRICS_POST_TYPE, + json_last_error_msg() + ) + ); + } + if ( ! is_array( $page_metrics ) ) { + return new WP_Error( + 'page_metrics_invalid_data_format', + sprintf( + /* translators: %s is post type slug */ + __( 'Contents of %s post type was not a JSON array.', 'performance-lab' ), + ILO_PAGE_METRICS_POST_TYPE + ) + ); + } + return $page_metrics; +} + +/** + * Stores page metric by merging it with the other page metrics for a given URL. + * + * The $validated_page_metric parameter has the following array shape: + * + * { + * 'url': string, + * 'viewport': array{ + * 'width': int, + * 'height': int + * }, + * 'elements': array + * } + * + * @param array $validated_page_metric Page metric, already validated by REST API. + * + * @return int|WP_Error Post ID or WP_Error otherwise. + */ +function ilo_store_page_metric( array $validated_page_metric ) { + $url = $validated_page_metric['url']; + unset( $validated_page_metric['url'] ); // Not stored in post_content but rather in post_title/post_name. + $validated_page_metric['timestamp'] = time(); + + // TODO: What about storing a version identifier? + $post_data = array( + 'post_title' => $url, + ); + + $post = ilo_get_page_metrics_post( $url ); + + if ( $post instanceof WP_Post ) { + $post_data['ID'] = $post->ID; + $post_data['post_name'] = $post->post_name; + + $page_metrics = ilo_parse_stored_page_metrics( $post ); + if ( $page_metrics instanceof WP_Error ) { + if ( function_exists( 'wp_trigger_error' ) ) { + wp_trigger_error( __FUNCTION__, esc_html( $page_metrics->get_error_message() ) ); + } + $page_metrics = array(); + } + } else { + $post_data['post_name'] = ilo_get_page_metrics_slug( $url ); + $page_metrics = array(); + } + + $page_metrics = ilo_unshift_page_metrics( $page_metrics, $validated_page_metric ); + + $post_data['post_content'] = wp_json_encode( $page_metrics, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // TODO: No need for pretty-printing. + + $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); + if ( $has_kses ) { + // Prevent KSES from corrupting JSON in post_content. + kses_remove_filters(); + } + + $post_data['post_type'] = ILO_PAGE_METRICS_POST_TYPE; + $post_data['post_status'] = 'publish'; + if ( isset( $post_data['ID'] ) ) { + $result = wp_update_post( wp_slash( $post_data ), true ); + } else { + $result = wp_insert_post( wp_slash( $post_data ), true ); + } + + if ( $has_kses ) { + kses_init_filters(); + } + + return $result; +} diff --git a/modules/images/image-loading-optimization/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php similarity index 97% rename from modules/images/image-loading-optimization/rest-api.php rename to modules/images/image-loading-optimization/storage/rest-api.php index 74967a0331..42c7d6af74 100644 --- a/modules/images/image-loading-optimization/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -138,7 +138,8 @@ function ilo_register_endpoint() { function ilo_handle_rest_request( WP_REST_Request $request ) { ilo_set_page_metric_storage_lock(); - $result = ilo_store_page_metric( $request->get_json_params() ); + $page_metric = $request->get_json_params(); + $result = ilo_store_page_metric( $page_metric ); if ( $result instanceof WP_Error ) { return $result; From d2455e9c4dfe843e4ed8d4a8f3c80bd52f1a49f0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 7 Nov 2023 20:26:05 -0800 Subject: [PATCH 044/371] Use PHP 7 const --- .../images/image-loading-optimization/storage/post-type.php | 6 +++--- .../images/image-loading-optimization/storage/rest-api.php | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index c0f6788d87..de9f10d5be 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -10,7 +10,7 @@ exit; // Exit if accessed directly. } -define( 'ILO_PAGE_METRICS_POST_TYPE', 'ilo_page_metrics' ); +const ILO_PAGE_METRICS_POST_TYPE = 'ilo_page_metrics'; /** * Register post type for page metrics storage. @@ -88,7 +88,7 @@ function ilo_parse_stored_page_metrics( WP_Post $post ) { return new WP_Error( 'page_metrics_json_parse_error', sprintf( - /* translators: 1: Post type slug, 2: JSON error message */ + /* translators: 1: Post type slug, 2: JSON error message */ __( 'Contents of %1$s post type not valid JSON: %2$s', 'performance-lab' ), ILO_PAGE_METRICS_POST_TYPE, json_last_error_msg() @@ -99,7 +99,7 @@ function ilo_parse_stored_page_metrics( WP_Post $post ) { return new WP_Error( 'page_metrics_invalid_data_format', sprintf( - /* translators: %s is post type slug */ + /* translators: %s is post type slug */ __( 'Contents of %s post type was not a JSON array.', 'performance-lab' ), ILO_PAGE_METRICS_POST_TYPE ) diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 42c7d6af74..87c6e77da4 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -10,8 +10,9 @@ exit; // Exit if accessed directly. } -define( 'ILO_REST_API_NAMESPACE', 'image-loading-optimization/v1' ); -define( 'ILO_PAGE_METRIC_STORAGE_ROUTE', '/image-loading-optimization/page-metric-storage' ); +const ILO_REST_API_NAMESPACE = 'image-loading-optimization/v1'; + +const ILO_PAGE_METRIC_STORAGE_ROUTE = '/image-loading-optimization/page-metric-storage'; /** * Register endpoint for storage of page metric. From 79bafac29a0882a1f1f577aab133ee4846a5f0c1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 7 Nov 2023 20:32:56 -0800 Subject: [PATCH 045/371] Improve static analysis --- .../images/image-loading-optimization/storage/rest-api.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 87c6e77da4..facd678dd8 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -39,7 +39,9 @@ function ilo_register_endpoint() { ILO_PAGE_METRIC_STORAGE_ROUTE, array( 'methods' => 'POST', - 'callback' => 'ilo_handle_rest_request', + 'callback' => static function ( WP_REST_Request $request ) { + return ilo_handle_rest_request( $request ); + }, 'permission_callback' => static function () { // Needs to be available to unauthenticated visitors. if ( ilo_is_page_metric_storage_locked() ) { From c01649442959849de3796239d4092a864bcfdac8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 8 Nov 2023 11:32:55 -0800 Subject: [PATCH 046/371] Split out detection logic into separate include --- .../image-loading-optimization/detection.php | 59 +++++++++++++++++++ .../{ => detection}/detect.js | 0 .../image-loading-optimization/hooks.php | 48 --------------- .../image-loading-optimization/load.php | 1 + .../image-loading-optimization/storage.php | 2 +- 5 files changed, 61 insertions(+), 49 deletions(-) create mode 100644 modules/images/image-loading-optimization/detection.php rename modules/images/image-loading-optimization/{ => detection}/detect.js (100%) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php new file mode 100644 index 0000000000..1cc0567559 --- /dev/null +++ b/modules/images/image-loading-optimization/detection.php @@ -0,0 +1,59 @@ + 'module' ) + ); +} +add_action( 'wp_print_footer_scripts', 'ilo_print_detection_script' ); diff --git a/modules/images/image-loading-optimization/detect.js b/modules/images/image-loading-optimization/detection/detect.js similarity index 100% rename from modules/images/image-loading-optimization/detect.js rename to modules/images/image-loading-optimization/detection/detect.js diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 59b98e61a7..1f002dab9d 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -46,51 +46,3 @@ static function ( $output ) { return $passthrough; } add_filter( 'template_include', 'ilo_buffer_output', PHP_INT_MAX ); - -/** - * Prints the script for detecting loaded images and the LCP element. - * - * @todo This should eventually only print the script if metrics are needed. - * @todo This script should not be printed if the page was requested with non-removal (non-canonical) query args. - */ -function ilo_print_detection_script() { - - // TODO: Also abort if we don't need any new page metrics due to the sample size being full. - if ( ilo_is_page_metric_storage_locked() ) { - return; - } - - $serve_time = ceil( microtime( true ) * 1000 ); - - /** - * Filters the time window between serve time and run time in which loading detection is allowed to run. - * - * Allow this amount of milliseconds between when the page was first generated (and perhaps cached) and when the - * detect function on the page is allowed to perform its detection logic and submit the request to store the results. - * This avoids situations in which there is missing detection metrics in which case a site with page caching which - * also has a lot of traffic could result in a cache stampede. - * - * @since n.e.x.t - * @todo The value should probably be something like the 99th percentile of Time To Last Byte (TTLB) for WordPress sites in CrUX. - * - * @param int $detection_time_window Detection time window in milliseconds. - */ - $detection_time_window = apply_filters( 'perflab_image_loading_detection_time_window', 5000 ); - - $detect_args = array( - $serve_time, - $detection_time_window, - WP_DEBUG, - rest_url( ILO_REST_API_NAMESPACE . ILO_PAGE_METRIC_STORAGE_ROUTE ), - wp_create_nonce( 'wp_rest' ), - ); - wp_print_inline_script_tag( - sprintf( - 'import detect from %s; detect( ...%s )', - wp_json_encode( add_query_arg( 'ver', PERFLAB_VERSION, plugin_dir_url( __FILE__ ) . 'detect.js' ) ), - wp_json_encode( $detect_args ) - ), - array( 'type' => 'module' ) - ); -} -add_action( 'wp_print_footer_scripts', 'ilo_print_detection_script' ); diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index 68f6287195..118b22d91b 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -23,3 +23,4 @@ require_once __DIR__ . '/helper.php'; require_once __DIR__ . '/hooks.php'; require_once __DIR__ . '/storage.php'; +require_once __DIR__ . '/detection.php'; diff --git a/modules/images/image-loading-optimization/storage.php b/modules/images/image-loading-optimization/storage.php index 5ac9fb9220..52bbf3b344 100644 --- a/modules/images/image-loading-optimization/storage.php +++ b/modules/images/image-loading-optimization/storage.php @@ -1,6 +1,6 @@ Date: Wed, 8 Nov 2023 11:33:41 -0800 Subject: [PATCH 047/371] Remove unused helper.php --- modules/images/image-loading-optimization/helper.php | 11 ----------- modules/images/image-loading-optimization/load.php | 1 - 2 files changed, 12 deletions(-) delete mode 100644 modules/images/image-loading-optimization/helper.php diff --git a/modules/images/image-loading-optimization/helper.php b/modules/images/image-loading-optimization/helper.php deleted file mode 100644 index 77e6b12b10..0000000000 --- a/modules/images/image-loading-optimization/helper.php +++ /dev/null @@ -1,11 +0,0 @@ - Date: Wed, 8 Nov 2023 11:44:05 -0800 Subject: [PATCH 048/371] Improve logging --- .../detection/detect.js | 49 ++++++++++++++----- .../storage/rest-api.php | 5 +- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 5971637f13..0fa5d7f534 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -25,6 +25,16 @@ function warn( ...message ) { console.warn( consoleLogPrefix, ...message ); } +/** + * Log an error. + * + * @param {...*} message + */ +function error( ...message ) { + // eslint-disable-next-line no-console + console.error( consoleLogPrefix, ...message ); +} + /** * @typedef {Object} Breadcrumb * @property {number} index - Index of element among sibling elements. @@ -264,7 +274,7 @@ export default async function detect( ); if ( ! breadcrumbs ) { if ( isDebug ) { - warn( 'Unable to look up breadcrumbs for element' ); + error( 'Unable to look up breadcrumbs for element' ); } continue; } @@ -289,18 +299,33 @@ export default async function detect( pageMetrics.elements.push( elementMetrics ); } - log( pageMetrics ); + if ( isDebug ) { + log( 'Page metrics:', pageMetrics ); + } - // TODO: Wait until idle. - const response = await fetch( restApiEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': restApiNonce, - }, - body: JSON.stringify( pageMetrics ), - } ); - log( 'response:', await response.json() ); + // TODO: Wait until idle? Yield to main? + try { + const response = await fetch( restApiEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': restApiNonce, + }, + body: JSON.stringify( pageMetrics ), + } ); + if ( isDebug ) { + const body = await response.json(); + if ( response.status === 200 ) { + log( 'Response:', body ); + } else { + error( 'Failure:', body ); + } + } + } catch ( err ) { + if ( isDebug ) { + error( err ); + } + } // Clean up. breadcrumbedElementsMap.clear(); diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index facd678dd8..000c00e688 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -148,12 +148,11 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { return $result; } - $response = new WP_REST_Response( + return new WP_REST_Response( array( 'success' => true, 'post_id' => $result, + 'data' => ilo_parse_stored_page_metrics( ilo_get_page_metrics_post( $page_metric['url'] ) ), // TODO: Remove this debug data. ) ); - $response->set_status( 201 ); - return $response; } From a65d0be988ad313f3c851c5bb66e662a1a63d126 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 8 Nov 2023 21:07:53 -0800 Subject: [PATCH 049/371] Restore version constant --- modules/images/image-loading-optimization/load.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index 283d45b2ad..90aac38d17 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -9,11 +9,11 @@ */ // Define the constant. -if ( defined( 'ILO_VERSION' ) ) { +if ( defined( 'IMAGE_LOADING_OPTIMIZATION_VERSION' ) ) { return; } -define( 'ILO_VERSION', 'Performance Lab ' . PERFLAB_VERSION ); +define( 'IMAGE_LOADING_OPTIMIZATION_VERSION', 'Performance Lab ' . PERFLAB_VERSION ); // Do not load the code if it is already loaded through another means. if ( function_exists( 'ilo_buffer_output' ) ) { From a17c9a267fe4a31d071f0e59b957ff2f51d1199d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 8 Nov 2023 21:08:23 -0800 Subject: [PATCH 050/371] Remove redundant plugin short-circuit --- modules/images/image-loading-optimization/load.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index 90aac38d17..6004979d30 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -15,11 +15,6 @@ define( 'IMAGE_LOADING_OPTIMIZATION_VERSION', 'Performance Lab ' . PERFLAB_VERSION ); -// Do not load the code if it is already loaded through another means. -if ( function_exists( 'ilo_buffer_output' ) ) { - return; -} - require_once __DIR__ . '/hooks.php'; require_once __DIR__ . '/storage.php'; require_once __DIR__ . '/detection.php'; From 7f6cc2be72512be94acb2781152fde530e656ef7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 8 Nov 2023 21:13:43 -0800 Subject: [PATCH 051/371] Remove superflous include file --- .../images/image-loading-optimization/load.php | 8 +++++++- .../image-loading-optimization/storage.php | 16 ---------------- 2 files changed, 7 insertions(+), 17 deletions(-) delete mode 100644 modules/images/image-loading-optimization/storage.php diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index 6004979d30..1086e0ed9d 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -16,5 +16,11 @@ define( 'IMAGE_LOADING_OPTIMIZATION_VERSION', 'Performance Lab ' . PERFLAB_VERSION ); require_once __DIR__ . '/hooks.php'; -require_once __DIR__ . '/storage.php'; + +// Storage logic. +require_once __DIR__ . '/storage/lock.php'; +require_once __DIR__ . '/storage/post-type.php'; +require_once __DIR__ . '/storage/data.php'; +require_once __DIR__ . '/storage/rest-api.php'; + require_once __DIR__ . '/detection.php'; diff --git a/modules/images/image-loading-optimization/storage.php b/modules/images/image-loading-optimization/storage.php deleted file mode 100644 index 52bbf3b344..0000000000 --- a/modules/images/image-loading-optimization/storage.php +++ /dev/null @@ -1,16 +0,0 @@ - Date: Wed, 8 Nov 2023 21:16:14 -0800 Subject: [PATCH 052/371] Improve page metrics route --- modules/images/image-loading-optimization/detection.php | 2 +- .../images/image-loading-optimization/storage/rest-api.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 1cc0567559..76dd045198 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -44,7 +44,7 @@ function ilo_print_detection_script() { $serve_time, $detection_time_window, WP_DEBUG, - rest_url( ILO_REST_API_NAMESPACE . ILO_PAGE_METRIC_STORAGE_ROUTE ), + rest_url( ILO_REST_API_NAMESPACE . ILO_PAGE_METRICS_ROUTE ), wp_create_nonce( 'wp_rest' ), ); wp_print_inline_script_tag( diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 000c00e688..4844392d3a 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -12,7 +12,7 @@ const ILO_REST_API_NAMESPACE = 'image-loading-optimization/v1'; -const ILO_PAGE_METRIC_STORAGE_ROUTE = '/image-loading-optimization/page-metric-storage'; +const ILO_PAGE_METRICS_ROUTE = '/page-metrics'; /** * Register endpoint for storage of page metric. @@ -36,7 +36,7 @@ function ilo_register_endpoint() { register_rest_route( ILO_REST_API_NAMESPACE, - ILO_PAGE_METRIC_STORAGE_ROUTE, + ILO_PAGE_METRICS_ROUTE, array( 'methods' => 'POST', 'callback' => static function ( WP_REST_Request $request ) { From fb8837b11999f96d9a07c07661767452ad885982 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 8 Nov 2023 21:29:27 -0800 Subject: [PATCH 053/371] Update composer lockfile --- composer.lock | 73 +++++++++++++++++++++++++++------------------------ 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/composer.lock b/composer.lock index 59e7347f06..6a9ae7127c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8afb8511538e46c6875a017b72ad8711", + "content-hash": "2dcd132f2c017c64da30a4a4b6f78f29", "packages": [ { "name": "composer/installers", @@ -236,30 +236,30 @@ }, { "name": "doctrine/instantiator", - "version": "2.0.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^11", + "doctrine/coding-standard": "^9 || ^11", "ext-pdo": "*", "ext-phar": "*", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.9.4", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^9.5.27", - "vimeo/psalm": "^5.4" + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.30 || ^5.4" }, "type": "library", "autoload": { @@ -286,7 +286,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" }, "funding": [ { @@ -302,7 +302,7 @@ "type": "tidelift" } ], - "time": "2022-12-30T00:23:10+00:00" + "time": "2022-12-30T00:15:36+00:00" }, { "name": "myclabs/deep-copy", @@ -532,16 +532,16 @@ }, { "name": "php-stubs/wordpress-stubs", - "version": "v6.3.0", + "version": "v6.4.0", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "adda7609e71d5f4dc7b87c74f8ec9e3437d2e92c" + "reference": "286d42eeb44c6808633cc59b8dbb9aa75fe41264" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/adda7609e71d5f4dc7b87c74f8ec9e3437d2e92c", - "reference": "adda7609e71d5f4dc7b87c74f8ec9e3437d2e92c", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/286d42eeb44c6808633cc59b8dbb9aa75fe41264", + "reference": "286d42eeb44c6808633cc59b8dbb9aa75fe41264", "shasum": "" }, "require-dev": { @@ -554,6 +554,7 @@ }, "suggest": { "paragonie/sodium_compat": "Pure PHP implementation of libsodium", + "symfony/polyfill-php80": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" }, "type": "library", @@ -570,9 +571,9 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.3.0" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.4.0" }, - "time": "2023-08-10T16:34:11+00:00" + "time": "2023-11-08T07:02:08+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -865,16 +866,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.38", + "version": "1.10.41", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "5302bb402c57f00fb3c2c015bac86e0827e4b691" + "reference": "c6174523c2a69231df55bdc65b61655e72876d76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/5302bb402c57f00fb3c2c015bac86e0827e4b691", - "reference": "5302bb402c57f00fb3c2c015bac86e0827e4b691", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6174523c2a69231df55bdc65b61655e72876d76", + "reference": "c6174523c2a69231df55bdc65b61655e72876d76", "shasum": "" }, "require": { @@ -923,7 +924,7 @@ "type": "tidelift" } ], - "time": "2023-10-06T14:19:14+00:00" + "time": "2023-11-05T12:57:57+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -2614,22 +2615,22 @@ }, { "name": "szepeviktor/phpstan-wordpress", - "version": "v1.3.0", + "version": "v1.3.2", "source": { "type": "git", "url": "https://github.com/szepeviktor/phpstan-wordpress.git", - "reference": "5b5cc77ed51fdaf64efe3f00b5aae4b709d2cfa9" + "reference": "b8516ed6bab7ec50aae981698ce3f67f1be2e45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/5b5cc77ed51fdaf64efe3f00b5aae4b709d2cfa9", - "reference": "5b5cc77ed51fdaf64efe3f00b5aae4b709d2cfa9", + "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/b8516ed6bab7ec50aae981698ce3f67f1be2e45a", + "reference": "b8516ed6bab7ec50aae981698ce3f67f1be2e45a", "shasum": "" }, "require": { "php": "^7.2 || ^8.0", "php-stubs/wordpress-stubs": "^4.7 || ^5.0 || ^6.0", - "phpstan/phpstan": "^1.10.0", + "phpstan/phpstan": "^1.10.30", "symfony/polyfill-php73": "^1.12.0" }, "require-dev": { @@ -2640,6 +2641,9 @@ "phpunit/phpunit": "^8.0 || ^9.0", "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^0.8" }, + "suggest": { + "swissspidy/phpstan-no-private": "Detect usage of internal core functions, classes and methods" + }, "type": "phpstan-extension", "extra": { "phpstan": { @@ -2667,9 +2671,9 @@ ], "support": { "issues": "https://github.com/szepeviktor/phpstan-wordpress/issues", - "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v1.3.0" + "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v1.3.2" }, - "time": "2023-04-23T06:15:06+00:00" + "time": "2023-10-16T17:23:56+00:00" }, { "name": "theseer/tokenizer", @@ -2902,8 +2906,9 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=7|^8" + "php": ">=7|^8", + "ext-json": "*" }, "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.2.0" } From 80c6827277ebcad35c8ebdfe173c773fc8d23d71 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 9 Nov 2023 10:36:29 -0800 Subject: [PATCH 054/371] Pass object to detect() instead of positional args --- .../image-loading-optimization/detection.php | 12 ++++++------ .../detection/detect.js | 17 +++++++++-------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 76dd045198..3dcd945e91 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -41,15 +41,15 @@ function ilo_print_detection_script() { $detection_time_window = apply_filters( 'perflab_image_loading_detection_time_window', 5000 ); $detect_args = array( - $serve_time, - $detection_time_window, - WP_DEBUG, - rest_url( ILO_REST_API_NAMESPACE . ILO_PAGE_METRICS_ROUTE ), - wp_create_nonce( 'wp_rest' ), + 'serveTime' => $serve_time, + 'detectionTimeWindow' => $detection_time_window, + 'isDebug' => WP_DEBUG, + 'restApiEndpoint' => rest_url( ILO_REST_API_NAMESPACE . ILO_PAGE_METRICS_ROUTE ), + 'restApiNonce' => wp_create_nonce( 'wp_rest' ), ); wp_print_inline_script_tag( sprintf( - 'import detect from %s; detect( ...%s )', + 'import detect from %s; detect( %s )', wp_json_encode( add_query_arg( 'ver', PERFLAB_VERSION, plugin_dir_url( __FILE__ ) . 'detection/detect.js' ) ), wp_json_encode( $detect_args ) ), diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 0fa5d7f534..7498e3d81d 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -98,19 +98,20 @@ function getBreadcrumbs( leafElement ) { /** * Detects the LCP element, loaded images, client viewport and store for future optimizations. * - * @param {number} serveTime The serve time of the page in milliseconds from PHP via `ceil( microtime( true ) * 1000 )`. - * @param {number} detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. - * @param {boolean} isDebug Whether to show debug messages. - * @param {string} restApiEndpoint URL for where to send the detection data. - * @param {string} restApiNonce Nonce for writing to the REST API. + * @param {Object} args Args. + * @param {number} args.serveTime The serve time of the page in milliseconds from PHP via `ceil( microtime( true ) * 1000 )`. + * @param {number} args.detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. + * @param {boolean} args.isDebug Whether to show debug messages. + * @param {string} args.restApiEndpoint URL for where to send the detection data. + * @param {string} args.restApiNonce Nonce for writing to the REST API. */ -export default async function detect( +export default async function detect( { serveTime, detectionTimeWindow, isDebug, restApiEndpoint, - restApiNonce -) { + restApiNonce, +} ) { const runTime = new Date().valueOf(); // Abort running detection logic if it was served in a cached page. From f6208c20ba799d74b2eced8a161145d68cb5c551 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 9 Nov 2023 10:50:11 -0800 Subject: [PATCH 055/371] Add description to ilo_get_page_metrics_breakpoint_sample_size and reduce default size to 3 --- .../images/image-loading-optimization/storage/data.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index f042422c4b..8bb8b3fb68 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -83,17 +83,21 @@ static function ( $breakpoint_max_width ) { } /** - * Gets desired sample size for a breakpoint's page metrics. + * Gets the sample size for a breakpoint's page metrics on a given URL. + * + * A breakpoint divides page metrics for viewports which are smaller and those which are larger. Given the default + * sample size of 3 and there being just a single breakpoint (480) by default, for a given URL, there would be a maximum + * total of 6 page metrics stored for a given URL: 3 for mobile and 3 for desktop. * * @return int Sample size. */ function ilo_get_page_metrics_breakpoint_sample_size() { /** - * Filters desired sample size for a viewport's page metrics. + * Filters the sample size for a breakpoint's page metrics on a given URL. * * @param int $sample_size Sample size. */ - return (int) apply_filters( 'ilo_page_metrics_breakpoint_sample_size', 10 ); + return (int) apply_filters( 'ilo_page_metrics_breakpoint_sample_size', 3 ); } /** From fcf9acd3cdbfd33a940c75ef9088d5f265a0181a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 9 Nov 2023 15:23:39 -0800 Subject: [PATCH 056/371] Add initial function to get normalized current URL --- .../storage/data.php | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 8bb8b3fb68..5de95f4049 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -28,6 +28,78 @@ function ilo_get_page_metric_ttl() { return (int) apply_filters( 'ilo_page_metric_ttl', MONTH_IN_SECONDS ); } +/** + * Gets the normalized current URL. + * + * TODO: This will need to be made more robust for non-singular URLs. What about multi-faceted archives with multiple taxonomies and date parameters? + * + * @return string Normalized current URL. + */ +function ilo_get_normalized_current_url() { + if ( is_singular() ) { + $url = wp_get_canonical_url(); + if ( $url ) { + return $url; + } + } + + $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH ); + + $scheme = is_ssl() ? 'https' : 'http'; + $host = strtok( $_SERVER['HTTP_HOST'], ':' ); // Use of strtok() since wp-env erroneously includes port in host. + $port = (int) $_SERVER['SERVER_PORT']; + $path = ''; + $query = ''; + if ( preg_match( '%(^.+?)(?:\?([^#]+))?%', wp_unslash( $_SERVER['REQUEST_URI'] ), $matches ) ) { + if ( ! empty( $matches[1] ) ) { + $path = $matches[1]; + } + if ( ! empty( $matches[2] ) ) { + $query = $matches[2]; + } + } + if ( $query ) { + $removable_query_args = wp_removable_query_args(); + $removable_query_args[] = 'fbclid'; + + $old_query_args = array(); + $new_query_args = array(); + wp_parse_str( $query, $old_query_args ); + foreach ( $old_query_args as $key => $value ) { + if ( + str_starts_with( 'utm_', $key ) || + in_array( $key, $removable_query_args, true ) + ) { + continue; + } + $new_query_args[ $key ] = $value; + } + asort( $new_query_args ); + $query = build_query( $new_query_args ); + } + + // Normalize open-ended URLs. + if ( is_404() ) { + $path = $home_path; + $query = 'error=404'; + } elseif ( is_search() ) { + $path = $home_path; + $query = 's={}'; + } + + // Rebuild the URL. + $url = $scheme . '://' . $host; + if ( 80 !== $port && 443 !== $port ) { + $url .= ":{$port}"; + } + $url .= $path; + if ( $query ) { + $url .= "?{$query}"; + } + + return $url; +} + /** * Unshift a new page metric onto an array of page metrics. * From 224ea516dd0ff470f51f9905d43865c2ee67b9ea Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 10 Nov 2023 11:22:40 -0800 Subject: [PATCH 057/371] Add function for normalizing query vars and getting current URL --- .../storage/data.php | 106 +++++++++--------- 1 file changed, 51 insertions(+), 55 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 5de95f4049..c2ac6b38e4 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -29,75 +29,71 @@ function ilo_get_page_metric_ttl() { } /** - * Gets the normalized current URL. + * Get the URL for the current request. * - * TODO: This will need to be made more robust for non-singular URLs. What about multi-faceted archives with multiple taxonomies and date parameters? + * This is essentially the REQUEST_URI prefixed by the scheme and host for the home URL. + * This is needed in particular due to subdirectory installs. * - * @return string Normalized current URL. + * @return string Current URL. */ -function ilo_get_normalized_current_url() { - if ( is_singular() ) { - $url = wp_get_canonical_url(); - if ( $url ) { - return $url; - } +function ilo_get_current_url() { + $parsed_url = wp_parse_url( home_url() ); + + if ( ! is_array( $parsed_url ) ) { + $parsed_url = array(); } - $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH ); + if ( empty( $parsed_url['scheme'] ) ) { + $parsed_url['scheme'] = is_ssl() ? 'https' : 'http'; + } + if ( ! isset( $parsed_url['host'] ) ) { + $parsed_url['host'] = isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : 'localhost'; + } - $scheme = is_ssl() ? 'https' : 'http'; - $host = strtok( $_SERVER['HTTP_HOST'], ':' ); // Use of strtok() since wp-env erroneously includes port in host. - $port = (int) $_SERVER['SERVER_PORT']; - $path = ''; - $query = ''; - if ( preg_match( '%(^.+?)(?:\?([^#]+))?%', wp_unslash( $_SERVER['REQUEST_URI'] ), $matches ) ) { - if ( ! empty( $matches[1] ) ) { - $path = $matches[1]; - } - if ( ! empty( $matches[2] ) ) { - $query = $matches[2]; + $current_url = $parsed_url['scheme'] . '://'; + if ( isset( $parsed_url['user'] ) ) { + $current_url .= $parsed_url['user']; + if ( isset( $parsed_url['pass'] ) ) { + $current_url .= ':' . $parsed_url['pass']; } + $current_url .= '@'; } - if ( $query ) { - $removable_query_args = wp_removable_query_args(); - $removable_query_args[] = 'fbclid'; - - $old_query_args = array(); - $new_query_args = array(); - wp_parse_str( $query, $old_query_args ); - foreach ( $old_query_args as $key => $value ) { - if ( - str_starts_with( 'utm_', $key ) || - in_array( $key, $removable_query_args, true ) - ) { - continue; - } - $new_query_args[ $key ] = $value; - } - asort( $new_query_args ); - $query = build_query( $new_query_args ); + $current_url .= $parsed_url['host']; + if ( isset( $parsed_url['port'] ) ) { + $current_url .= ':' . $parsed_url['port']; } + $current_url .= '/'; - // Normalize open-ended URLs. - if ( is_404() ) { - $path = $home_path; - $query = 'error=404'; - } elseif ( is_search() ) { - $path = $home_path; - $query = 's={}'; + if ( isset( $_SERVER['REQUEST_URI'] ) ) { + $current_url .= ltrim( wp_unslash( $_SERVER['REQUEST_URI'] ), '/' ); } + return esc_url_raw( $current_url ); +} - // Rebuild the URL. - $url = $scheme . '://' . $host; - if ( 80 !== $port && 443 !== $port ) { - $url .= ":{$port}"; - } - $url .= $path; - if ( $query ) { - $url .= "?{$query}"; +/** + * Gets the normalized query vars for the current request. + * + * This is used as a cache key for stored page metrics. + * + * @return array Normalized query vars. + */ +function ilo_get_normalized_query_vars() { + global $wp; + + // Note that the order of this array is naturally normalized since it is + // assembled by iterating over public_query_vars. + $normalized_query_vars = $wp->query_vars; + + // Normalize unbounded query vars. + if ( is_404() ) { + $normalized_query_vars = array( + 'error' => 404, + ); + } elseif ( array_key_exists( 's', $normalized_query_vars ) ) { + $normalized_query_vars['s'] = '...'; } - return $url; + return $normalized_query_vars; } /** From dd9b3754dc3d3aed99dada94656e9e1988ae1b07 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 10 Nov 2023 11:23:35 -0800 Subject: [PATCH 058/371] Use current() instead of array_shift() --- modules/images/image-loading-optimization/storage/post-type.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index de9f10d5be..578dcc2db2 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -68,7 +68,7 @@ function ilo_get_page_metrics_post( $url ) { ) ); - $post = array_shift( $post_query->posts ); + $post = current( $post_query->posts ); if ( $post instanceof WP_Post ) { return $post; } else { From f9a14939d935e37a360dae450a4a19fff9f92c43 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 10 Nov 2023 12:39:11 -0800 Subject: [PATCH 059/371] Use query vars instead of URL for computing slug; add HMAC --- .../image-loading-optimization/detection.php | 5 +++ .../detection/detect.js | 8 ++++- .../storage/data.php | 24 ++++++++++++++ .../storage/post-type.php | 32 ++++++------------- .../storage/rest-api.php | 20 ++++++++++-- 5 files changed, 64 insertions(+), 25 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 3dcd945e91..00f5e10c72 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -40,12 +40,17 @@ function ilo_print_detection_script() { */ $detection_time_window = apply_filters( 'perflab_image_loading_detection_time_window', 5000 ); + $query_vars = ilo_get_normalized_query_vars(); + $slug = ilo_get_page_metrics_slug( $query_vars ); + $detect_args = array( 'serveTime' => $serve_time, 'detectionTimeWindow' => $detection_time_window, 'isDebug' => WP_DEBUG, 'restApiEndpoint' => rest_url( ILO_REST_API_NAMESPACE . ILO_PAGE_METRICS_ROUTE ), 'restApiNonce' => wp_create_nonce( 'wp_rest' ), + 'pageMetricsSlug' => $slug, + 'pageMetricsHmac' => ilo_get_slug_hmac( $slug ), // TODO: Or would a nonce make more sense with the $slug being the action? ); wp_print_inline_script_tag( sprintf( diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 7498e3d81d..dec3879ba3 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -104,6 +104,8 @@ function getBreadcrumbs( leafElement ) { * @param {boolean} args.isDebug Whether to show debug messages. * @param {string} args.restApiEndpoint URL for where to send the detection data. * @param {string} args.restApiNonce Nonce for writing to the REST API. + * @param {string} args.pageMetricsSlug Slug for page metrics. + * @param {string} args.pageMetricsHmac HMAC for the page metric slug. */ export default async function detect( { serveTime, @@ -111,6 +113,8 @@ export default async function detect( { isDebug, restApiEndpoint, restApiNonce, + pageMetricsSlug, + pageMetricsHmac, } ) { const runTime = new Date().valueOf(); @@ -259,7 +263,9 @@ export default async function detect( { /** @type {PageMetrics} */ const pageMetrics = { - url: win.location.href, // TODO: Consider sending canonical URL instead. + url: win.location.href, + slug: pageMetricsSlug, + hmac: pageMetricsHmac, viewport: { width: win.innerWidth, height: win.innerHeight, diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index c2ac6b38e4..63e5ccc3b7 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -96,6 +96,30 @@ function ilo_get_normalized_query_vars() { return $normalized_query_vars; } +/** + * Gets slug for page metrics. + * + * @see ilo_get_normalized_query_vars() + * + * @param array $query_vars Normalized query vars. + * @return string Slug. + */ +function ilo_get_page_metrics_slug( $query_vars ) { + return md5( wp_json_encode( $query_vars ) ); +} + +/** + * Compute HMAC for page metrics slug. + * + * This is used in the REST API to authenticate the storage of new page metrics from a given URL. + * + * @param string $slug Page metrics slug. + * @return false HMAC. + */ +function ilo_get_slug_hmac( $slug ) { + return hash_hmac( 'sha1', $slug, wp_salt() ); +} + /** * Unshift a new page metric onto an array of page metrics. * diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index 578dcc2db2..d3bfefdc32 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -37,28 +37,18 @@ function ilo_register_page_metrics_post_type() { } add_action( 'init', 'ilo_register_page_metrics_post_type' ); -/** - * Gets slug for page metrics post. - * - * @param string $url URL. - * @return string Slug for URL. - */ -function ilo_get_page_metrics_slug( $url ) { - return md5( $url ); -} - /** * Get page metrics post. * - * @param string $url URL. + * @param string $slug Page metrics slug. * @return WP_Post|null Post object if exists. */ -function ilo_get_page_metrics_post( $url ) { +function ilo_get_page_metrics_post( $slug ) { $post_query = new WP_Query( array( 'post_type' => ILO_PAGE_METRICS_POST_TYPE, 'post_status' => 'publish', - 'name' => ilo_get_page_metrics_slug( $url ), + 'name' => $slug, 'posts_per_page' => 1, 'no_found_rows' => true, 'cache_results' => true, @@ -114,7 +104,6 @@ function ilo_parse_stored_page_metrics( WP_Post $post ) { * The $validated_page_metric parameter has the following array shape: * * { - * 'url': string, * 'viewport': array{ * 'width': int, * 'height': int @@ -122,21 +111,20 @@ function ilo_parse_stored_page_metrics( WP_Post $post ) { * 'elements': array * } * - * @param array $validated_page_metric Page metric, already validated by REST API. - * + * @param string $url URL for the page metrics. This is used purely as metadata. + * @param string $slug Page metrics slug (computed from query vars). + * @param array $validated_page_metric Page metric, already validated by REST API. * @return int|WP_Error Post ID or WP_Error otherwise. */ -function ilo_store_page_metric( array $validated_page_metric ) { - $url = $validated_page_metric['url']; - unset( $validated_page_metric['url'] ); // Not stored in post_content but rather in post_title/post_name. +function ilo_store_page_metric( $url, $slug, array $validated_page_metric ) { $validated_page_metric['timestamp'] = time(); // TODO: What about storing a version identifier? $post_data = array( - 'post_title' => $url, + 'post_title' => $url, // TODO: Should we keep this? It can help with debugging. ); - $post = ilo_get_page_metrics_post( $url ); + $post = ilo_get_page_metrics_post( $slug ); if ( $post instanceof WP_Post ) { $post_data['ID'] = $post->ID; @@ -150,7 +138,7 @@ function ilo_store_page_metric( array $validated_page_metric ) { $page_metrics = array(); } } else { - $post_data['post_name'] = ilo_get_page_metrics_slug( $url ); + $post_data['post_name'] = $slug; $page_metrics = array(); } diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 4844392d3a..fb2c2c9868 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -65,6 +65,22 @@ function ilo_register_endpoint() { return true; }, ), + 'slug' => array( + 'type' => 'string', + 'required' => true, + 'pattern' => '^[0-9a-f]{32}$', + ), + 'hmac' => array( + 'type' => 'string', + 'required' => true, + 'pattern' => '^[0-9a-f]+$', + 'validate_callback' => static function ( $hmac, WP_REST_Request $request ) { + if ( ! hash_equals( $hmac, ilo_get_slug_hmac( $request->get_param( 'slug' ) ) ) ) { + return new WP_Error( 'invalid_hmac', __( 'HMAC comparison failure.', 'performance-lab' ) ); + } + return true; + }, + ), 'viewport' => array( 'description' => __( 'Viewport dimensions', 'performance-lab' ), 'type' => 'object', @@ -142,7 +158,7 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { ilo_set_page_metric_storage_lock(); $page_metric = $request->get_json_params(); - $result = ilo_store_page_metric( $page_metric ); + $result = ilo_store_page_metric( $page_metric['url'], $page_metric['slug'], $request->get_json_params() ); if ( $result instanceof WP_Error ) { return $result; @@ -152,7 +168,7 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { array( 'success' => true, 'post_id' => $result, - 'data' => ilo_parse_stored_page_metrics( ilo_get_page_metrics_post( $page_metric['url'] ) ), // TODO: Remove this debug data. + 'data' => ilo_parse_stored_page_metrics( ilo_get_page_metrics_post( $page_metric['slug'] ) ), // TODO: Remove this debug data. ) ); } From 5dc6828bd39683c931f75e0a60250316e145fdaa Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 10 Nov 2023 13:03:35 -0800 Subject: [PATCH 060/371] Use nonce instead of hmac --- .../image-loading-optimization/detection.php | 2 +- .../detection/detect.js | 6 ++--- .../storage/data.php | 27 ++++++++++++++++--- .../storage/rest-api.php | 8 +++--- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 00f5e10c72..113577ffb0 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -50,7 +50,7 @@ function ilo_print_detection_script() { 'restApiEndpoint' => rest_url( ILO_REST_API_NAMESPACE . ILO_PAGE_METRICS_ROUTE ), 'restApiNonce' => wp_create_nonce( 'wp_rest' ), 'pageMetricsSlug' => $slug, - 'pageMetricsHmac' => ilo_get_slug_hmac( $slug ), // TODO: Or would a nonce make more sense with the $slug being the action? + 'pageMetricsNonce' => ilo_get_page_metrics_storage_nonce( $slug ), ); wp_print_inline_script_tag( sprintf( diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index dec3879ba3..6379cf7856 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -105,7 +105,7 @@ function getBreadcrumbs( leafElement ) { * @param {string} args.restApiEndpoint URL for where to send the detection data. * @param {string} args.restApiNonce Nonce for writing to the REST API. * @param {string} args.pageMetricsSlug Slug for page metrics. - * @param {string} args.pageMetricsHmac HMAC for the page metric slug. + * @param {string} args.pageMetricsNonce Nonce for page metrics storage. */ export default async function detect( { serveTime, @@ -114,7 +114,7 @@ export default async function detect( { restApiEndpoint, restApiNonce, pageMetricsSlug, - pageMetricsHmac, + pageMetricsNonce, } ) { const runTime = new Date().valueOf(); @@ -265,7 +265,7 @@ export default async function detect( { const pageMetrics = { url: win.location.href, slug: pageMetricsSlug, - hmac: pageMetricsHmac, + nonce: pageMetricsNonce, viewport: { width: win.innerWidth, height: win.innerHeight, diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 63e5ccc3b7..f3813143b6 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -109,15 +109,34 @@ function ilo_get_page_metrics_slug( $query_vars ) { } /** - * Compute HMAC for page metrics slug. + * Compute nonce for storing page metrics for a specific slug. * * This is used in the REST API to authenticate the storage of new page metrics from a given URL. * + * @see wp_create_nonce() + * @see ilo_verify_page_metrics_storage_nonce() + * * @param string $slug Page metrics slug. - * @return false HMAC. + * @return string Nonce. + */ +function ilo_get_page_metrics_storage_nonce( $slug ) { + return wp_create_nonce( "store_page_metrics:{$slug}" ); +} + +/** + * Verify nonce for storing page metrics for a specific slug. + * + * @see wp_verify_nonce() + * @see ilo_get_page_metrics_storage_nonce() + * + * @param string $nonce Page metrics storage nonce. + * @param string $slug Page metrics slug. + * @return int|false 1 if the nonce is valid and generated between 0-12 hours ago, + * 2 if the nonce is valid and generated between 12-24 hours ago. + * False if the nonce is invalid. */ -function ilo_get_slug_hmac( $slug ) { - return hash_hmac( 'sha1', $slug, wp_salt() ); +function ilo_verify_page_metrics_storage_nonce( $nonce, $slug ) { + return wp_verify_nonce( $nonce, "store_page_metrics:{$slug}" ); } /** diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index fb2c2c9868..2c2816c9a3 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -70,13 +70,13 @@ function ilo_register_endpoint() { 'required' => true, 'pattern' => '^[0-9a-f]{32}$', ), - 'hmac' => array( + 'nonce' => array( 'type' => 'string', 'required' => true, 'pattern' => '^[0-9a-f]+$', - 'validate_callback' => static function ( $hmac, WP_REST_Request $request ) { - if ( ! hash_equals( $hmac, ilo_get_slug_hmac( $request->get_param( 'slug' ) ) ) ) { - return new WP_Error( 'invalid_hmac', __( 'HMAC comparison failure.', 'performance-lab' ) ); + 'validate_callback' => static function ( $nonce, WP_REST_Request $request ) { + if ( ! ilo_verify_page_metrics_storage_nonce( $nonce, $request->get_param( 'slug' ) ) ) { + return new WP_Error( 'invalid_nonce', __( 'Page metrics nonce verification failure.', 'performance-lab' ) ); } return true; }, From e0f2b6826ee48502a6ba3594f93f82b4e3c0c877 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 10 Nov 2023 15:19:04 -0800 Subject: [PATCH 061/371] Prevent collecting page metrics when sample size is full for all breakpoints --- .../image-loading-optimization/detection.php | 40 ++++++++++++++++--- .../storage/data.php | 40 ++++++++++++------- .../storage/post-type.php | 20 +++++++++- .../storage/rest-api.php | 10 +++-- 4 files changed, 87 insertions(+), 23 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 113577ffb0..a8a7c3c234 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -17,8 +17,41 @@ * @todo This script should not be printed if the page was requested with non-removal (non-canonical) query args. */ function ilo_print_detection_script() { + $query_vars = ilo_get_normalized_query_vars(); + $slug = ilo_get_page_metrics_slug( $query_vars ); + $data = ilo_get_page_metrics_data( $slug ); + if ( ! is_array( $data ) ) { + $data = $data; + } + + $metrics_by_breakpoint = ilo_group_page_metrics_by_breakpoint( $data, ilo_get_breakpoint_max_widths() ); + $sample_size = ilo_get_page_metrics_breakpoint_sample_size(); + $freshness_ttl = ilo_get_page_metric_freshness_ttl(); + + // TODO: This same logic needs to be in the endpoint so that we can reject requests when not needed. + $current_time = time(); + $needed_minimum_viewport_widths = array(); + foreach ( $metrics_by_breakpoint as $minimum_viewport_width => $page_metrics ) { + $needs_page_metrics = false; + if ( count( $page_metrics ) < $sample_size ) { + $needs_page_metrics = true; + } else { + foreach ( $page_metrics as $page_metric ) { + if ( isset( $page_metric['timestamp'] ) && $page_metric['timestamp'] + $freshness_ttl < $current_time ) { + $needs_page_metrics = true; + break; + } + } + } + $needed_minimum_viewport_widths[ $minimum_viewport_width ] = $needs_page_metrics; + } - // TODO: Also abort if we don't need any new page metrics due to the sample size being full. + // Abort if we already have all the sample size we need for all breakpoints. + if ( count( array_filter( $needed_minimum_viewport_widths ) ) === 0 ) { + return; + } + + // Abort if storage is locked. if ( ilo_is_page_metric_storage_locked() ) { return; } @@ -40,9 +73,6 @@ function ilo_print_detection_script() { */ $detection_time_window = apply_filters( 'perflab_image_loading_detection_time_window', 5000 ); - $query_vars = ilo_get_normalized_query_vars(); - $slug = ilo_get_page_metrics_slug( $query_vars ); - $detect_args = array( 'serveTime' => $serve_time, 'detectionTimeWindow' => $detection_time_window, @@ -54,7 +84,7 @@ function ilo_print_detection_script() { ); wp_print_inline_script_tag( sprintf( - 'import detect from %s; detect( %s )', + 'import detect from %s; detect( %s );', wp_json_encode( add_query_arg( 'ver', PERFLAB_VERSION, plugin_dir_url( __FILE__ ) . 'detection/detect.js' ) ), wp_json_encode( $detect_args ) ), diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index f3813143b6..1eda44737b 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -15,11 +15,9 @@ * * When a page metric expires it is eligible to be replaced by a newer one. * - * TODO: However, we keep viewport-specific page metrics regardless of TTL. - * * @return int Expiration age in seconds. */ -function ilo_get_page_metric_ttl() { +function ilo_get_page_metric_freshness_ttl() { /** * Filters the expiration age for a given page metric. * @@ -216,25 +214,39 @@ function ilo_get_page_metrics_breakpoint_sample_size() { * * @param array $page_metrics Page metrics. * @param int[] $breakpoints Viewport breakpoint max widths, sorted in ascending order. - * @return array Grouped page metrics. + * @return array Page metrics grouped by breakpoint. The array keys are the minimum widths for a viewport to lie within + * the breakpoint. The returned array is always one larger than the provided array of breakpoints, since + * the breakpoints reflect the max inclusive boundaries whereas the return value is the groups of page + * metrics with viewports on either side of the breakpoint boundaries. */ function ilo_group_page_metrics_by_breakpoint( array $page_metrics, array $breakpoints ) { - $max_index = count( $breakpoints ); - $groups = array_fill( 0, $max_index + 1, array() ); - $largest_breakpoint = $breakpoints[ $max_index - 1 ]; + + // Convert breakpoint max widths into viewport minimum widths. + $viewport_minimum_widths = array_map( + static function ( $breakpoint ) { + return $breakpoint + 1; + }, + $breakpoints + ); + + $grouped = array_fill_keys( array_merge( array( 0 ), $viewport_minimum_widths ), array() ); + foreach ( $page_metrics as $page_metric ) { if ( ! isset( $page_metric['viewport']['width'] ) ) { continue; } $viewport_width = $page_metric['viewport']['width']; - if ( $viewport_width > $largest_breakpoint ) { - $groups[ $max_index ][] = $page_metric; - } - foreach ( $breakpoints as $group => $breakpoint ) { - if ( $viewport_width <= $breakpoint ) { - $groups[ $group ][] = $page_metric; + + $current_minimum_viewport = 0; + foreach ( $viewport_minimum_widths as $viewport_minimum_width ) { + if ( $viewport_width > $viewport_minimum_width ) { + $current_minimum_viewport = $viewport_minimum_width; + } else { + break; } } + + $grouped[ $current_minimum_viewport ][] = $page_metric; } - return $groups; + return $grouped; } diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index d3bfefdc32..c9a0c173ba 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -31,7 +31,7 @@ function ilo_register_page_metrics_post_type() { 'query_var' => false, 'delete_with_user' => false, 'can_export' => false, - 'supports' => array( 'title' ), // The original URL is stored in the post_title, and the MD5 hash in the post_name. + 'supports' => array( 'title' ), // The original URL is stored in the post_title, and the post_name is a hash of the query vars. ) ); } @@ -98,6 +98,24 @@ function ilo_parse_stored_page_metrics( WP_Post $post ) { return $page_metrics; } +/** + * Parses post content in page metrics post. + * + * @param string $slug Page metrics slug. + * @return array Page metrics data, or null if invalid. + */ +function ilo_get_page_metrics_data( $slug ) { + $post = ilo_get_page_metrics_post( $slug ); + if ( ! ( $post instanceof WP_Post ) ) { + return null; + } + $data = ilo_parse_stored_page_metrics( $post ); + if ( ! is_array( $data ) ) { + return null; + } + return $data; +} + /** * Stores page metric by merging it with the other page metrics for a given URL. * diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 2c2816c9a3..7d48ced227 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -157,8 +157,12 @@ function ilo_register_endpoint() { function ilo_handle_rest_request( WP_REST_Request $request ) { ilo_set_page_metric_storage_lock(); - $page_metric = $request->get_json_params(); - $result = ilo_store_page_metric( $page_metric['url'], $page_metric['slug'], $request->get_json_params() ); + $page_metric = wp_array_slice_assoc( $request->get_json_params(), array( 'viewport', 'elements' ) ); + $result = ilo_store_page_metric( + $request->get_param( 'url' ), + $request->get_param( 'slug' ), + $page_metric + ); if ( $result instanceof WP_Error ) { return $result; @@ -168,7 +172,7 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { array( 'success' => true, 'post_id' => $result, - 'data' => ilo_parse_stored_page_metrics( ilo_get_page_metrics_post( $page_metric['slug'] ) ), // TODO: Remove this debug data. + 'data' => ilo_parse_stored_page_metrics( ilo_get_page_metrics_post( $request->get_param( 'slug' ) ) ), // TODO: Remove this debug data. ) ); } From a6b7760bcbe4c44acadb31f656e06993fecb50bc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 13 Nov 2023 10:58:27 -0800 Subject: [PATCH 062/371] Fix function prefix in tests and self-assignment --- admin/server-timing.php | 4 ++-- modules/images/image-loading-optimization/detection.php | 2 +- server-timing/class-perflab-server-timing.php | 2 +- tests/admin/server-timing-tests.php | 2 +- .../images/image-loading-optimization/load-tests.php | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/admin/server-timing.php b/admin/server-timing.php index 46f28df890..85daaf1464 100644 --- a/admin/server-timing.php +++ b/admin/server-timing.php @@ -43,7 +43,7 @@ function perflab_add_server_timing_page() { * @since 2.6.0 */ function perflab_load_server_timing_page() { - if ( ! has_filter( 'template_include', 'image_loading_optimization_buffer_output' ) ) { + if ( ! has_filter( 'template_include', 'ilo_buffer_output' ) ) { /* * This settings section technically includes a field, however it is directly rendered as part of the section * callback due to requiring custom markup. @@ -95,7 +95,7 @@ static function () { ); ?>

- +

assertArrayHasKey( PERFLAB_SERVER_TIMING_SCREEN, $wp_settings_sections ); $expected_sections = array( 'benchmarking' ); - if ( ! has_filter( 'template_include', 'image_loading_optimization_buffer_output' ) ) { + if ( ! has_filter( 'template_include', 'ilo_buffer_output' ) ) { $expected_sections[] = 'output-buffering'; } $this->assertEqualSets( diff --git a/tests/modules/images/image-loading-optimization/load-tests.php b/tests/modules/images/image-loading-optimization/load-tests.php index a1a9333c05..3fb443947d 100644 --- a/tests/modules/images/image-loading-optimization/load-tests.php +++ b/tests/modules/images/image-loading-optimization/load-tests.php @@ -16,14 +16,14 @@ class Image_Loading_Optimization_Load_Tests extends ImagesTestCase { * @test */ public function it_is_hooking_output_buffering_at_template_include() { - $this->assertEquals( PHP_INT_MAX, has_filter( 'template_include', 'image_loading_optimization_buffer_output' ) ); + $this->assertEquals( PHP_INT_MAX, has_filter( 'template_include', 'ilo_buffer_output' ) ); } /** * Make output is buffered and that it is also filtered. * * @test - * @covers ::image_loading_optimization_buffer_output + * @covers ::ilo_buffer_output */ public function it_buffers_and_filters_output() { $original = 'Hello World!'; @@ -42,7 +42,7 @@ function ( $buffer ) use ( $original, $expected ) { ); $original_ob_level = ob_get_level(); - image_loading_optimization_buffer_output(); + ilo_buffer_output(); $this->assertSame( $original_ob_level + 1, ob_get_level(), 'Expected call to ob_start().' ); echo $original; From f7aa73b4ee4ec342d933e5a405376e4725371aee Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 13 Nov 2023 11:03:10 -0800 Subject: [PATCH 063/371] Fix filter for ilo_get_page_metric_freshness_ttl; reduce TTL from month to day --- .../image-loading-optimization/storage/data.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 1eda44737b..91d20d46a7 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -11,19 +11,19 @@ } /** - * Gets the expiration age for a given page metric. + * Gets the freshness age (TTL) for a given page metric. * - * When a page metric expires it is eligible to be replaced by a newer one. + * When a page metric expires it is eligible to be replaced by a newer one if its viewport lies within the same breakpoint. * - * @return int Expiration age in seconds. + * @return int Expiration TTL in seconds. */ function ilo_get_page_metric_freshness_ttl() { /** - * Filters the expiration age for a given page metric. + * Filters the freshness age (TTL) for a given page metric. * - * @param int $ttl TTL. + * @param int $ttl Expiration TTL in seconds. */ - return (int) apply_filters( 'ilo_page_metric_ttl', MONTH_IN_SECONDS ); + return (int) apply_filters( 'ilo_page_metric_freshness_ttl', DAY_IN_SECONDS ); } /** From 8eb0dbef72f7a7957f9fc5660601901b19760d3e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 13 Nov 2023 17:16:39 -0800 Subject: [PATCH 064/371] Add client-side and server-side checks for whether page metrics needed for breakpoints --- .../image-loading-optimization/detection.php | 45 ++++----------- .../detection/detect.js | 47 +++++++++++++--- .../storage/data.php | 55 +++++++++++++++++++ .../storage/rest-api.php | 15 ++++- 4 files changed, 116 insertions(+), 46 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 91f336793d..2f96406e0f 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -13,41 +13,15 @@ /** * Prints the script for detecting loaded images and the LCP element. * - * @todo This should eventually only print the script if metrics are needed. * @todo This script should not be printed if the page was requested with non-removal (non-canonical) query args. */ function ilo_print_detection_script() { $query_vars = ilo_get_normalized_query_vars(); $slug = ilo_get_page_metrics_slug( $query_vars ); - $data = ilo_get_page_metrics_data( $slug ); - if ( ! is_array( $data ) ) { - $data = array(); - } - - $metrics_by_breakpoint = ilo_group_page_metrics_by_breakpoint( $data, ilo_get_breakpoint_max_widths() ); - $sample_size = ilo_get_page_metrics_breakpoint_sample_size(); - $freshness_ttl = ilo_get_page_metric_freshness_ttl(); - - // TODO: This same logic needs to be in the endpoint so that we can reject requests when not needed. - $current_time = time(); - $needed_minimum_viewport_widths = array(); - foreach ( $metrics_by_breakpoint as $minimum_viewport_width => $page_metrics ) { - $needs_page_metrics = false; - if ( count( $page_metrics ) < $sample_size ) { - $needs_page_metrics = true; - } else { - foreach ( $page_metrics as $page_metric ) { - if ( isset( $page_metric['timestamp'] ) && $page_metric['timestamp'] + $freshness_ttl < $current_time ) { - $needs_page_metrics = true; - break; - } - } - } - $needed_minimum_viewport_widths[ $minimum_viewport_width ] = $needs_page_metrics; - } // Abort if we already have all the sample size we need for all breakpoints. - if ( count( array_filter( $needed_minimum_viewport_widths ) ) === 0 ) { + $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( $slug ); + if ( ! ilo_needs_page_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { return; } @@ -74,13 +48,14 @@ function ilo_print_detection_script() { $detection_time_window = apply_filters( 'perflab_image_loading_detection_time_window', 5000 ); $detect_args = array( - 'serveTime' => $serve_time, - 'detectionTimeWindow' => $detection_time_window, - 'isDebug' => WP_DEBUG, - 'restApiEndpoint' => rest_url( ILO_REST_API_NAMESPACE . ILO_PAGE_METRICS_ROUTE ), - 'restApiNonce' => wp_create_nonce( 'wp_rest' ), - 'pageMetricsSlug' => $slug, - 'pageMetricsNonce' => ilo_get_page_metrics_storage_nonce( $slug ), + 'serveTime' => $serve_time, + 'detectionTimeWindow' => $detection_time_window, + 'isDebug' => WP_DEBUG, + 'restApiEndpoint' => rest_url( ILO_REST_API_NAMESPACE . ILO_PAGE_METRICS_ROUTE ), + 'restApiNonce' => wp_create_nonce( 'wp_rest' ), + 'pageMetricsSlug' => $slug, + 'pageMetricsNonce' => ilo_get_page_metrics_storage_nonce( $slug ), + 'neededMinimumViewportWidths' => $needed_minimum_viewport_widths, ); wp_print_inline_script_tag( sprintf( diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 6379cf7856..5101f127df 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -95,17 +95,40 @@ function getBreadcrumbs( leafElement ) { return breadcrumbs; } +/** + * Checks whether the page metric(s) for the provided viewport width is needed. + * + * @param {number} viewportWidth - Current viewport width. + * @param {Array[]} neededMinimumViewportWidths - Needed minimum viewport widths, in ascending order. + * @return {boolean} Whether page metrics are needed. + */ +function isViewportNeeded( viewportWidth, neededMinimumViewportWidths ) { + let lastWasNeeded = false; + for ( const [ + minimumViewportWidth, + isNeeded, + ] of neededMinimumViewportWidths ) { + if ( viewportWidth >= minimumViewportWidth ) { + lastWasNeeded = isNeeded; + } else { + break; + } + } + return lastWasNeeded; +} + /** * Detects the LCP element, loaded images, client viewport and store for future optimizations. * - * @param {Object} args Args. - * @param {number} args.serveTime The serve time of the page in milliseconds from PHP via `ceil( microtime( true ) * 1000 )`. - * @param {number} args.detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. - * @param {boolean} args.isDebug Whether to show debug messages. - * @param {string} args.restApiEndpoint URL for where to send the detection data. - * @param {string} args.restApiNonce Nonce for writing to the REST API. - * @param {string} args.pageMetricsSlug Slug for page metrics. - * @param {string} args.pageMetricsNonce Nonce for page metrics storage. + * @param {Object} args Args. + * @param {number} args.serveTime The serve time of the page in milliseconds from PHP via `ceil( microtime( true ) * 1000 )`. + * @param {number} args.detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. + * @param {boolean} args.isDebug Whether to show debug messages. + * @param {string} args.restApiEndpoint URL for where to send the detection data. + * @param {string} args.restApiNonce Nonce for writing to the REST API. + * @param {string} args.pageMetricsSlug Slug for page metrics. + * @param {string} args.pageMetricsNonce Nonce for page metrics storage. + * @param {Array} args.neededMinimumViewportWidths Needed minimum viewport widths for page metrics. */ export default async function detect( { serveTime, @@ -115,6 +138,7 @@ export default async function detect( { restApiNonce, pageMetricsSlug, pageMetricsNonce, + neededMinimumViewportWidths, // TODO: The name is not great here. } ) { const runTime = new Date().valueOf(); @@ -139,6 +163,13 @@ export default async function detect( { return; } + if ( ! isViewportNeeded( win.innerWidth, neededMinimumViewportWidths ) ) { + if ( isDebug ) { + log( 'No need for page metrics from the current viewport.' ); + } + return; + } + if ( isDebug ) { log( 'Proceeding with detection' ); } diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 91d20d46a7..1088de68b1 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -250,3 +250,58 @@ static function ( $breakpoint ) { } return $grouped; } + +/** + * Get needed minimum viewport widths. + * + * @param string $slug Page metric slug. + * @return array Array of tuples mapping minimum viewport width to whether page metric(s) are needed. + */ +function ilo_get_needed_minimum_viewport_widths( $slug ) { + $data = ilo_get_page_metrics_data( $slug ); + if ( ! is_array( $data ) ) { + $data = array(); + } + + $metrics_by_breakpoint = ilo_group_page_metrics_by_breakpoint( $data, ilo_get_breakpoint_max_widths() ); + $sample_size = ilo_get_page_metrics_breakpoint_sample_size(); + $freshness_ttl = ilo_get_page_metric_freshness_ttl(); + + $current_time = time(); + $needed_minimum_viewport_widths = array(); + foreach ( $metrics_by_breakpoint as $minimum_viewport_width => $viewport_page_metrics ) { + $needs_page_metrics = false; + if ( count( $viewport_page_metrics ) < $sample_size ) { + $needs_page_metrics = true; + } else { + foreach ( $viewport_page_metrics as $page_metric ) { + if ( isset( $page_metric['timestamp'] ) && $page_metric['timestamp'] + $freshness_ttl < $current_time ) { + $needs_page_metrics = true; + break; + } + } + } + $needed_minimum_viewport_widths[] = array( + $minimum_viewport_width, + $needs_page_metrics, + ); + } + + return $needed_minimum_viewport_widths; +} + + +/** + * Checks whether there is a page metric needed for one of the breakpoints. + * + * @param array $needed_minimum_viewport_widths Array of tuples mapping minimum viewport width to whether page metric(s) are needed. + * @return bool Whether a page metric is needed. + */ +function ilo_needs_page_metric_for_breakpoint( $needed_minimum_viewport_widths ) { + foreach ( $needed_minimum_viewport_widths as list( $minimum_viewport_width, $is_needed ) ) { + if ( $is_needed ) { + return true; + } + } + return false; +} diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 7d48ced227..bcd90347a0 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -155,13 +155,22 @@ function ilo_register_endpoint() { * @return WP_REST_Response|WP_Error Response. */ function ilo_handle_rest_request( WP_REST_Request $request ) { + $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( $request->get_param( 'slug' ) ); + if ( ! ilo_needs_page_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { + return new WP_Error( + 'no_page_metric_needed', + __( 'No page metric needed for any of the breakpoints.', 'performance-lab' ), + array( 'status' => 403 ) + ); + } + ilo_set_page_metric_storage_lock(); + $new_page_metric = wp_array_slice_assoc( $request->get_json_params(), array( 'viewport', 'elements' ) ); - $page_metric = wp_array_slice_assoc( $request->get_json_params(), array( 'viewport', 'elements' ) ); - $result = ilo_store_page_metric( + $result = ilo_store_page_metric( $request->get_param( 'url' ), $request->get_param( 'slug' ), - $page_metric + $new_page_metric ); if ( $result instanceof WP_Error ) { From 8c6b55984b7604ac01886bf8c87e8720748f7354 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 13 Nov 2023 17:21:49 -0800 Subject: [PATCH 065/371] Remove unused ilo_get_current_url() --- .../storage/data.php | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 1088de68b1..a544363d34 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -26,48 +26,6 @@ function ilo_get_page_metric_freshness_ttl() { return (int) apply_filters( 'ilo_page_metric_freshness_ttl', DAY_IN_SECONDS ); } -/** - * Get the URL for the current request. - * - * This is essentially the REQUEST_URI prefixed by the scheme and host for the home URL. - * This is needed in particular due to subdirectory installs. - * - * @return string Current URL. - */ -function ilo_get_current_url() { - $parsed_url = wp_parse_url( home_url() ); - - if ( ! is_array( $parsed_url ) ) { - $parsed_url = array(); - } - - if ( empty( $parsed_url['scheme'] ) ) { - $parsed_url['scheme'] = is_ssl() ? 'https' : 'http'; - } - if ( ! isset( $parsed_url['host'] ) ) { - $parsed_url['host'] = isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : 'localhost'; - } - - $current_url = $parsed_url['scheme'] . '://'; - if ( isset( $parsed_url['user'] ) ) { - $current_url .= $parsed_url['user']; - if ( isset( $parsed_url['pass'] ) ) { - $current_url .= ':' . $parsed_url['pass']; - } - $current_url .= '@'; - } - $current_url .= $parsed_url['host']; - if ( isset( $parsed_url['port'] ) ) { - $current_url .= ':' . $parsed_url['port']; - } - $current_url .= '/'; - - if ( isset( $_SERVER['REQUEST_URI'] ) ) { - $current_url .= ltrim( wp_unslash( $_SERVER['REQUEST_URI'] ), '/' ); - } - return esc_url_raw( $current_url ); -} - /** * Gets the normalized query vars for the current request. * @@ -290,7 +248,6 @@ function ilo_get_needed_minimum_viewport_widths( $slug ) { return $needed_minimum_viewport_widths; } - /** * Checks whether there is a page metric needed for one of the breakpoints. * From 7e4064ab11f2befa4e8987e1149a7052019160bb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 13 Nov 2023 17:40:14 -0800 Subject: [PATCH 066/371] Improve testability of ilo_get_needed_minimum_viewport_widths() --- .../image-loading-optimization/detection.php | 8 +++++++- .../storage/data.php | 19 +++++++------------ .../storage/post-type.php | 13 +++++++++---- .../storage/rest-api.php | 8 +++++++- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 2f96406e0f..8bc5bd373f 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -20,7 +20,13 @@ function ilo_print_detection_script() { $slug = ilo_get_page_metrics_slug( $query_vars ); // Abort if we already have all the sample size we need for all breakpoints. - $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( $slug ); + $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( + ilo_get_page_metrics_data( $slug ), + time(), + ilo_get_breakpoint_max_widths(), + ilo_get_page_metrics_breakpoint_sample_size(), + ilo_get_page_metric_freshness_ttl() + ); if ( ! ilo_needs_page_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { return; } diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index a544363d34..8a8573b7c6 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -212,20 +212,15 @@ static function ( $breakpoint ) { /** * Get needed minimum viewport widths. * - * @param string $slug Page metric slug. + * @param array $page_metrics Page metrics. + * @param int $current_time Current time. + * @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 page metric. * @return array Array of tuples mapping minimum viewport width to whether page metric(s) are needed. */ -function ilo_get_needed_minimum_viewport_widths( $slug ) { - $data = ilo_get_page_metrics_data( $slug ); - if ( ! is_array( $data ) ) { - $data = array(); - } - - $metrics_by_breakpoint = ilo_group_page_metrics_by_breakpoint( $data, ilo_get_breakpoint_max_widths() ); - $sample_size = ilo_get_page_metrics_breakpoint_sample_size(); - $freshness_ttl = ilo_get_page_metric_freshness_ttl(); - - $current_time = time(); +function ilo_get_needed_minimum_viewport_widths( $page_metrics, $current_time, $breakpoint_max_widths, $sample_size, $freshness_ttl ) { + $metrics_by_breakpoint = ilo_group_page_metrics_by_breakpoint( $page_metrics, $breakpoint_max_widths ); $needed_minimum_viewport_widths = array(); foreach ( $metrics_by_breakpoint as $minimum_viewport_width => $viewport_page_metrics ) { $needs_page_metrics = false; diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index c9a0c173ba..b5b5a6db29 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -99,19 +99,24 @@ function ilo_parse_stored_page_metrics( WP_Post $post ) { } /** - * Parses post content in page metrics post. + * Gets page metrics for a slug. + * + * This is a convenience abstractions for lower-level functions. + * + * @see ilo_get_page_metrics_post() + * @see ilo_parse_stored_page_metrics() * * @param string $slug Page metrics slug. - * @return array Page metrics data, or null if invalid. + * @return array Page metrics data, or empty array if invalid. */ function ilo_get_page_metrics_data( $slug ) { $post = ilo_get_page_metrics_post( $slug ); if ( ! ( $post instanceof WP_Post ) ) { - return null; + return array(); } $data = ilo_parse_stored_page_metrics( $post ); if ( ! is_array( $data ) ) { - return null; + return array(); } return $data; } diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index bcd90347a0..2e8a0518dd 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -155,7 +155,13 @@ function ilo_register_endpoint() { * @return WP_REST_Response|WP_Error Response. */ function ilo_handle_rest_request( WP_REST_Request $request ) { - $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( $request->get_param( 'slug' ) ); + $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( + ilo_get_page_metrics_data( $request->get_param( 'slug' ) ), + time(), + ilo_get_breakpoint_max_widths(), + ilo_get_page_metrics_breakpoint_sample_size(), + ilo_get_page_metric_freshness_ttl() + ); if ( ! ilo_needs_page_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { return new WP_Error( 'no_page_metric_needed', From a1f1aaa9e9261740380558af5c9901661465e6b5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 13 Nov 2023 18:16:54 -0800 Subject: [PATCH 067/371] Opt for sessionStorage for storage lock for aborting --- .../image-loading-optimization/detection.php | 6 +- .../detection/detect.js | 87 ++++++++++++++++--- 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 8bc5bd373f..287ef79425 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -31,11 +31,6 @@ function ilo_print_detection_script() { return; } - // Abort if storage is locked. - if ( ilo_is_page_metric_storage_locked() ) { - return; - } - $serve_time = ceil( microtime( true ) * 1000 ); /** @@ -62,6 +57,7 @@ function ilo_print_detection_script() { 'pageMetricsSlug' => $slug, 'pageMetricsNonce' => ilo_get_page_metrics_storage_nonce( $slug ), 'neededMinimumViewportWidths' => $needed_minimum_viewport_widths, + 'storageLockTTL' => ilo_get_page_metric_storage_lock_ttl(), ); wp_print_inline_script_tag( sprintf( diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 5101f127df..11bc7d0c3b 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -5,6 +5,43 @@ const doc = win.document; const consoleLogPrefix = '[Image Loading Optimization]'; +const storageLockTimeSessionKey = 'iloStorageLockTime'; + +/** + * Checks whether storage is locked. + * + * @param {number} currentTime - Current time in milliseconds. + * @param {number} storageLockTTL - Storage lock TTL in seconds. + * @return {boolean} Whether storage is locked. + */ +function isStorageLocked( currentTime, storageLockTTL ) { + try { + const storageLockTime = parseInt( + sessionStorage.getItem( storageLockTimeSessionKey ) + ); + return ( + ! isNaN( storageLockTime ) && + currentTime < storageLockTime + storageLockTTL * 1000 + ); + } catch ( e ) { + return false; + } +} + +/** + * Set the storage lock. + * + * @param {number} currentTime - Current time in milliseconds. + */ +function setStorageLock( currentTime ) { + try { + sessionStorage.setItem( + storageLockTimeSessionKey, + String( currentTime ) + ); + } catch ( e ) {} +} + /** * Log a message. * @@ -117,18 +154,28 @@ function isViewportNeeded( viewportWidth, neededMinimumViewportWidths ) { return lastWasNeeded; } +/** + * Gets the current time in milliseconds. + * + * @return {number} Current time in milliseconds. + */ +function getCurrentTime() { + return new Date().valueOf(); +} + /** * Detects the LCP element, loaded images, client viewport and store for future optimizations. * - * @param {Object} args Args. - * @param {number} args.serveTime The serve time of the page in milliseconds from PHP via `ceil( microtime( true ) * 1000 )`. - * @param {number} args.detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. - * @param {boolean} args.isDebug Whether to show debug messages. - * @param {string} args.restApiEndpoint URL for where to send the detection data. - * @param {string} args.restApiNonce Nonce for writing to the REST API. - * @param {string} args.pageMetricsSlug Slug for page metrics. - * @param {string} args.pageMetricsNonce Nonce for page metrics storage. - * @param {Array} args.neededMinimumViewportWidths Needed minimum viewport widths for page metrics. + * @param {Object} args Args. + * @param {number} args.serveTime The serve time of the page in milliseconds from PHP via `ceil( microtime( true ) * 1000 )`. + * @param {number} args.detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. + * @param {boolean} args.isDebug Whether to show debug messages. + * @param {string} args.restApiEndpoint URL for where to send the detection data. + * @param {string} args.restApiNonce Nonce for writing to the REST API. + * @param {string} args.pageMetricsSlug Slug for page metrics. + * @param {string} args.pageMetricsNonce Nonce for page metrics storage. + * @param {Array[]} args.neededMinimumViewportWidths Needed minimum viewport widths for page metrics. + * @param {number} args.storageLockTTL The TTL (in seconds) for the page metric storage lock. */ export default async function detect( { serveTime, @@ -138,12 +185,23 @@ export default async function detect( { restApiNonce, pageMetricsSlug, pageMetricsNonce, - neededMinimumViewportWidths, // TODO: The name is not great here. + neededMinimumViewportWidths, + storageLockTTL, } ) { - const runTime = new Date().valueOf(); + const currentTime = getCurrentTime(); + + // As an alternative to this, the ilo_print_detection_script() function can short-circuit if the + // ilo_is_page_metric_storage_locked() function returns true. However, the downside with that is page caching could + // result in metrics being missed being gathered when a user navigates around a site and primes the page cache. + if ( isStorageLocked( currentTime, storageLockTTL ) ) { + if ( isDebug ) { + warn( 'Aborted detection due to storage being locked.' ); + } + return; + } // Abort running detection logic if it was served in a cached page. - if ( runTime - serveTime > detectionTimeWindow ) { + if ( currentTime - serveTime > detectionTimeWindow ) { if ( isDebug ) { warn( 'Aborted detection due to being outside detection time window.' @@ -351,6 +409,11 @@ export default async function detect( { }, body: JSON.stringify( pageMetrics ), } ); + + if ( response.status === 200 ) { + setStorageLock( getCurrentTime() ); + } + if ( isDebug ) { const body = await response.json(); if ( response.status === 200 ) { From acf17f97e6ef66a1a499fa04f47efc2d471a02a4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 10:33:22 -0800 Subject: [PATCH 068/371] Ensure grouped page metrics are sorted by timestamp before unshifting --- .../image-loading-optimization/storage/data.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 8a8573b7c6..e5294c7380 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -110,6 +110,18 @@ function ilo_unshift_page_metrics( $page_metrics, $validated_page_metric ) { foreach ( $grouped_page_metrics as &$breakpoint_page_metrics ) { if ( count( $breakpoint_page_metrics ) > $sample_size ) { + + // Sort page metrics in descending order by timestamp. + usort( + $breakpoint_page_metrics, + static function ( $a, $b ) { + if ( ! isset( $a['timestamp'] ) || ! isset( $b['timestamp'] ) ) { + return 0; + } + return $b['timestamp'] <=> $a['timestamp']; + } + ); + $breakpoint_page_metrics = array_slice( $breakpoint_page_metrics, 0, $sample_size ); } } From 6cd8198532b7076770d93b2003364949d0d3030f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 10:36:18 -0800 Subject: [PATCH 069/371] Use microtime(true) instead of time() --- .../images/image-loading-optimization/detection.php | 7 +++---- .../image-loading-optimization/detection/detect.js | 4 ++-- .../image-loading-optimization/storage/data.php | 2 +- .../image-loading-optimization/storage/lock.php | 12 ++++++------ .../image-loading-optimization/storage/post-type.php | 2 +- .../image-loading-optimization/storage/rest-api.php | 2 +- 6 files changed, 14 insertions(+), 15 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 287ef79425..ac251f83b9 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -18,11 +18,12 @@ function ilo_print_detection_script() { $query_vars = ilo_get_normalized_query_vars(); $slug = ilo_get_page_metrics_slug( $query_vars ); + $microtime = microtime( true ); // Abort if we already have all the sample size we need for all breakpoints. $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( ilo_get_page_metrics_data( $slug ), - time(), + $microtime, ilo_get_breakpoint_max_widths(), ilo_get_page_metrics_breakpoint_sample_size(), ilo_get_page_metric_freshness_ttl() @@ -31,8 +32,6 @@ function ilo_print_detection_script() { return; } - $serve_time = ceil( microtime( true ) * 1000 ); - /** * Filters the time window between serve time and run time in which loading detection is allowed to run. * @@ -49,7 +48,7 @@ function ilo_print_detection_script() { $detection_time_window = apply_filters( 'perflab_image_loading_detection_time_window', 5000 ); $detect_args = array( - 'serveTime' => $serve_time, + 'serveTime' => $microtime * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. 'detectionTimeWindow' => $detection_time_window, 'isDebug' => WP_DEBUG, 'restApiEndpoint' => rest_url( ILO_REST_API_NAMESPACE . ILO_PAGE_METRICS_ROUTE ), diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 11bc7d0c3b..803919f28c 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -160,14 +160,14 @@ function isViewportNeeded( viewportWidth, neededMinimumViewportWidths ) { * @return {number} Current time in milliseconds. */ function getCurrentTime() { - return new Date().valueOf(); + return Date.now(); } /** * Detects the LCP element, loaded images, client viewport and store for future optimizations. * * @param {Object} args Args. - * @param {number} args.serveTime The serve time of the page in milliseconds from PHP via `ceil( microtime( true ) * 1000 )`. + * @param {number} args.serveTime The serve time of the page in milliseconds from PHP via `microtime( true ) * 1000`. * @param {number} args.detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. * @param {boolean} args.isDebug Whether to show debug messages. * @param {string} args.restApiEndpoint URL for where to send the detection data. diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index e5294c7380..e1a747310f 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -225,7 +225,7 @@ static function ( $breakpoint ) { * Get needed minimum viewport widths. * * @param array $page_metrics Page metrics. - * @param int $current_time Current time. + * @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 page metric. diff --git a/modules/images/image-loading-optimization/storage/lock.php b/modules/images/image-loading-optimization/storage/lock.php index 1ae536397b..f0084b9d38 100644 --- a/modules/images/image-loading-optimization/storage/lock.php +++ b/modules/images/image-loading-optimization/storage/lock.php @@ -11,9 +11,9 @@ } /** - * Gets the TTL for the page metric storage lock. + * Gets the TTL (in seconds) for the page metric storage lock. * - * @return int TTL. + * @return int TTL in seconds. */ function ilo_get_page_metric_storage_lock_ttl() { @@ -47,7 +47,7 @@ function ilo_set_page_metric_storage_lock() { if ( 0 === $ttl ) { delete_transient( $key ); } else { - set_transient( $key, time(), $ttl ); + set_transient( $key, microtime( true ), $ttl ); } } @@ -61,9 +61,9 @@ function ilo_is_page_metric_storage_locked() { if ( 0 === $ttl ) { return false; } - $locked_time = (int) get_transient( ilo_get_page_metric_storage_lock_transient_key() ); - if ( 0 === $locked_time ) { + $locked_time = get_transient( ilo_get_page_metric_storage_lock_transient_key() ); + if ( false === $locked_time ) { return false; } - return time() - $locked_time < $ttl; + return microtime( true ) - floatval( $locked_time ) < $ttl; } diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index b5b5a6db29..3654cccd1e 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -140,7 +140,7 @@ function ilo_get_page_metrics_data( $slug ) { * @return int|WP_Error Post ID or WP_Error otherwise. */ function ilo_store_page_metric( $url, $slug, array $validated_page_metric ) { - $validated_page_metric['timestamp'] = time(); + $validated_page_metric['timestamp'] = microtime( true ); // TODO: What about storing a version identifier? $post_data = array( diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 2e8a0518dd..0c84c79f51 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -157,7 +157,7 @@ function ilo_register_endpoint() { function ilo_handle_rest_request( WP_REST_Request $request ) { $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( ilo_get_page_metrics_data( $request->get_param( 'slug' ) ), - time(), + microtime( true ), ilo_get_breakpoint_max_widths(), ilo_get_page_metrics_breakpoint_sample_size(), ilo_get_page_metric_freshness_ttl() From 9d6707c54ed3e04cd6378f49187e30b54e14337e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 10:42:03 -0800 Subject: [PATCH 070/371] Reduce code duplication with helper function --- .../image-loading-optimization/detection.php | 8 +------ .../storage/data.php | 21 +++++++++++++++++++ .../storage/rest-api.php | 8 +------ 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index ac251f83b9..f7e929480d 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -21,13 +21,7 @@ function ilo_print_detection_script() { $microtime = microtime( true ); // Abort if we already have all the sample size we need for all breakpoints. - $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( - ilo_get_page_metrics_data( $slug ), - $microtime, - ilo_get_breakpoint_max_widths(), - ilo_get_page_metrics_breakpoint_sample_size(), - ilo_get_page_metric_freshness_ttl() - ); + $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths_now_for_slug( $slug ); if ( ! ilo_needs_page_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { return; } diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index e1a747310f..4ca3081001 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -255,6 +255,27 @@ function ilo_get_needed_minimum_viewport_widths( $page_metrics, $current_time, $ return $needed_minimum_viewport_widths; } + +/** + * Get needed minimum viewport widths by slug for the current time. + * + * This is a convenience wrapper on top of ilo_get_needed_minimum_viewport_widths() to reduce code duplication. + * + * @see ilo_get_needed_minimum_viewport_widths() + * + * @param string $slug Page metrics slug. + * @return array Array of tuples mapping minimum viewport width to whether page metric(s) are needed. + */ +function ilo_get_needed_minimum_viewport_widths_now_for_slug( $slug ) { + return ilo_get_needed_minimum_viewport_widths( + ilo_get_page_metrics_data( $slug ), + microtime( true ), + ilo_get_breakpoint_max_widths(), + ilo_get_page_metrics_breakpoint_sample_size(), + ilo_get_page_metric_freshness_ttl() + ); +} + /** * Checks whether there is a page metric needed for one of the breakpoints. * diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 0c84c79f51..9feea484df 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -155,13 +155,7 @@ function ilo_register_endpoint() { * @return WP_REST_Response|WP_Error Response. */ function ilo_handle_rest_request( WP_REST_Request $request ) { - $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( - ilo_get_page_metrics_data( $request->get_param( 'slug' ) ), - microtime( true ), - ilo_get_breakpoint_max_widths(), - ilo_get_page_metrics_breakpoint_sample_size(), - ilo_get_page_metric_freshness_ttl() - ); + $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths_now_for_slug( $request->get_param( 'slug' ) ); if ( ! ilo_needs_page_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { return new WP_Error( 'no_page_metric_needed', From 99ef2996caf805cf2ca0d253374ee66e901cbf84 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 11:28:41 -0800 Subject: [PATCH 071/371] Prevent optimizing search results and add ilo_can_optimize_response filter --- .../image-loading-optimization/detection.php | 6 +++-- .../storage/data.php | 23 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index f7e929480d..a8e014e0f5 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -12,10 +12,12 @@ /** * Prints the script for detecting loaded images and the LCP element. - * - * @todo This script should not be printed if the page was requested with non-removal (non-canonical) query args. */ function ilo_print_detection_script() { + if ( ! ilo_can_optimize_response() ) { + return; + } + $query_vars = ilo_get_normalized_query_vars(); $slug = ilo_get_page_metrics_slug( $query_vars ); $microtime = microtime( true ); diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 4ca3081001..7c4c97b6b3 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -26,6 +26,26 @@ function ilo_get_page_metric_freshness_ttl() { return (int) apply_filters( 'ilo_page_metric_freshness_ttl', DAY_IN_SECONDS ); } +/** + * 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. + * + * @return bool Whether response can be optimized. + */ +function ilo_can_optimize_response() { + $able = ! is_search(); + + /** + * Filters whether the current response can be optimized. + * + * @param bool $able Whether response can be optimized. + */ + return (bool) apply_filters( 'ilo_can_optimize_response', $able ); +} + /** * Gets the normalized query vars for the current request. * @@ -45,8 +65,6 @@ function ilo_get_normalized_query_vars() { $normalized_query_vars = array( 'error' => 404, ); - } elseif ( array_key_exists( 's', $normalized_query_vars ) ) { - $normalized_query_vars['s'] = '...'; } return $normalized_query_vars; @@ -255,7 +273,6 @@ function ilo_get_needed_minimum_viewport_widths( $page_metrics, $current_time, $ return $needed_minimum_viewport_widths; } - /** * Get needed minimum viewport widths by slug for the current time. * From 8cb2dcf73ad67a21733f668c8f8554b0bc97baf0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 11:34:11 -0800 Subject: [PATCH 072/371] Add TODO for ilo_get_normalized_query_vars --- modules/images/image-loading-optimization/storage/data.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 7c4c97b6b3..b0c2662e38 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -51,6 +51,8 @@ function ilo_can_optimize_response() { * * This is used as a cache key for stored page metrics. * + * TODO: For non-singular requests, consider adding the post IDs from The Loop to ensure publishing a new post will invalidate the cache. + * * @return array Normalized query vars. */ function ilo_get_normalized_query_vars() { From a4ffbaef191b7d2b859c0a8bf08834fd57defedf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 12:55:45 -0800 Subject: [PATCH 073/371] Reference JSON Schema for defintion of function arg array shape --- .../image-loading-optimization/storage/data.php | 2 +- .../image-loading-optimization/storage/post-type.php | 12 +----------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index b0c2662e38..804452bf24 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -119,7 +119,7 @@ function ilo_verify_page_metrics_storage_nonce( $nonce, $slug ) { * Unshift a new page metric onto an array of page metrics. * * @param array $page_metrics Page metrics. - * @param array $validated_page_metric Validated page metric. + * @param array $validated_page_metric Validated page metric. See JSON Schema defined in ilo_register_endpoint(). * @return array Updated page metrics. */ function ilo_unshift_page_metrics( $page_metrics, $validated_page_metric ) { diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index 3654cccd1e..5c030941c3 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -124,19 +124,9 @@ function ilo_get_page_metrics_data( $slug ) { /** * Stores page metric by merging it with the other page metrics for a given URL. * - * The $validated_page_metric parameter has the following array shape: - * - * { - * 'viewport': array{ - * 'width': int, - * 'height': int - * }, - * 'elements': array - * } - * * @param string $url URL for the page metrics. This is used purely as metadata. * @param string $slug Page metrics slug (computed from query vars). - * @param array $validated_page_metric Page metric, already validated by REST API. + * @param array $validated_page_metric Validated page metric. See JSON Schema defined in ilo_register_endpoint(). * @return int|WP_Error Post ID or WP_Error otherwise. */ function ilo_store_page_metric( $url, $slug, array $validated_page_metric ) { From ec6e17757c51b558f5bda72c56361af89e52ce4c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 13:17:42 -0800 Subject: [PATCH 074/371] Add array type declarations --- .../images/image-loading-optimization/storage/data.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 804452bf24..73283a4066 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -80,7 +80,7 @@ function ilo_get_normalized_query_vars() { * @param array $query_vars Normalized query vars. * @return string Slug. */ -function ilo_get_page_metrics_slug( $query_vars ) { +function ilo_get_page_metrics_slug( array $query_vars ) { return md5( wp_json_encode( $query_vars ) ); } @@ -122,7 +122,7 @@ function ilo_verify_page_metrics_storage_nonce( $nonce, $slug ) { * @param array $validated_page_metric Validated page metric. See JSON Schema defined in ilo_register_endpoint(). * @return array Updated page metrics. */ -function ilo_unshift_page_metrics( $page_metrics, $validated_page_metric ) { +function ilo_unshift_page_metrics( array $page_metrics, array $validated_page_metric ) { array_unshift( $page_metrics, $validated_page_metric ); $breakpoints = ilo_get_breakpoint_max_widths(); $sample_size = ilo_get_page_metrics_breakpoint_sample_size(); @@ -251,7 +251,7 @@ static function ( $breakpoint ) { * @param int $freshness_ttl Freshness TTL for a page metric. * @return array Array of tuples mapping minimum viewport width to whether page metric(s) are needed. */ -function ilo_get_needed_minimum_viewport_widths( $page_metrics, $current_time, $breakpoint_max_widths, $sample_size, $freshness_ttl ) { +function ilo_get_needed_minimum_viewport_widths( array $page_metrics, $current_time, array $breakpoint_max_widths, $sample_size, $freshness_ttl ) { $metrics_by_breakpoint = ilo_group_page_metrics_by_breakpoint( $page_metrics, $breakpoint_max_widths ); $needed_minimum_viewport_widths = array(); foreach ( $metrics_by_breakpoint as $minimum_viewport_width => $viewport_page_metrics ) { @@ -301,7 +301,7 @@ function ilo_get_needed_minimum_viewport_widths_now_for_slug( $slug ) { * @param array $needed_minimum_viewport_widths Array of tuples mapping minimum viewport width to whether page metric(s) are needed. * @return bool Whether a page metric is needed. */ -function ilo_needs_page_metric_for_breakpoint( $needed_minimum_viewport_widths ) { +function ilo_needs_page_metric_for_breakpoint( array $needed_minimum_viewport_widths ) { foreach ( $needed_minimum_viewport_widths as list( $minimum_viewport_width, $is_needed ) ) { if ( $is_needed ) { return true; From 817d6825b6542f6bb2e0cbfeefaf4e93d00dd504 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 14:11:15 -0800 Subject: [PATCH 075/371] Add PHP type declarations --- .../image-loading-optimization/detection.php | 2 +- .../image-loading-optimization/hooks.php | 10 +++--- .../storage/data.php | 34 +++++++++---------- .../storage/lock.php | 8 ++--- .../storage/post-type.php | 10 +++--- .../storage/rest-api.php | 4 +-- .../image-loading-optimization/load-tests.php | 2 +- 7 files changed, 35 insertions(+), 35 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index a8e014e0f5..d445b1a230 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -13,7 +13,7 @@ /** * Prints the script for detecting loaded images and the LCP element. */ -function ilo_print_detection_script() { +function ilo_print_detection_script() /*: void (in PHP 7.1) */ { if ( ! ilo_can_optimize_response() ) { return; } diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 1f002dab9d..60565a6119 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -28,19 +28,19 @@ * @since n.e.x.t * @link https://core.trac.wordpress.org/ticket/43258 * - * @param mixed $passthrough Optional. Filter value. Default null. - * @return mixed Unmodified value of $passthrough. + * @param string $passthrough Optional. Filter value. Default null. + * @return string Unmodified value of $passthrough. */ -function ilo_buffer_output( $passthrough = null ) { +function ilo_buffer_output( string $passthrough ): string { ob_start( - static function ( $output ) { + static function ( string $output ): string { /** * Filters the template output buffer prior to sending to the client. * * @param string $output Output buffer. * @return string Filtered output buffer. */ - return apply_filters( 'perflab_template_output_buffer', $output ); + return (string) apply_filters( 'perflab_template_output_buffer', $output ); } ); return $passthrough; diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 73283a4066..e91dc17f2a 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -17,7 +17,7 @@ * * @return int Expiration TTL in seconds. */ -function ilo_get_page_metric_freshness_ttl() { +function ilo_get_page_metric_freshness_ttl(): int { /** * Filters the freshness age (TTL) for a given page metric. * @@ -35,7 +35,7 @@ function ilo_get_page_metric_freshness_ttl() { * * @return bool Whether response can be optimized. */ -function ilo_can_optimize_response() { +function ilo_can_optimize_response(): bool { $able = ! is_search(); /** @@ -55,7 +55,7 @@ function ilo_can_optimize_response() { * * @return array Normalized query vars. */ -function ilo_get_normalized_query_vars() { +function ilo_get_normalized_query_vars(): array { global $wp; // Note that the order of this array is naturally normalized since it is @@ -80,7 +80,7 @@ function ilo_get_normalized_query_vars() { * @param array $query_vars Normalized query vars. * @return string Slug. */ -function ilo_get_page_metrics_slug( array $query_vars ) { +function ilo_get_page_metrics_slug( array $query_vars ): string { return md5( wp_json_encode( $query_vars ) ); } @@ -95,7 +95,7 @@ function ilo_get_page_metrics_slug( array $query_vars ) { * @param string $slug Page metrics slug. * @return string Nonce. */ -function ilo_get_page_metrics_storage_nonce( $slug ) { +function ilo_get_page_metrics_storage_nonce( string $slug ): string { return wp_create_nonce( "store_page_metrics:{$slug}" ); } @@ -107,12 +107,12 @@ function ilo_get_page_metrics_storage_nonce( $slug ) { * * @param string $nonce Page metrics storage nonce. * @param string $slug Page metrics slug. - * @return int|false 1 if the nonce is valid and generated between 0-12 hours ago, - * 2 if the nonce is valid and generated between 12-24 hours ago. - * False if the nonce is invalid. + * @return int 1 if the nonce is valid and generated between 0-12 hours ago, + * 2 if the nonce is valid and generated between 12-24 hours ago. + * 0 if the nonce is invalid. */ -function ilo_verify_page_metrics_storage_nonce( $nonce, $slug ) { - return wp_verify_nonce( $nonce, "store_page_metrics:{$slug}" ); +function ilo_verify_page_metrics_storage_nonce( string $nonce, string $slug ): int { + return (int) wp_verify_nonce( $nonce, "store_page_metrics:{$slug}" ); } /** @@ -122,7 +122,7 @@ function ilo_verify_page_metrics_storage_nonce( $nonce, $slug ) { * @param array $validated_page_metric Validated page metric. See JSON Schema defined in ilo_register_endpoint(). * @return array Updated page metrics. */ -function ilo_unshift_page_metrics( array $page_metrics, array $validated_page_metric ) { +function ilo_unshift_page_metrics( array $page_metrics, array $validated_page_metric ): array { array_unshift( $page_metrics, $validated_page_metric ); $breakpoints = ilo_get_breakpoint_max_widths(); $sample_size = ilo_get_page_metrics_breakpoint_sample_size(); @@ -163,7 +163,7 @@ static function ( $a, $b ) { * * @return int[] Breakpoint max widths, sorted in ascending order. */ -function ilo_get_breakpoint_max_widths() { +function ilo_get_breakpoint_max_widths(): array { /** * Filters the breakpoint max widths to group page metrics for various viewports. @@ -190,7 +190,7 @@ static function ( $breakpoint_max_width ) { * * @return int Sample size. */ -function ilo_get_page_metrics_breakpoint_sample_size() { +function ilo_get_page_metrics_breakpoint_sample_size(): int { /** * Filters the sample size for a breakpoint's page metrics on a given URL. * @@ -209,7 +209,7 @@ function ilo_get_page_metrics_breakpoint_sample_size() { * the breakpoints reflect the max inclusive boundaries whereas the return value is the groups of page * metrics with viewports on either side of the breakpoint boundaries. */ -function ilo_group_page_metrics_by_breakpoint( array $page_metrics, array $breakpoints ) { +function ilo_group_page_metrics_by_breakpoint( array $page_metrics, array $breakpoints ): array { // Convert breakpoint max widths into viewport minimum widths. $viewport_minimum_widths = array_map( @@ -251,7 +251,7 @@ static function ( $breakpoint ) { * @param int $freshness_ttl Freshness TTL for a page metric. * @return array Array of tuples mapping minimum viewport width to whether page metric(s) are needed. */ -function ilo_get_needed_minimum_viewport_widths( array $page_metrics, $current_time, array $breakpoint_max_widths, $sample_size, $freshness_ttl ) { +function ilo_get_needed_minimum_viewport_widths( array $page_metrics, float $current_time, array $breakpoint_max_widths, int $sample_size, int $freshness_ttl ): array { $metrics_by_breakpoint = ilo_group_page_metrics_by_breakpoint( $page_metrics, $breakpoint_max_widths ); $needed_minimum_viewport_widths = array(); foreach ( $metrics_by_breakpoint as $minimum_viewport_width => $viewport_page_metrics ) { @@ -285,7 +285,7 @@ function ilo_get_needed_minimum_viewport_widths( array $page_metrics, $current_t * @param string $slug Page metrics slug. * @return array Array of tuples mapping minimum viewport width to whether page metric(s) are needed. */ -function ilo_get_needed_minimum_viewport_widths_now_for_slug( $slug ) { +function ilo_get_needed_minimum_viewport_widths_now_for_slug( string $slug ): array { return ilo_get_needed_minimum_viewport_widths( ilo_get_page_metrics_data( $slug ), microtime( true ), @@ -301,7 +301,7 @@ function ilo_get_needed_minimum_viewport_widths_now_for_slug( $slug ) { * @param array $needed_minimum_viewport_widths Array of tuples mapping minimum viewport width to whether page metric(s) are needed. * @return bool Whether a page metric is needed. */ -function ilo_needs_page_metric_for_breakpoint( array $needed_minimum_viewport_widths ) { +function ilo_needs_page_metric_for_breakpoint( array $needed_minimum_viewport_widths ): bool { foreach ( $needed_minimum_viewport_widths as list( $minimum_viewport_width, $is_needed ) ) { if ( $is_needed ) { return true; diff --git a/modules/images/image-loading-optimization/storage/lock.php b/modules/images/image-loading-optimization/storage/lock.php index f0084b9d38..b9794394f5 100644 --- a/modules/images/image-loading-optimization/storage/lock.php +++ b/modules/images/image-loading-optimization/storage/lock.php @@ -15,7 +15,7 @@ * * @return int TTL in seconds. */ -function ilo_get_page_metric_storage_lock_ttl() { +function ilo_get_page_metric_storage_lock_ttl(): int { /** * Filters how long a given IP is locked from submitting another metric-storage REST API request. @@ -33,7 +33,7 @@ function ilo_get_page_metric_storage_lock_ttl() { * @todo Should the URL be included in the key? Or should a user only be allowed to store one metric? * @return string Transient key. */ -function ilo_get_page_metric_storage_lock_transient_key() { +function ilo_get_page_metric_storage_lock_transient_key(): string { $ip_address = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR']; return 'page_metrics_storage_lock_' . wp_hash( $ip_address ); } @@ -41,7 +41,7 @@ function ilo_get_page_metric_storage_lock_transient_key() { /** * Sets page metric storage lock (for the current IP). */ -function ilo_set_page_metric_storage_lock() { +function ilo_set_page_metric_storage_lock() /*: void (in PHP 7.1) */ { $ttl = ilo_get_page_metric_storage_lock_ttl(); $key = ilo_get_page_metric_storage_lock_transient_key(); if ( 0 === $ttl ) { @@ -56,7 +56,7 @@ function ilo_set_page_metric_storage_lock() { * * @return bool Whether locked. */ -function ilo_is_page_metric_storage_locked() { +function ilo_is_page_metric_storage_locked(): bool { $ttl = ilo_get_page_metric_storage_lock_ttl(); if ( 0 === $ttl ) { return false; diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index 5c030941c3..1e0be232e6 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -17,7 +17,7 @@ * * This the configuration for this post type is similar to the oembed_cache in core. */ -function ilo_register_page_metrics_post_type() { +function ilo_register_page_metrics_post_type() /*: void (in PHP 7.1) */ { register_post_type( ILO_PAGE_METRICS_POST_TYPE, array( @@ -43,7 +43,7 @@ function ilo_register_page_metrics_post_type() { * @param string $slug Page metrics slug. * @return WP_Post|null Post object if exists. */ -function ilo_get_page_metrics_post( $slug ) { +function ilo_get_page_metrics_post( string $slug ) /*: ?WP_Post (in PHP 7.1) */ { $post_query = new WP_Query( array( 'post_type' => ILO_PAGE_METRICS_POST_TYPE, @@ -72,7 +72,7 @@ function ilo_get_page_metrics_post( $slug ) { * @param WP_Post $post Page metrics post. * @return array|WP_Error Page metrics when valid, or WP_Error otherwise. */ -function ilo_parse_stored_page_metrics( WP_Post $post ) { +function ilo_parse_stored_page_metrics( WP_Post $post ) /*: array|WP_Error (in PHP 8) */ { $page_metrics = json_decode( $post->post_content, true ); if ( json_last_error() ) { return new WP_Error( @@ -109,7 +109,7 @@ function ilo_parse_stored_page_metrics( WP_Post $post ) { * @param string $slug Page metrics slug. * @return array Page metrics data, or empty array if invalid. */ -function ilo_get_page_metrics_data( $slug ) { +function ilo_get_page_metrics_data( string $slug ): array { $post = ilo_get_page_metrics_post( $slug ); if ( ! ( $post instanceof WP_Post ) ) { return array(); @@ -129,7 +129,7 @@ function ilo_get_page_metrics_data( $slug ) { * @param array $validated_page_metric Validated page metric. See JSON Schema defined in ilo_register_endpoint(). * @return int|WP_Error Post ID or WP_Error otherwise. */ -function ilo_store_page_metric( $url, $slug, array $validated_page_metric ) { +function ilo_store_page_metric( string $url, string $slug, array $validated_page_metric ) /*: int|WP_Error (in PHP 8) */ { $validated_page_metric['timestamp'] = microtime( true ); // TODO: What about storing a version identifier? diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 9feea484df..71c1f5300f 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -17,7 +17,7 @@ /** * Register endpoint for storage of page metric. */ -function ilo_register_endpoint() { +function ilo_register_endpoint() /*: void (in PHP 7.1) */ { $dom_rect_schema = array( 'type' => 'object', @@ -154,7 +154,7 @@ function ilo_register_endpoint() { * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error Response. */ -function ilo_handle_rest_request( WP_REST_Request $request ) { +function ilo_handle_rest_request( WP_REST_Request $request ) /*: WP_REST_Response|WP_Error (in PHP 8) */ { $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths_now_for_slug( $request->get_param( 'slug' ) ); if ( ! ilo_needs_page_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { return new WP_Error( diff --git a/tests/modules/images/image-loading-optimization/load-tests.php b/tests/modules/images/image-loading-optimization/load-tests.php index 3fb443947d..1015d12b7d 100644 --- a/tests/modules/images/image-loading-optimization/load-tests.php +++ b/tests/modules/images/image-loading-optimization/load-tests.php @@ -42,7 +42,7 @@ function ( $buffer ) use ( $original, $expected ) { ); $original_ob_level = ob_get_level(); - ilo_buffer_output(); + ilo_buffer_output( '' ); $this->assertSame( $original_ob_level + 1, ob_get_level(), 'Expected call to ob_start().' ); echo $original; From caaef872dbd05595a730a91ad9efee2ad47caefd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 18:10:24 -0800 Subject: [PATCH 076/371] Fix placement of ilo_breakpoint_max_widths filter --- .../images/image-loading-optimization/storage/data.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index e91dc17f2a..96f642c0a9 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -165,15 +165,15 @@ static function ( $a, $b ) { */ function ilo_get_breakpoint_max_widths(): array { - /** - * Filters the breakpoint max widths to group page metrics for various viewports. - * - * @param int[] $breakpoint_max_widths Max widths for viewport breakpoints. - */ $breakpoint_max_widths = array_map( static function ( $breakpoint_max_width ) { return (int) $breakpoint_max_width; }, + /** + * Filters the breakpoint max widths to group page metrics for various viewports. + * + * @param int[] $breakpoint_max_widths Max widths for viewport breakpoints. + */ (array) apply_filters( 'ilo_breakpoint_max_widths', array( 480 ) ) ); From eadec5592a8b103e3522fa9f29b75e5b8dcea2e9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 18:11:43 -0800 Subject: [PATCH 077/371] Use 3rd person singular for function phpdoc Co-authored-by: Felix Arntz --- modules/images/image-loading-optimization/storage/data.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 96f642c0a9..b90953e45d 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -85,7 +85,7 @@ function ilo_get_page_metrics_slug( array $query_vars ): string { } /** - * Compute nonce for storing page metrics for a specific slug. + * Computes nonce for storing page metrics for a specific slug. * * This is used in the REST API to authenticate the storage of new page metrics from a given URL. * @@ -100,7 +100,7 @@ function ilo_get_page_metrics_storage_nonce( string $slug ): string { } /** - * Verify nonce for storing page metrics for a specific slug. + * Verifies nonce for storing page metrics for a specific slug. * * @see wp_verify_nonce() * @see ilo_get_page_metrics_storage_nonce() @@ -116,7 +116,7 @@ function ilo_verify_page_metrics_storage_nonce( string $nonce, string $slug ): i } /** - * Unshift a new page metric onto an array of page metrics. + * Unshifts a new page metric onto an array of page metrics. * * @param array $page_metrics Page metrics. * @param array $validated_page_metric Validated page metric. See JSON Schema defined in ilo_register_endpoint(). From 02dbb359fa726ed889475f9cad66bb362f9b36e8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 18:15:25 -0800 Subject: [PATCH 078/371] Use 3rd person singular in phpdoc for additional functions --- modules/images/image-loading-optimization/storage/data.php | 4 ++-- .../images/image-loading-optimization/storage/post-type.php | 4 ++-- .../images/image-loading-optimization/storage/rest-api.php | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index b90953e45d..25d3bca571 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -242,7 +242,7 @@ static function ( $breakpoint ) { } /** - * Get needed minimum viewport widths. + * Gets needed minimum viewport widths. * * @param array $page_metrics Page metrics. * @param float $current_time Current time as returned by microtime(true). @@ -276,7 +276,7 @@ function ilo_get_needed_minimum_viewport_widths( array $page_metrics, float $cur } /** - * Get needed minimum viewport widths by slug for the current time. + * Gets needed minimum viewport widths by slug for the current time. * * This is a convenience wrapper on top of ilo_get_needed_minimum_viewport_widths() to reduce code duplication. * diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index 1e0be232e6..c3a840e240 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -13,7 +13,7 @@ const ILO_PAGE_METRICS_POST_TYPE = 'ilo_page_metrics'; /** - * Register post type for page metrics storage. + * Registers post type for page metrics storage. * * This the configuration for this post type is similar to the oembed_cache in core. */ @@ -38,7 +38,7 @@ function ilo_register_page_metrics_post_type() /*: void (in PHP 7.1) */ { add_action( 'init', 'ilo_register_page_metrics_post_type' ); /** - * Get page metrics post. + * Gets page metrics post. * * @param string $slug Page metrics slug. * @return WP_Post|null Post object if exists. diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 71c1f5300f..9726f04087 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -15,7 +15,7 @@ const ILO_PAGE_METRICS_ROUTE = '/page-metrics'; /** - * Register endpoint for storage of page metric. + * Registers endpoint for storage of page metric. */ function ilo_register_endpoint() /*: void (in PHP 7.1) */ { @@ -149,7 +149,7 @@ function ilo_register_endpoint() /*: void (in PHP 7.1) */ { add_action( 'rest_api_init', 'ilo_register_endpoint' ); /** - * Handle REST API request to store metrics. + * Handles REST API request to store metrics. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error Response. From 06d7fad7f75e02a0f3b2e572ba6d289065515fd1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 18:17:39 -0800 Subject: [PATCH 079/371] Account for isStorageLocked with storageLockTTL of 0 Co-authored-by: Felix Arntz --- modules/images/image-loading-optimization/detection/detect.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 803919f28c..58a0034376 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -15,6 +15,10 @@ const storageLockTimeSessionKey = 'iloStorageLockTime'; * @return {boolean} Whether storage is locked. */ function isStorageLocked( currentTime, storageLockTTL ) { + if ( ! storageLockTTL ) { + return false; + } + try { const storageLockTime = parseInt( sessionStorage.getItem( storageLockTimeSessionKey ) From da0b4208b73694d1e1800cb04178140b9837b094 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 19:55:14 -0800 Subject: [PATCH 080/371] Ensure storage lock TTL is at least zero and add filter example --- .../detection/detect.js | 2 +- .../image-loading-optimization/storage/lock.php | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 58a0034376..96d0587a4b 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -15,7 +15,7 @@ const storageLockTimeSessionKey = 'iloStorageLockTime'; * @return {boolean} Whether storage is locked. */ function isStorageLocked( currentTime, storageLockTTL ) { - if ( ! storageLockTTL ) { + if ( storageLockTTL === 0 ) { return false; } diff --git a/modules/images/image-loading-optimization/storage/lock.php b/modules/images/image-loading-optimization/storage/lock.php index b9794394f5..eb6b21ca68 100644 --- a/modules/images/image-loading-optimization/storage/lock.php +++ b/modules/images/image-loading-optimization/storage/lock.php @@ -13,18 +13,24 @@ /** * Gets the TTL (in seconds) for the page metric storage lock. * - * @return int TTL in seconds. + * @return int TTL in seconds, greater than or equal to zero. */ function ilo_get_page_metric_storage_lock_ttl(): int { /** * Filters how long a given IP is locked from submitting another metric-storage REST API request. * - * Filtering the TTL to zero will disable any metric storage locking. This is useful during development. + * Filtering the TTL to zero will disable any metric storage locking. This is useful, for example, to disable + * locking when a user is logged-in with code like the following: + * + * add_filter( 'ilo_metrics_storage_lock_ttl', static function ( $ttl ) { + * return is_user_logged_in() ? 0 : $ttl; + * } ); * * @param int $ttl TTL. */ - return (int) apply_filters( 'ilo_metrics_storage_lock_ttl', MINUTE_IN_SECONDS ); + $ttl = (int) apply_filters( 'ilo_metrics_storage_lock_ttl', MINUTE_IN_SECONDS ); + return max( 0, $ttl ); } /** @@ -40,6 +46,9 @@ function ilo_get_page_metric_storage_lock_transient_key(): string { /** * Sets page metric storage lock (for the current IP). + * + * If the storage lock TTL is greater than zero, then a transient is set with the current timestamp and expiring at TTL + * seconds. Otherwise, if the current TTL is zero, then any transient is deleted. */ function ilo_set_page_metric_storage_lock() /*: void (in PHP 7.1) */ { $ttl = ilo_get_page_metric_storage_lock_ttl(); From 19ce75252cd1ef2a414e421d1c8a71024a5d1a32 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 20:23:08 -0800 Subject: [PATCH 081/371] Add since and access private tags --- .../image-loading-optimization/detection.php | 3 ++ .../image-loading-optimization/hooks.php | 5 ++- .../storage/data.php | 45 +++++++++++++++++++ .../storage/lock.php | 11 +++++ .../storage/post-type.php | 15 +++++++ .../storage/rest-api.php | 6 +++ 6 files changed, 83 insertions(+), 2 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index d445b1a230..db8b3a658a 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -12,6 +12,9 @@ /** * Prints the script for detecting loaded images and the LCP element. + * + * @since n.e.x.t + * @access private */ function ilo_print_detection_script() /*: void (in PHP 7.1) */ { if ( ! ilo_can_optimize_response() ) { diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 60565a6119..426f20b646 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -17,15 +17,14 @@ * * This is a hack which would eventually be replaced with something like this in wp-includes/template-loader.php: * - * ``` * $template = apply_filters( 'template_include', $template ); * + ob_start( 'wp_template_output_buffer_callback' ); * if ( $template ) { * include $template; * } elseif ( current_user_can( 'switch_themes' ) ) { - * ``` * * @since n.e.x.t + * @access private * @link https://core.trac.wordpress.org/ticket/43258 * * @param string $passthrough Optional. Filter value. Default null. @@ -37,6 +36,8 @@ static function ( string $output ): string { /** * Filters the template output buffer prior to sending to the client. * + * @since n.e.x.t + * * @param string $output Output buffer. * @return string Filtered output buffer. */ diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 25d3bca571..b562d2784e 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -15,12 +15,17 @@ * * When a page metric expires it is eligible to be replaced by a newer one if its viewport lies within the same breakpoint. * + * @since n.e.x.t + * @access private + * * @return int Expiration TTL in seconds. */ function ilo_get_page_metric_freshness_ttl(): int { /** * Filters the freshness age (TTL) for a given page metric. * + * @since n.e.x.t + * * @param int $ttl Expiration TTL in seconds. */ return (int) apply_filters( 'ilo_page_metric_freshness_ttl', DAY_IN_SECONDS ); @@ -33,6 +38,9 @@ function ilo_get_page_metric_freshness_ttl(): int { * 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 { @@ -41,6 +49,8 @@ function ilo_can_optimize_response(): bool { /** * 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 ); @@ -53,6 +63,9 @@ function ilo_can_optimize_response(): bool { * * TODO: For non-singular requests, consider adding the post IDs from The Loop to ensure publishing a new post will invalidate the cache. * + * @since n.e.x.t + * @access private + * * @return array Normalized query vars. */ function ilo_get_normalized_query_vars(): array { @@ -75,6 +88,9 @@ function ilo_get_normalized_query_vars(): array { /** * Gets slug for page metrics. * + * @since n.e.x.t + * @access private + * * @see ilo_get_normalized_query_vars() * * @param array $query_vars Normalized query vars. @@ -89,6 +105,9 @@ function ilo_get_page_metrics_slug( array $query_vars ): string { * * This is used in the REST API to authenticate the storage of new page metrics from a given URL. * + * @since n.e.x.t + * @access private + * * @see wp_create_nonce() * @see ilo_verify_page_metrics_storage_nonce() * @@ -102,6 +121,9 @@ function ilo_get_page_metrics_storage_nonce( string $slug ): string { /** * Verifies nonce for storing page metrics for a specific slug. * + * @since n.e.x.t + * @access private + * * @see wp_verify_nonce() * @see ilo_get_page_metrics_storage_nonce() * @@ -118,6 +140,9 @@ function ilo_verify_page_metrics_storage_nonce( string $nonce, string $slug ): i /** * Unshifts a new page metric onto an array of page metrics. * + * @since n.e.x.t + * @access private + * * @param array $page_metrics Page metrics. * @param array $validated_page_metric Validated page metric. See JSON Schema defined in ilo_register_endpoint(). * @return array Updated page metrics. @@ -161,6 +186,9 @@ static function ( $a, $b ) { * 3. 481-576 (phablets) * 4. >576 (desktop) * + * @since n.e.x.t + * @access private + * * @return int[] Breakpoint max widths, sorted in ascending order. */ function ilo_get_breakpoint_max_widths(): array { @@ -188,12 +216,17 @@ static function ( $breakpoint_max_width ) { * sample size of 3 and there being just a single breakpoint (480) by default, for a given URL, there would be a maximum * total of 6 page metrics stored for a given URL: 3 for mobile and 3 for desktop. * + * @since n.e.x.t + * @access private + * * @return int Sample size. */ function ilo_get_page_metrics_breakpoint_sample_size(): int { /** * Filters the sample size for a breakpoint's page metrics on a given URL. * + * @since n.e.x.t + * * @param int $sample_size Sample size. */ return (int) apply_filters( 'ilo_page_metrics_breakpoint_sample_size', 3 ); @@ -202,6 +235,9 @@ function ilo_get_page_metrics_breakpoint_sample_size(): int { /** * Groups page metrics by breakpoint. * + * @since n.e.x.t + * @access private + * * @param array $page_metrics Page metrics. * @param int[] $breakpoints Viewport breakpoint max widths, sorted in ascending order. * @return array Page metrics grouped by breakpoint. The array keys are the minimum widths for a viewport to lie within @@ -244,6 +280,9 @@ static function ( $breakpoint ) { /** * Gets needed minimum viewport widths. * + * @since n.e.x.t + * @access private + * * @param array $page_metrics Page metrics. * @param float $current_time Current time as returned by microtime(true). * @param int[] $breakpoint_max_widths Breakpoint max widths. @@ -280,6 +319,9 @@ function ilo_get_needed_minimum_viewport_widths( array $page_metrics, float $cur * * This is a convenience wrapper on top of ilo_get_needed_minimum_viewport_widths() to reduce code duplication. * + * @since n.e.x.t + * @access private + * * @see ilo_get_needed_minimum_viewport_widths() * * @param string $slug Page metrics slug. @@ -298,6 +340,9 @@ function ilo_get_needed_minimum_viewport_widths_now_for_slug( string $slug ): ar /** * Checks whether there is a page metric needed for one of the breakpoints. * + * @since n.e.x.t + * @access private + * * @param array $needed_minimum_viewport_widths Array of tuples mapping minimum viewport width to whether page metric(s) are needed. * @return bool Whether a page metric is needed. */ diff --git a/modules/images/image-loading-optimization/storage/lock.php b/modules/images/image-loading-optimization/storage/lock.php index eb6b21ca68..5b9307fdad 100644 --- a/modules/images/image-loading-optimization/storage/lock.php +++ b/modules/images/image-loading-optimization/storage/lock.php @@ -13,6 +13,9 @@ /** * Gets the TTL (in seconds) for the page metric storage lock. * + * @since n.e.x.t + * @access private + * * @return int TTL in seconds, greater than or equal to zero. */ function ilo_get_page_metric_storage_lock_ttl(): int { @@ -27,6 +30,8 @@ function ilo_get_page_metric_storage_lock_ttl(): int { * return is_user_logged_in() ? 0 : $ttl; * } ); * + * @since n.e.x.t + * * @param int $ttl TTL. */ $ttl = (int) apply_filters( 'ilo_metrics_storage_lock_ttl', MINUTE_IN_SECONDS ); @@ -49,6 +54,9 @@ function ilo_get_page_metric_storage_lock_transient_key(): string { * * If the storage lock TTL is greater than zero, then a transient is set with the current timestamp and expiring at TTL * seconds. Otherwise, if the current TTL is zero, then any transient is deleted. + * + * @since n.e.x.t + * @access private */ function ilo_set_page_metric_storage_lock() /*: void (in PHP 7.1) */ { $ttl = ilo_get_page_metric_storage_lock_ttl(); @@ -63,6 +71,9 @@ function ilo_set_page_metric_storage_lock() /*: void (in PHP 7.1) */ { /** * Checks whether page metric storage is locked (for the current IP). * + * @since n.e.x.t + * @access private + * * @return bool Whether locked. */ function ilo_is_page_metric_storage_locked(): bool { diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index c3a840e240..fce053b38d 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -16,6 +16,9 @@ * Registers post type for page metrics storage. * * This the configuration for this post type is similar to the oembed_cache in core. + * + * @since n.e.x.t + * @access private */ function ilo_register_page_metrics_post_type() /*: void (in PHP 7.1) */ { register_post_type( @@ -40,6 +43,9 @@ function ilo_register_page_metrics_post_type() /*: void (in PHP 7.1) */ { /** * Gets page metrics post. * + * @since n.e.x.t + * @access private + * * @param string $slug Page metrics slug. * @return WP_Post|null Post object if exists. */ @@ -69,6 +75,9 @@ function ilo_get_page_metrics_post( string $slug ) /*: ?WP_Post (in PHP 7.1) */ /** * Parses post content in page metrics post. * + * @since n.e.x.t + * @access private + * * @param WP_Post $post Page metrics post. * @return array|WP_Error Page metrics when valid, or WP_Error otherwise. */ @@ -103,6 +112,9 @@ function ilo_parse_stored_page_metrics( WP_Post $post ) /*: array|WP_Error (in P * * This is a convenience abstractions for lower-level functions. * + * @since n.e.x.t + * @access private + * * @see ilo_get_page_metrics_post() * @see ilo_parse_stored_page_metrics() * @@ -124,6 +136,9 @@ function ilo_get_page_metrics_data( string $slug ): array { /** * Stores page metric by merging it with the other page metrics for a given URL. * + * @since n.e.x.t + * @access private + * * @param string $url URL for the page metrics. This is used purely as metadata. * @param string $slug Page metrics slug (computed from query vars). * @param array $validated_page_metric Validated page metric. See JSON Schema defined in ilo_register_endpoint(). diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 9726f04087..8cd9848c22 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -16,6 +16,9 @@ /** * Registers endpoint for storage of page metric. + * + * @since n.e.x.t + * @access private */ function ilo_register_endpoint() /*: void (in PHP 7.1) */ { @@ -151,6 +154,9 @@ function ilo_register_endpoint() /*: void (in PHP 7.1) */ { /** * Handles REST API request to store metrics. * + * @since n.e.x.t + * @access private + * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error Response. */ From 19a2c3b274b553074f212b053c66f13fe665a148 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 14 Nov 2023 20:26:13 -0800 Subject: [PATCH 082/371] Use IMAGE_LOADING_OPTIMIZATION_VERSION constant for script ver arg --- modules/images/image-loading-optimization/detection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index db8b3a658a..47e770e9a9 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -60,7 +60,7 @@ function ilo_print_detection_script() /*: void (in PHP 7.1) */ { wp_print_inline_script_tag( sprintf( 'import detect from %s; detect( %s );', - wp_json_encode( add_query_arg( 'ver', PERFLAB_VERSION, plugin_dir_url( __FILE__ ) . 'detection/detect.js' ) ), + wp_json_encode( add_query_arg( 'ver', IMAGE_LOADING_OPTIMIZATION_VERSION, plugin_dir_url( __FILE__ ) . 'detection/detect.js' ) ), wp_json_encode( $detect_args ) ), array( 'type' => 'module' ) From f566663483409664c0776989df918a9a08635dff Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 15 Nov 2023 09:52:00 -0800 Subject: [PATCH 083/371] Move error emitting to ilo_parse_stored_page_metrics() --- .../storage/data.php | 3 +- .../storage/post-type.php | 57 +++++-------------- 2 files changed, 17 insertions(+), 43 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index b562d2784e..211d1210f0 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -328,8 +328,9 @@ function ilo_get_needed_minimum_viewport_widths( array $page_metrics, float $cur * @return array Array of tuples mapping minimum viewport width to whether page metric(s) are needed. */ function ilo_get_needed_minimum_viewport_widths_now_for_slug( string $slug ): array { + $post = ilo_get_page_metrics_post( $slug ); return ilo_get_needed_minimum_viewport_widths( - ilo_get_page_metrics_data( $slug ), + $post instanceof WP_Post ? ilo_parse_stored_page_metrics( $post ) : array(), microtime( true ), ilo_get_breakpoint_max_widths(), ilo_get_page_metrics_breakpoint_sample_size(), diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index fce053b38d..e712678d2b 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -79,13 +79,19 @@ function ilo_get_page_metrics_post( string $slug ) /*: ?WP_Post (in PHP 7.1) */ * @access private * * @param WP_Post $post Page metrics post. - * @return array|WP_Error Page metrics when valid, or WP_Error otherwise. + * @return array Page metrics. */ -function ilo_parse_stored_page_metrics( WP_Post $post ) /*: array|WP_Error (in PHP 8) */ { +function ilo_parse_stored_page_metrics( WP_Post $post ): array { + $this_function = __FUNCTION__; + $trigger_error = static function ( $error ) use ( $this_function ) { + if ( function_exists( 'wp_trigger_error' ) ) { + wp_trigger_error( $this_function, esc_html( $error ), E_USER_WARNING ); + } + }; + $page_metrics = json_decode( $post->post_content, true ); if ( json_last_error() ) { - return new WP_Error( - 'page_metrics_json_parse_error', + $trigger_error( sprintf( /* translators: 1: Post type slug, 2: JSON error message */ __( 'Contents of %1$s post type not valid JSON: %2$s', 'performance-lab' ), @@ -93,46 +99,20 @@ function ilo_parse_stored_page_metrics( WP_Post $post ) /*: array|WP_Error (in P json_last_error_msg() ) ); - } - if ( ! is_array( $page_metrics ) ) { - return new WP_Error( - 'page_metrics_invalid_data_format', + $page_metrics = array(); + } elseif ( ! is_array( $page_metrics ) ) { + $trigger_error( sprintf( /* translators: %s is post type slug */ __( 'Contents of %s post type was not a JSON array.', 'performance-lab' ), ILO_PAGE_METRICS_POST_TYPE ) ); + $page_metrics = array(); } return $page_metrics; } -/** - * Gets page metrics for a slug. - * - * This is a convenience abstractions for lower-level functions. - * - * @since n.e.x.t - * @access private - * - * @see ilo_get_page_metrics_post() - * @see ilo_parse_stored_page_metrics() - * - * @param string $slug Page metrics slug. - * @return array Page metrics data, or empty array if invalid. - */ -function ilo_get_page_metrics_data( string $slug ): array { - $post = ilo_get_page_metrics_post( $slug ); - if ( ! ( $post instanceof WP_Post ) ) { - return array(); - } - $data = ilo_parse_stored_page_metrics( $post ); - if ( ! is_array( $data ) ) { - return array(); - } - return $data; -} - /** * Stores page metric by merging it with the other page metrics for a given URL. * @@ -157,14 +137,7 @@ function ilo_store_page_metric( string $url, string $slug, array $validated_page if ( $post instanceof WP_Post ) { $post_data['ID'] = $post->ID; $post_data['post_name'] = $post->post_name; - - $page_metrics = ilo_parse_stored_page_metrics( $post ); - if ( $page_metrics instanceof WP_Error ) { - if ( function_exists( 'wp_trigger_error' ) ) { - wp_trigger_error( __FUNCTION__, esc_html( $page_metrics->get_error_message() ) ); - } - $page_metrics = array(); - } + $page_metrics = ilo_parse_stored_page_metrics( $post ); } else { $post_data['post_name'] = $slug; $page_metrics = array(); From b228934d938040485671559145b5044c2b656497 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 15 Nov 2023 11:24:18 -0800 Subject: [PATCH 084/371] Elaborate on return value phpdoc for ilo_get_page_metric_storage_lock_ttl() Co-authored-by: Felix Arntz --- modules/images/image-loading-optimization/storage/lock.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/storage/lock.php b/modules/images/image-loading-optimization/storage/lock.php index 5b9307fdad..97902d4a30 100644 --- a/modules/images/image-loading-optimization/storage/lock.php +++ b/modules/images/image-loading-optimization/storage/lock.php @@ -16,7 +16,7 @@ * @since n.e.x.t * @access private * - * @return int TTL in seconds, greater than or equal to zero. + * @return int TTL in seconds, greater than or equal to zero. A value of zero means that the storage lock should be disabled and thus that transients must not be used. */ function ilo_get_page_metric_storage_lock_ttl(): int { From 95d940603cecfeacbdddc64d04384acce303cc54 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 15 Nov 2023 11:27:11 -0800 Subject: [PATCH 085/371] Remove overkill PHP 7.1+ type declaration comments --- modules/images/image-loading-optimization/detection.php | 2 +- modules/images/image-loading-optimization/storage/lock.php | 2 +- .../images/image-loading-optimization/storage/post-type.php | 6 +++--- .../images/image-loading-optimization/storage/rest-api.php | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 47e770e9a9..90a1ffc461 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -16,7 +16,7 @@ * @since n.e.x.t * @access private */ -function ilo_print_detection_script() /*: void (in PHP 7.1) */ { +function ilo_print_detection_script() { if ( ! ilo_can_optimize_response() ) { return; } diff --git a/modules/images/image-loading-optimization/storage/lock.php b/modules/images/image-loading-optimization/storage/lock.php index 97902d4a30..298689243c 100644 --- a/modules/images/image-loading-optimization/storage/lock.php +++ b/modules/images/image-loading-optimization/storage/lock.php @@ -58,7 +58,7 @@ function ilo_get_page_metric_storage_lock_transient_key(): string { * @since n.e.x.t * @access private */ -function ilo_set_page_metric_storage_lock() /*: void (in PHP 7.1) */ { +function ilo_set_page_metric_storage_lock() { $ttl = ilo_get_page_metric_storage_lock_ttl(); $key = ilo_get_page_metric_storage_lock_transient_key(); if ( 0 === $ttl ) { diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index e712678d2b..bb7f4e9b75 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -20,7 +20,7 @@ * @since n.e.x.t * @access private */ -function ilo_register_page_metrics_post_type() /*: void (in PHP 7.1) */ { +function ilo_register_page_metrics_post_type() { register_post_type( ILO_PAGE_METRICS_POST_TYPE, array( @@ -49,7 +49,7 @@ function ilo_register_page_metrics_post_type() /*: void (in PHP 7.1) */ { * @param string $slug Page metrics slug. * @return WP_Post|null Post object if exists. */ -function ilo_get_page_metrics_post( string $slug ) /*: ?WP_Post (in PHP 7.1) */ { +function ilo_get_page_metrics_post( string $slug ) { $post_query = new WP_Query( array( 'post_type' => ILO_PAGE_METRICS_POST_TYPE, @@ -124,7 +124,7 @@ function ilo_parse_stored_page_metrics( WP_Post $post ): array { * @param array $validated_page_metric Validated page metric. See JSON Schema defined in ilo_register_endpoint(). * @return int|WP_Error Post ID or WP_Error otherwise. */ -function ilo_store_page_metric( string $url, string $slug, array $validated_page_metric ) /*: int|WP_Error (in PHP 8) */ { +function ilo_store_page_metric( string $url, string $slug, array $validated_page_metric ) { $validated_page_metric['timestamp'] = microtime( true ); // TODO: What about storing a version identifier? diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 8cd9848c22..a9c68ef84d 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -20,7 +20,7 @@ * @since n.e.x.t * @access private */ -function ilo_register_endpoint() /*: void (in PHP 7.1) */ { +function ilo_register_endpoint() { $dom_rect_schema = array( 'type' => 'object', @@ -160,7 +160,7 @@ function ilo_register_endpoint() /*: void (in PHP 7.1) */ { * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error Response. */ -function ilo_handle_rest_request( WP_REST_Request $request ) /*: WP_REST_Response|WP_Error (in PHP 8) */ { +function ilo_handle_rest_request( WP_REST_Request $request ) { $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths_now_for_slug( $request->get_param( 'slug' ) ); if ( ! ilo_needs_page_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { return new WP_Error( From 9876aa44911a17fe5cae2843a34f5eb5c15368ef Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 15 Nov 2023 12:03:32 -0800 Subject: [PATCH 086/371] Rename 'page metrics' to 'URL metrics' --- .../image-loading-optimization/detection.php | 12 +- .../detection/detect.js | 36 ++--- .../storage/data.php | 148 +++++++++--------- .../storage/lock.php | 26 +-- .../storage/post-type.php | 68 ++++---- .../storage/rest-api.php | 32 ++-- 6 files changed, 161 insertions(+), 161 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 90a1ffc461..c54229fc86 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -22,12 +22,12 @@ function ilo_print_detection_script() { } $query_vars = ilo_get_normalized_query_vars(); - $slug = ilo_get_page_metrics_slug( $query_vars ); + $slug = ilo_get_url_metrics_slug( $query_vars ); $microtime = microtime( true ); // Abort if we already have all the sample size we need for all breakpoints. $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths_now_for_slug( $slug ); - if ( ! ilo_needs_page_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { + if ( ! ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { return; } @@ -50,12 +50,12 @@ function ilo_print_detection_script() { 'serveTime' => $microtime * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. 'detectionTimeWindow' => $detection_time_window, 'isDebug' => WP_DEBUG, - 'restApiEndpoint' => rest_url( ILO_REST_API_NAMESPACE . ILO_PAGE_METRICS_ROUTE ), + 'restApiEndpoint' => rest_url( ILO_REST_API_NAMESPACE . ILO_URL_METRICS_ROUTE ), 'restApiNonce' => wp_create_nonce( 'wp_rest' ), - 'pageMetricsSlug' => $slug, - 'pageMetricsNonce' => ilo_get_page_metrics_storage_nonce( $slug ), + 'urlMetricsSlug' => $slug, + 'urlMetricsNonce' => ilo_get_url_metrics_storage_nonce( $slug ), 'neededMinimumViewportWidths' => $needed_minimum_viewport_widths, - 'storageLockTTL' => ilo_get_page_metric_storage_lock_ttl(), + 'storageLockTTL' => ilo_get_url_metric_storage_lock_ttl(), ); wp_print_inline_script_tag( sprintf( diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 96d0587a4b..512832e7f2 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -93,7 +93,7 @@ function error( ...message ) { */ /** - * @typedef {Object} PageMetrics + * @typedef {Object} URLMetrics * @property {string} url - URL of the page. * @property {Object} viewport - Viewport. * @property {number} viewport.width - Viewport width. @@ -137,11 +137,11 @@ function getBreadcrumbs( leafElement ) { } /** - * Checks whether the page metric(s) for the provided viewport width is needed. + * Checks whether the URL metric(s) for the provided viewport width is needed. * * @param {number} viewportWidth - Current viewport width. * @param {Array[]} neededMinimumViewportWidths - Needed minimum viewport widths, in ascending order. - * @return {boolean} Whether page metrics are needed. + * @return {boolean} Whether URL metrics are needed. */ function isViewportNeeded( viewportWidth, neededMinimumViewportWidths ) { let lastWasNeeded = false; @@ -176,10 +176,10 @@ function getCurrentTime() { * @param {boolean} args.isDebug Whether to show debug messages. * @param {string} args.restApiEndpoint URL for where to send the detection data. * @param {string} args.restApiNonce Nonce for writing to the REST API. - * @param {string} args.pageMetricsSlug Slug for page metrics. - * @param {string} args.pageMetricsNonce Nonce for page metrics storage. - * @param {Array[]} args.neededMinimumViewportWidths Needed minimum viewport widths for page metrics. - * @param {number} args.storageLockTTL The TTL (in seconds) for the page metric storage lock. + * @param {string} args.urlMetricsSlug Slug for URL metrics. + * @param {string} args.urlMetricsNonce Nonce for URL metrics storage. + * @param {Array[]} args.neededMinimumViewportWidths Needed minimum viewport widths for URL metrics. + * @param {number} args.storageLockTTL The TTL (in seconds) for the URL metric storage lock. */ export default async function detect( { serveTime, @@ -187,15 +187,15 @@ export default async function detect( { isDebug, restApiEndpoint, restApiNonce, - pageMetricsSlug, - pageMetricsNonce, + urlMetricsSlug, + urlMetricsNonce, neededMinimumViewportWidths, storageLockTTL, } ) { const currentTime = getCurrentTime(); // As an alternative to this, the ilo_print_detection_script() function can short-circuit if the - // ilo_is_page_metric_storage_locked() function returns true. However, the downside with that is page caching could + // ilo_is_url_metric_storage_locked() function returns true. However, the downside with that is page caching could // result in metrics being missed being gathered when a user navigates around a site and primes the page cache. if ( isStorageLocked( currentTime, storageLockTTL ) ) { if ( isDebug ) { @@ -227,7 +227,7 @@ export default async function detect( { if ( ! isViewportNeeded( win.innerWidth, neededMinimumViewportWidths ) ) { if ( isDebug ) { - log( 'No need for page metrics from the current viewport.' ); + log( 'No need for URL metrics from the current viewport.' ); } return; } @@ -354,11 +354,11 @@ export default async function detect( { log( 'Detection is stopping.' ); } - /** @type {PageMetrics} */ - const pageMetrics = { + /** @type {URLMetrics} */ + const urlMetrics = { url: win.location.href, - slug: pageMetricsSlug, - nonce: pageMetricsNonce, + slug: urlMetricsSlug, + nonce: urlMetricsNonce, viewport: { width: win.innerWidth, height: win.innerHeight, @@ -396,11 +396,11 @@ export default async function detect( { boundingClientRect: elementIntersection.boundingClientRect, }; - pageMetrics.elements.push( elementMetrics ); + urlMetrics.elements.push( elementMetrics ); } if ( isDebug ) { - log( 'Page metrics:', pageMetrics ); + log( 'URL metrics:', urlMetrics ); } // TODO: Wait until idle? Yield to main? @@ -411,7 +411,7 @@ export default async function detect( { 'Content-Type': 'application/json', 'X-WP-Nonce': restApiNonce, }, - body: JSON.stringify( pageMetrics ), + body: JSON.stringify( urlMetrics ), } ); if ( response.status === 200 ) { diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 211d1210f0..731275d3ea 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -11,24 +11,24 @@ } /** - * Gets the freshness age (TTL) for a given page metric. + * Gets the freshness age (TTL) for a given URL metric. * - * When a page metric expires it is eligible to be replaced by a newer one if its viewport lies within the same breakpoint. + * When a URL metric expires it is eligible to be replaced by a newer one if its viewport lies within the same breakpoint. * * @since n.e.x.t * @access private * * @return int Expiration TTL in seconds. */ -function ilo_get_page_metric_freshness_ttl(): int { +function ilo_get_url_metric_freshness_ttl(): int { /** - * Filters the freshness age (TTL) for a given page metric. + * Filters the freshness age (TTL) for a given URL metric. * * @since n.e.x.t * * @param int $ttl Expiration TTL in seconds. */ - return (int) apply_filters( 'ilo_page_metric_freshness_ttl', DAY_IN_SECONDS ); + return (int) apply_filters( 'ilo_url_metric_freshness_ttl', DAY_IN_SECONDS ); } /** @@ -59,7 +59,7 @@ function ilo_can_optimize_response(): bool { /** * Gets the normalized query vars for the current request. * - * This is used as a cache key for stored page metrics. + * This is used as a cache key for stored URL metrics. * * TODO: For non-singular requests, consider adding the post IDs from The Loop to ensure publishing a new post will invalidate the cache. * @@ -86,7 +86,7 @@ function ilo_get_normalized_query_vars(): array { } /** - * Gets slug for page metrics. + * Gets slug for URL metrics. * * @since n.e.x.t * @access private @@ -96,69 +96,69 @@ function ilo_get_normalized_query_vars(): array { * @param array $query_vars Normalized query vars. * @return string Slug. */ -function ilo_get_page_metrics_slug( array $query_vars ): string { +function ilo_get_url_metrics_slug( array $query_vars ): string { return md5( wp_json_encode( $query_vars ) ); } /** - * Computes nonce for storing page metrics for a specific slug. + * Computes nonce for storing URL metrics for a specific slug. * - * This is used in the REST API to authenticate the storage of new page metrics from a given URL. + * This is used in the REST API to authenticate the storage of new URL metrics from a given URL. * * @since n.e.x.t * @access private * * @see wp_create_nonce() - * @see ilo_verify_page_metrics_storage_nonce() + * @see ilo_verify_url_metrics_storage_nonce() * - * @param string $slug Page metrics slug. + * @param string $slug URL metrics slug. * @return string Nonce. */ -function ilo_get_page_metrics_storage_nonce( string $slug ): string { - return wp_create_nonce( "store_page_metrics:{$slug}" ); +function ilo_get_url_metrics_storage_nonce( string $slug ): string { + return wp_create_nonce( "store_url_metrics:{$slug}" ); } /** - * Verifies nonce for storing page metrics for a specific slug. + * Verifies nonce for storing URL metrics for a specific slug. * * @since n.e.x.t * @access private * * @see wp_verify_nonce() - * @see ilo_get_page_metrics_storage_nonce() + * @see ilo_get_url_metrics_storage_nonce() * - * @param string $nonce Page metrics storage nonce. - * @param string $slug Page metrics slug. + * @param string $nonce URL metrics storage nonce. + * @param string $slug URL metrics slug. * @return int 1 if the nonce is valid and generated between 0-12 hours ago, * 2 if the nonce is valid and generated between 12-24 hours ago. * 0 if the nonce is invalid. */ -function ilo_verify_page_metrics_storage_nonce( string $nonce, string $slug ): int { - return (int) wp_verify_nonce( $nonce, "store_page_metrics:{$slug}" ); +function ilo_verify_url_metrics_storage_nonce( string $nonce, string $slug ): int { + return (int) wp_verify_nonce( $nonce, "store_url_metrics:{$slug}" ); } /** - * Unshifts a new page metric onto an array of page metrics. + * Unshifts a new URL metric onto an array of URL metrics. * * @since n.e.x.t * @access private * - * @param array $page_metrics Page metrics. - * @param array $validated_page_metric Validated page metric. See JSON Schema defined in ilo_register_endpoint(). - * @return array Updated page metrics. + * @param array $url_metrics URL metrics. + * @param array $validated_url_metric Validated URL metric. See JSON Schema defined in ilo_register_endpoint(). + * @return array Updated URL metrics. */ -function ilo_unshift_page_metrics( array $page_metrics, array $validated_page_metric ): array { - array_unshift( $page_metrics, $validated_page_metric ); - $breakpoints = ilo_get_breakpoint_max_widths(); - $sample_size = ilo_get_page_metrics_breakpoint_sample_size(); - $grouped_page_metrics = ilo_group_page_metrics_by_breakpoint( $page_metrics, $breakpoints ); +function ilo_unshift_url_metrics( array $url_metrics, array $validated_url_metric ): array { + 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_page_metrics as &$breakpoint_page_metrics ) { - if ( count( $breakpoint_page_metrics ) > $sample_size ) { + foreach ( $grouped_url_metrics as &$breakpoint_url_metrics ) { + if ( count( $breakpoint_url_metrics ) > $sample_size ) { - // Sort page metrics in descending order by timestamp. + // Sort URL metrics in descending order by timestamp. usort( - $breakpoint_page_metrics, + $breakpoint_url_metrics, static function ( $a, $b ) { if ( ! isset( $a['timestamp'] ) || ! isset( $b['timestamp'] ) ) { return 0; @@ -167,15 +167,15 @@ static function ( $a, $b ) { } ); - $breakpoint_page_metrics = array_slice( $breakpoint_page_metrics, 0, $sample_size ); + $breakpoint_url_metrics = array_slice( $breakpoint_url_metrics, 0, $sample_size ); } } - return array_merge( ...$grouped_page_metrics ); + return array_merge( ...$grouped_url_metrics ); } /** - * Gets the breakpoint max widths to group page metrics for various viewports. + * Gets the breakpoint max widths to group URL metrics for various viewports. * * Each max with represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then * this means there will be two viewport groupings, one for 0<=480, and another >480. If instead there were three @@ -198,7 +198,7 @@ static function ( $breakpoint_max_width ) { return (int) $breakpoint_max_width; }, /** - * Filters the breakpoint max widths to group page metrics for various viewports. + * Filters the breakpoint max widths to group URL metrics for various viewports. * * @param int[] $breakpoint_max_widths Max widths for viewport breakpoints. */ @@ -210,42 +210,42 @@ static function ( $breakpoint_max_width ) { } /** - * Gets the sample size for a breakpoint's page metrics on a given URL. + * Gets the sample size for a breakpoint's URL metrics on a given URL. * - * A breakpoint divides page metrics for viewports which are smaller and those which are larger. Given the default + * A breakpoint divides URL metrics for viewports which are smaller and those which are larger. Given the default * sample size of 3 and there being just a single breakpoint (480) by default, for a given URL, there would be a maximum - * total of 6 page metrics stored for a given URL: 3 for mobile and 3 for desktop. + * total of 6 URL metrics stored for a given URL: 3 for mobile and 3 for desktop. * * @since n.e.x.t * @access private * * @return int Sample size. */ -function ilo_get_page_metrics_breakpoint_sample_size(): int { +function ilo_get_url_metrics_breakpoint_sample_size(): int { /** - * Filters the sample size for a breakpoint's page metrics on a given URL. + * Filters the sample size for a breakpoint's URL metrics on a given URL. * * @since n.e.x.t * * @param int $sample_size Sample size. */ - return (int) apply_filters( 'ilo_page_metrics_breakpoint_sample_size', 3 ); + return (int) apply_filters( 'ilo_url_metrics_breakpoint_sample_size', 3 ); } /** - * Groups page metrics by breakpoint. + * Groups URL metrics by breakpoint. * * @since n.e.x.t * @access private * - * @param array $page_metrics Page metrics. + * @param array $url_metrics URL metrics. * @param int[] $breakpoints Viewport breakpoint max widths, sorted in ascending order. - * @return array Page metrics grouped by breakpoint. The array keys are the minimum widths for a viewport to lie within + * @return array URL metrics grouped by breakpoint. The array keys are the minimum widths for a viewport to lie within * the breakpoint. The returned array is always one larger than the provided array of breakpoints, since * the breakpoints reflect the max inclusive boundaries whereas the return value is the groups of page * metrics with viewports on either side of the breakpoint boundaries. */ -function ilo_group_page_metrics_by_breakpoint( array $page_metrics, array $breakpoints ): array { +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( @@ -257,11 +257,11 @@ static function ( $breakpoint ) { $grouped = array_fill_keys( array_merge( array( 0 ), $viewport_minimum_widths ), array() ); - foreach ( $page_metrics as $page_metric ) { - if ( ! isset( $page_metric['viewport']['width'] ) ) { + foreach ( $url_metrics as $url_metric ) { + if ( ! isset( $url_metric['viewport']['width'] ) ) { continue; } - $viewport_width = $page_metric['viewport']['width']; + $viewport_width = $url_metric['viewport']['width']; $current_minimum_viewport = 0; foreach ( $viewport_minimum_widths as $viewport_minimum_width ) { @@ -272,7 +272,7 @@ static function ( $breakpoint ) { } } - $grouped[ $current_minimum_viewport ][] = $page_metric; + $grouped[ $current_minimum_viewport ][] = $url_metric; } return $grouped; } @@ -283,31 +283,31 @@ static function ( $breakpoint ) { * @since n.e.x.t * @access private * - * @param array $page_metrics Page metrics. + * @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 page metric. - * @return array Array of tuples mapping minimum viewport width to whether page metric(s) are needed. + * @param int $freshness_ttl Freshness TTL for a URL metric. + * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. */ -function ilo_get_needed_minimum_viewport_widths( array $page_metrics, float $current_time, array $breakpoint_max_widths, int $sample_size, int $freshness_ttl ): array { - $metrics_by_breakpoint = ilo_group_page_metrics_by_breakpoint( $page_metrics, $breakpoint_max_widths ); +function ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $current_time, array $breakpoint_max_widths, int $sample_size, int $freshness_ttl ): array { + $metrics_by_breakpoint = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths ); $needed_minimum_viewport_widths = array(); - foreach ( $metrics_by_breakpoint as $minimum_viewport_width => $viewport_page_metrics ) { - $needs_page_metrics = false; - if ( count( $viewport_page_metrics ) < $sample_size ) { - $needs_page_metrics = true; + foreach ( $metrics_by_breakpoint as $minimum_viewport_width => $viewport_url_metrics ) { + $needs_url_metrics = false; + if ( count( $viewport_url_metrics ) < $sample_size ) { + $needs_url_metrics = true; } else { - foreach ( $viewport_page_metrics as $page_metric ) { - if ( isset( $page_metric['timestamp'] ) && $page_metric['timestamp'] + $freshness_ttl < $current_time ) { - $needs_page_metrics = true; + foreach ( $viewport_url_metrics as $url_metric ) { + if ( isset( $url_metric['timestamp'] ) && $url_metric['timestamp'] + $freshness_ttl < $current_time ) { + $needs_url_metrics = true; break; } } } $needed_minimum_viewport_widths[] = array( $minimum_viewport_width, - $needs_page_metrics, + $needs_url_metrics, ); } @@ -324,30 +324,30 @@ function ilo_get_needed_minimum_viewport_widths( array $page_metrics, float $cur * * @see ilo_get_needed_minimum_viewport_widths() * - * @param string $slug Page metrics slug. - * @return array Array of tuples mapping minimum viewport width to whether page metric(s) are needed. + * @param string $slug URL metrics slug. + * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. */ function ilo_get_needed_minimum_viewport_widths_now_for_slug( string $slug ): array { - $post = ilo_get_page_metrics_post( $slug ); + $post = ilo_get_url_metrics_post( $slug ); return ilo_get_needed_minimum_viewport_widths( - $post instanceof WP_Post ? ilo_parse_stored_page_metrics( $post ) : array(), + $post instanceof WP_Post ? ilo_parse_stored_url_metrics( $post ) : array(), microtime( true ), ilo_get_breakpoint_max_widths(), - ilo_get_page_metrics_breakpoint_sample_size(), - ilo_get_page_metric_freshness_ttl() + ilo_get_url_metrics_breakpoint_sample_size(), + ilo_get_url_metric_freshness_ttl() ); } /** - * Checks whether there is a page metric needed for one of the breakpoints. + * Checks whether there is a URL metric needed for one of the breakpoints. * * @since n.e.x.t * @access private * - * @param array $needed_minimum_viewport_widths Array of tuples mapping minimum viewport width to whether page metric(s) are needed. - * @return bool Whether a page metric is needed. + * @param array $needed_minimum_viewport_widths Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. + * @return bool Whether a URL metric is needed. */ -function ilo_needs_page_metric_for_breakpoint( array $needed_minimum_viewport_widths ): bool { +function ilo_needs_url_metric_for_breakpoint( array $needed_minimum_viewport_widths ): bool { foreach ( $needed_minimum_viewport_widths as list( $minimum_viewport_width, $is_needed ) ) { if ( $is_needed ) { return true; diff --git a/modules/images/image-loading-optimization/storage/lock.php b/modules/images/image-loading-optimization/storage/lock.php index 298689243c..d30fe8d75c 100644 --- a/modules/images/image-loading-optimization/storage/lock.php +++ b/modules/images/image-loading-optimization/storage/lock.php @@ -11,14 +11,14 @@ } /** - * Gets the TTL (in seconds) for the page metric storage lock. + * Gets the TTL (in seconds) for the URL metric storage lock. * * @since n.e.x.t * @access private * * @return int TTL in seconds, greater than or equal to zero. A value of zero means that the storage lock should be disabled and thus that transients must not be used. */ -function ilo_get_page_metric_storage_lock_ttl(): int { +function ilo_get_url_metric_storage_lock_ttl(): int { /** * Filters how long a given IP is locked from submitting another metric-storage REST API request. @@ -39,18 +39,18 @@ function ilo_get_page_metric_storage_lock_ttl(): int { } /** - * Gets transient key for locking page metric storage (for the current IP). + * Gets transient key for locking URL metric storage (for the current IP). * * @todo Should the URL be included in the key? Or should a user only be allowed to store one metric? * @return string Transient key. */ -function ilo_get_page_metric_storage_lock_transient_key(): string { +function ilo_get_url_metric_storage_lock_transient_key(): string { $ip_address = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR']; - return 'page_metrics_storage_lock_' . wp_hash( $ip_address ); + return 'url_metrics_storage_lock_' . wp_hash( $ip_address ); } /** - * Sets page metric storage lock (for the current IP). + * Sets URL metric storage lock (for the current IP). * * If the storage lock TTL is greater than zero, then a transient is set with the current timestamp and expiring at TTL * seconds. Otherwise, if the current TTL is zero, then any transient is deleted. @@ -58,9 +58,9 @@ function ilo_get_page_metric_storage_lock_transient_key(): string { * @since n.e.x.t * @access private */ -function ilo_set_page_metric_storage_lock() { - $ttl = ilo_get_page_metric_storage_lock_ttl(); - $key = ilo_get_page_metric_storage_lock_transient_key(); +function ilo_set_url_metric_storage_lock() { + $ttl = ilo_get_url_metric_storage_lock_ttl(); + $key = ilo_get_url_metric_storage_lock_transient_key(); if ( 0 === $ttl ) { delete_transient( $key ); } else { @@ -69,19 +69,19 @@ function ilo_set_page_metric_storage_lock() { } /** - * Checks whether page metric storage is locked (for the current IP). + * Checks whether URL metric storage is locked (for the current IP). * * @since n.e.x.t * @access private * * @return bool Whether locked. */ -function ilo_is_page_metric_storage_locked(): bool { - $ttl = ilo_get_page_metric_storage_lock_ttl(); +function ilo_is_url_metric_storage_locked(): bool { + $ttl = ilo_get_url_metric_storage_lock_ttl(); if ( 0 === $ttl ) { return false; } - $locked_time = get_transient( ilo_get_page_metric_storage_lock_transient_key() ); + $locked_time = get_transient( ilo_get_url_metric_storage_lock_transient_key() ); if ( false === $locked_time ) { return false; } diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index bb7f4e9b75..a95a71176d 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -10,23 +10,23 @@ exit; // Exit if accessed directly. } -const ILO_PAGE_METRICS_POST_TYPE = 'ilo_page_metrics'; +const ILO_URL_METRICS_POST_TYPE = 'ilo_url_metrics'; /** - * Registers post type for page metrics storage. + * Registers post type for URL metrics storage. * * This the configuration for this post type is similar to the oembed_cache in core. * * @since n.e.x.t * @access private */ -function ilo_register_page_metrics_post_type() { +function ilo_register_url_metrics_post_type() { register_post_type( - ILO_PAGE_METRICS_POST_TYPE, + ILO_URL_METRICS_POST_TYPE, array( 'labels' => array( - 'name' => __( 'Page Metrics', 'performance-lab' ), - 'singular_name' => __( 'Page Metrics', 'performance-lab' ), + 'name' => __( 'URL Metrics', 'performance-lab' ), + 'singular_name' => __( 'URL Metrics', 'performance-lab' ), ), 'public' => false, 'hierarchical' => false, @@ -38,21 +38,21 @@ function ilo_register_page_metrics_post_type() { ) ); } -add_action( 'init', 'ilo_register_page_metrics_post_type' ); +add_action( 'init', 'ilo_register_url_metrics_post_type' ); /** - * Gets page metrics post. + * Gets URL metrics post. * * @since n.e.x.t * @access private * - * @param string $slug Page metrics slug. + * @param string $slug URL metrics slug. * @return WP_Post|null Post object if exists. */ -function ilo_get_page_metrics_post( string $slug ) { +function ilo_get_url_metrics_post( string $slug ) { $post_query = new WP_Query( array( - 'post_type' => ILO_PAGE_METRICS_POST_TYPE, + 'post_type' => ILO_URL_METRICS_POST_TYPE, 'post_status' => 'publish', 'name' => $slug, 'posts_per_page' => 1, @@ -73,15 +73,15 @@ function ilo_get_page_metrics_post( string $slug ) { } /** - * Parses post content in page metrics post. + * Parses post content in URL metrics post. * * @since n.e.x.t * @access private * - * @param WP_Post $post Page metrics post. - * @return array Page metrics. + * @param WP_Post $post URL metrics post. + * @return array URL metrics. */ -function ilo_parse_stored_page_metrics( WP_Post $post ): array { +function ilo_parse_stored_url_metrics( WP_Post $post ): array { $this_function = __FUNCTION__; $trigger_error = static function ( $error ) use ( $this_function ) { if ( function_exists( 'wp_trigger_error' ) ) { @@ -89,63 +89,63 @@ function ilo_parse_stored_page_metrics( WP_Post $post ): array { } }; - $page_metrics = json_decode( $post->post_content, true ); + $url_metrics = json_decode( $post->post_content, true ); if ( json_last_error() ) { $trigger_error( sprintf( /* translators: 1: Post type slug, 2: JSON error message */ __( 'Contents of %1$s post type not valid JSON: %2$s', 'performance-lab' ), - ILO_PAGE_METRICS_POST_TYPE, + ILO_URL_METRICS_POST_TYPE, json_last_error_msg() ) ); - $page_metrics = array(); - } elseif ( ! is_array( $page_metrics ) ) { + $url_metrics = array(); + } elseif ( ! is_array( $url_metrics ) ) { $trigger_error( sprintf( /* translators: %s is post type slug */ __( 'Contents of %s post type was not a JSON array.', 'performance-lab' ), - ILO_PAGE_METRICS_POST_TYPE + ILO_URL_METRICS_POST_TYPE ) ); - $page_metrics = array(); + $url_metrics = array(); } - return $page_metrics; + return $url_metrics; } /** - * Stores page metric by merging it with the other page metrics for a given URL. + * Stores URL metric by merging it with the other URL metrics for a given URL. * * @since n.e.x.t * @access private * - * @param string $url URL for the page metrics. This is used purely as metadata. - * @param string $slug Page metrics slug (computed from query vars). - * @param array $validated_page_metric Validated page metric. See JSON Schema defined in ilo_register_endpoint(). + * @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_page_metric( string $url, string $slug, array $validated_page_metric ) { - $validated_page_metric['timestamp'] = microtime( true ); +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( 'post_title' => $url, // TODO: Should we keep this? It can help with debugging. ); - $post = ilo_get_page_metrics_post( $slug ); + $post = ilo_get_url_metrics_post( $slug ); if ( $post instanceof WP_Post ) { $post_data['ID'] = $post->ID; $post_data['post_name'] = $post->post_name; - $page_metrics = ilo_parse_stored_page_metrics( $post ); + $url_metrics = ilo_parse_stored_url_metrics( $post ); } else { $post_data['post_name'] = $slug; - $page_metrics = array(); + $url_metrics = array(); } - $page_metrics = ilo_unshift_page_metrics( $page_metrics, $validated_page_metric ); + $url_metrics = ilo_unshift_url_metrics( $url_metrics, $validated_url_metric ); - $post_data['post_content'] = wp_json_encode( $page_metrics, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // TODO: No need for pretty-printing. + $post_data['post_content'] = wp_json_encode( $url_metrics, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // TODO: No need for pretty-printing. $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); if ( $has_kses ) { @@ -153,7 +153,7 @@ function ilo_store_page_metric( string $url, string $slug, array $validated_page kses_remove_filters(); } - $post_data['post_type'] = ILO_PAGE_METRICS_POST_TYPE; + $post_data['post_type'] = ILO_URL_METRICS_POST_TYPE; $post_data['post_status'] = 'publish'; if ( isset( $post_data['ID'] ) ) { $result = wp_update_post( wp_slash( $post_data ), true ); diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index a9c68ef84d..423355f344 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -12,10 +12,10 @@ const ILO_REST_API_NAMESPACE = 'image-loading-optimization/v1'; -const ILO_PAGE_METRICS_ROUTE = '/page-metrics'; +const ILO_URL_METRICS_ROUTE = '/url-metrics'; /** - * Registers endpoint for storage of page metric. + * Registers endpoint for storage of URL metric. * * @since n.e.x.t * @access private @@ -39,7 +39,7 @@ function ilo_register_endpoint() { register_rest_route( ILO_REST_API_NAMESPACE, - ILO_PAGE_METRICS_ROUTE, + ILO_URL_METRICS_ROUTE, array( 'methods' => 'POST', 'callback' => static function ( WP_REST_Request $request ) { @@ -47,10 +47,10 @@ function ilo_register_endpoint() { }, 'permission_callback' => static function () { // Needs to be available to unauthenticated visitors. - if ( ilo_is_page_metric_storage_locked() ) { + if ( ilo_is_url_metric_storage_locked() ) { return new WP_Error( - 'page_metric_storage_locked', - __( 'Page metric storage is presently locked for the current IP.', 'performance-lab' ), + 'url_metric_storage_locked', + __( 'URL metric storage is presently locked for the current IP.', 'performance-lab' ), array( 'status' => 403 ) ); } @@ -78,8 +78,8 @@ function ilo_register_endpoint() { 'required' => true, 'pattern' => '^[0-9a-f]+$', 'validate_callback' => static function ( $nonce, WP_REST_Request $request ) { - if ( ! ilo_verify_page_metrics_storage_nonce( $nonce, $request->get_param( 'slug' ) ) ) { - return new WP_Error( 'invalid_nonce', __( 'Page metrics nonce verification failure.', 'performance-lab' ) ); + if ( ! ilo_verify_url_metrics_storage_nonce( $nonce, $request->get_param( 'slug' ) ) ) { + return new WP_Error( 'invalid_nonce', __( 'URL metrics nonce verification failure.', 'performance-lab' ) ); } return true; }, @@ -162,21 +162,21 @@ function ilo_register_endpoint() { */ function ilo_handle_rest_request( WP_REST_Request $request ) { $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths_now_for_slug( $request->get_param( 'slug' ) ); - if ( ! ilo_needs_page_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { + if ( ! ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { return new WP_Error( - 'no_page_metric_needed', - __( 'No page metric needed for any of the breakpoints.', 'performance-lab' ), + 'no_url_metric_needed', + __( 'No URL metric needed for any of the breakpoints.', 'performance-lab' ), array( 'status' => 403 ) ); } - ilo_set_page_metric_storage_lock(); - $new_page_metric = wp_array_slice_assoc( $request->get_json_params(), array( 'viewport', 'elements' ) ); + ilo_set_url_metric_storage_lock(); + $new_url_metric = wp_array_slice_assoc( $request->get_json_params(), array( 'viewport', 'elements' ) ); - $result = ilo_store_page_metric( + $result = ilo_store_url_metric( $request->get_param( 'url' ), $request->get_param( 'slug' ), - $new_page_metric + $new_url_metric ); if ( $result instanceof WP_Error ) { @@ -187,7 +187,7 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { array( 'success' => true, 'post_id' => $result, - 'data' => ilo_parse_stored_page_metrics( ilo_get_page_metrics_post( $request->get_param( 'slug' ) ) ), // TODO: Remove this debug data. + 'data' => ilo_parse_stored_url_metrics( ilo_get_url_metrics_post( $request->get_param( 'slug' ) ) ), // TODO: Remove this debug data. ) ); } From 82b6210d897dfec56dbe6d07abf30a023e454d11 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 15 Nov 2023 12:04:12 -0800 Subject: [PATCH 087/371] Remove unnecessary curly braces --- modules/images/image-loading-optimization/storage/data.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 731275d3ea..0f091a324b 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -115,7 +115,7 @@ function ilo_get_url_metrics_slug( array $query_vars ): string { * @return string Nonce. */ function ilo_get_url_metrics_storage_nonce( string $slug ): string { - return wp_create_nonce( "store_url_metrics:{$slug}" ); + return wp_create_nonce( "store_url_metrics:$slug" ); } /** @@ -134,7 +134,7 @@ function ilo_get_url_metrics_storage_nonce( string $slug ): string { * 0 if the nonce is invalid. */ function ilo_verify_url_metrics_storage_nonce( string $nonce, string $slug ): int { - return (int) wp_verify_nonce( $nonce, "store_url_metrics:{$slug}" ); + return (int) wp_verify_nonce( $nonce, "store_url_metrics:$slug" ); } /** From 1168a5a4b383be56b03398492dc5773e669e54c2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 15 Nov 2023 12:06:50 -0800 Subject: [PATCH 088/371] Rename filter to ilo_url_metric_storage_lock_ttl --- modules/images/image-loading-optimization/storage/lock.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/storage/lock.php b/modules/images/image-loading-optimization/storage/lock.php index d30fe8d75c..2d8992e31f 100644 --- a/modules/images/image-loading-optimization/storage/lock.php +++ b/modules/images/image-loading-optimization/storage/lock.php @@ -34,7 +34,7 @@ function ilo_get_url_metric_storage_lock_ttl(): int { * * @param int $ttl TTL. */ - $ttl = (int) apply_filters( 'ilo_metrics_storage_lock_ttl', MINUTE_IN_SECONDS ); + $ttl = (int) apply_filters( 'ilo_url_metric_storage_lock_ttl', MINUTE_IN_SECONDS ); return max( 0, $ttl ); } From 698d89f6b8ce3a0cb90eec3f35eced3b4c22b7ca Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 15 Nov 2023 12:57:22 -0800 Subject: [PATCH 089/371] Follow AIP-136 --- .../storage/rest-api.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 423355f344..aaa7c98500 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -10,9 +10,24 @@ exit; // Exit if accessed directly. } +/** + * Namespace for image-loading-optimization. + * + * @var string + */ const ILO_REST_API_NAMESPACE = 'image-loading-optimization/v1'; -const ILO_URL_METRICS_ROUTE = '/url-metrics'; +/** + * Route for storing a URL metric. + * + * Note the `:store` art of the endpoint follows Google's guidance in AIP-136 for the use of the POST method in a way + * that does not strictly follow the standard usage. Namely, submitting a POST request to this endpoint will either + * create a new `ilo_url_metrics` post, or it will update an existing post if one already exists for the provided slug. + * + * @link https://google.aip.dev/136 + * @var string + */ +const ILO_URL_METRICS_ROUTE = '/url-metrics:store'; /** * Registers endpoint for storage of URL metric. From 47580138d95f9d1e110f6f922ddc397bc473fecb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 15 Nov 2023 10:17:57 -0800 Subject: [PATCH 090/371] Fix prefix for output buffer template filter --- modules/images/image-loading-optimization/hooks.php | 2 +- server-timing/class-perflab-server-timing.php | 2 +- tests/modules/images/image-loading-optimization/load-tests.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/hooks.php b/modules/images/image-loading-optimization/hooks.php index 426f20b646..096588a94f 100644 --- a/modules/images/image-loading-optimization/hooks.php +++ b/modules/images/image-loading-optimization/hooks.php @@ -41,7 +41,7 @@ static function ( string $output ): string { * @param string $output Output buffer. * @return string Filtered output buffer. */ - return (string) apply_filters( 'perflab_template_output_buffer', $output ); + return (string) apply_filters( 'ilo_template_output_buffer', $output ); } ); return $passthrough; diff --git a/server-timing/class-perflab-server-timing.php b/server-timing/class-perflab-server-timing.php index aeb442a764..c718e8f60e 100644 --- a/server-timing/class-perflab-server-timing.php +++ b/server-timing/class-perflab-server-timing.php @@ -230,7 +230,7 @@ public function on_template_include( $passthrough = null ) { // It feels better if this could rather be replaced with add_action( 'shutdown', [ $this, 'send_header' ] ) // However, this does not work because the buffer is sent before the shutdown callback is executed. add_filter( - 'perflab_template_output_buffer', + 'ilo_template_output_buffer', function ( $buffer ) { $this->send_header(); return $buffer; diff --git a/tests/modules/images/image-loading-optimization/load-tests.php b/tests/modules/images/image-loading-optimization/load-tests.php index 1015d12b7d..661d876f8c 100644 --- a/tests/modules/images/image-loading-optimization/load-tests.php +++ b/tests/modules/images/image-loading-optimization/load-tests.php @@ -34,7 +34,7 @@ public function it_buffers_and_filters_output() { ob_start(); add_filter( - 'perflab_template_output_buffer', + 'ilo_template_output_buffer', function ( $buffer ) use ( $original, $expected ) { $this->assertSame( $original, $buffer ); return $expected; From 5fb6fda4cae0b9e9e998ae6d007d6378bf7408e0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 16 Nov 2023 14:11:13 -0800 Subject: [PATCH 091/371] Add small and medium breakpoints from Gutenberg to go along with mobile --- .../image-loading-optimization/storage/data.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 0f091a324b..4603abadea 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -186,8 +186,17 @@ static function ( $a, $b ) { * 3. 481-576 (phablets) * 4. >576 (desktop) * + * The default breakpoints are reused from Gutenberg where the _breakpoints.scss file includes these variables: + * + * $break-medium: 782px; // adminbar goes big + * $break-small: 600px; + * $break-mobile: 480px; + * + * These breakpoints appear to be used the most in media queries that affect frontend styles. + * * @since n.e.x.t * @access private + * @link https://github.com/WordPress/gutenberg/blob/093d52cbfd3e2c140843d3fb91ad3d03330320a5/packages/base-styles/_breakpoints.scss#L11-L13 * * @return int[] Breakpoint max widths, sorted in ascending order. */ @@ -200,9 +209,11 @@ static function ( $breakpoint_max_width ) { /** * Filters the breakpoint max widths to group URL metrics for various viewports. * + * @since n.e.x.t + * * @param int[] $breakpoint_max_widths Max widths for viewport breakpoints. */ - (array) apply_filters( 'ilo_breakpoint_max_widths', array( 480 ) ) + (array) apply_filters( 'ilo_breakpoint_max_widths', array( 480, 600, 782 ) ) ); sort( $breakpoint_max_widths ); From bd1153d88d5305c2251ca08b7c1999cb0060b7fe Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 16 Nov 2023 14:30:42 -0800 Subject: [PATCH 092/371] Add ilo_get_lcp_elements_by_minimum_viewport_widths() --- .../storage/data.php | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 4603abadea..875e304da0 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -288,6 +288,66 @@ static function ( $breakpoint ) { return $grouped; } +/** + * Gets the LCP element for each breakpoint. + * + * The array keys are the minimum viewport width required for the element to be LCP. + * + * @param array $url_metrics URL metrics. + * @param int[] $breakpoint_max_widths Breakpoint max widths. + * @return array LCP elements keyed by its minimum viewport width. + */ +function ilo_get_lcp_elements_by_minimum_viewport_widths( array $url_metrics, array $breakpoint_max_widths ): array { + $grouped_url_metrics = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths ); + + $lcp_element_by_viewport_minimum_width = array(); + foreach ( $grouped_url_metrics as $viewport_minimum_width => $breakpoint_url_metrics ) { + + // The following arrays all share array indices. + $seen_breadcrumbs = array(); + $breadcrumb_counts = array(); + $breadcrumb_element = array(); + + foreach ( $breakpoint_url_metrics as $breakpoint_url_metric ) { + foreach ( $breakpoint_url_metric['elements'] as $element ) { + if ( ! $element['isLCP'] ) { + continue; + } + + $i = array_search( $element['breadcrumbs'], $seen_breadcrumbs, true ); + if ( false === $i ) { + $i = count( $seen_breadcrumbs ); + $seen_breadcrumbs[ $i ] = $element['breadcrumbs']; + $breadcrumb_counts[ $i ] = 0; + } + + $breadcrumb_counts[ $i ] += 1; + $breadcrumb_element[ $i ] = $element; + break; // We found the LCP element for the URL metric, go to the next URL metric. + } + } + + // Now sort by the breadcrumb counts in descending order, so the remaining first key is the most common breadcrumb. + if ( $seen_breadcrumbs ) { + arsort( $breadcrumb_counts ); + $most_common_breadcrumb_index = key( $breadcrumb_counts ); + + $lcp_element_by_viewport_minimum_width[ $viewport_minimum_width ] = $breadcrumb_element[ $most_common_breadcrumb_index ]; + } + } + + // Now we need to merge the breakpoints when there is an LCP element common between them. + $reduced_breadcrumbs = array(); + $last_breadcrumb_element = null; + foreach ( $lcp_element_by_viewport_minimum_width as $viewport_minimum_width => $lcp_element ) { + if ( ! $last_breadcrumb_element || $lcp_element['breadcrumbs'] !== $last_breadcrumb_element['breadcrumbs'] ) { + $reduced_breadcrumbs[ $viewport_minimum_width ] = $lcp_element; + $last_breadcrumb_element = $lcp_element; + } + } + return $reduced_breadcrumbs; +} + /** * Gets needed minimum viewport widths. * From a990982e67095c65feecf720e913a0a59cdf5721 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 16 Nov 2023 14:37:35 -0800 Subject: [PATCH 093/371] Use array_filter() to simplify breakpoint merge --- .../image-loading-optimization/storage/data.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 875e304da0..f37b75daeb 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -337,15 +337,15 @@ function ilo_get_lcp_elements_by_minimum_viewport_widths( array $url_metrics, ar } // Now we need to merge the breakpoints when there is an LCP element common between them. - $reduced_breadcrumbs = array(); - $last_breadcrumb_element = null; - foreach ( $lcp_element_by_viewport_minimum_width as $viewport_minimum_width => $lcp_element ) { - if ( ! $last_breadcrumb_element || $lcp_element['breadcrumbs'] !== $last_breadcrumb_element['breadcrumbs'] ) { - $reduced_breadcrumbs[ $viewport_minimum_width ] = $lcp_element; - $last_breadcrumb_element = $lcp_element; + $last_lcp_element = null; + return array_filter( + $lcp_element_by_viewport_minimum_width, + static function ( $lcp_element ) use ( &$last_lcp_element ) { + $include = ( ! $last_lcp_element || $last_lcp_element['breadcrumbs'] !== $lcp_element['breadcrumbs'] ); + $last_lcp_element = $lcp_element; + return $include; } - } - return $reduced_breadcrumbs; + ); } /** From b18a0c7aba7861030b5f891d31cb18ec951c28e9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 16 Nov 2023 16:03:03 -0800 Subject: [PATCH 094/371] Remove fetchpriority from images when different breakpoints have different LCP images --- .../image-loading-optimization/load.php | 2 + .../optimization.php | 51 +++++++++++++++++++ .../storage/data.php | 2 + 3 files changed, 55 insertions(+) create mode 100644 modules/images/image-loading-optimization/optimization.php diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index 1086e0ed9d..b253f9d8b3 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -24,3 +24,5 @@ require_once __DIR__ . '/storage/rest-api.php'; require_once __DIR__ . '/detection.php'; + +require_once __DIR__ . '/optimization.php'; diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php new file mode 100644 index 0000000000..70a19005ef --- /dev/null +++ b/modules/images/image-loading-optimization/optimization.php @@ -0,0 +1,51 @@ +next_tag( array( 'tag_name' => 'IMG' ) ) ) { + if ( $p->get_attribute( 'fetchpriority' ) ) { + $p->set_attribute( 'data-wp-removed-fetchpriority', $p->get_attribute( 'fetchpriority' ) ); + $p->remove_attribute( 'fetchpriority' ); + } + } + $buffer = $p->get_updated_html(); + } + } + + return $buffer; +} diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index f37b75daeb..7648828d9f 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -293,6 +293,8 @@ static function ( $breakpoint ) { * * The array keys are the minimum viewport width required for the element to be LCP. * + * @TODO: If there is no LCP element at a given breakpoint, make sure to return null? + * * @param array $url_metrics URL metrics. * @param int[] $breakpoint_max_widths Breakpoint max widths. * @return array LCP elements keyed by its minimum viewport width. From 25ea308628ed306309b48569b67b6eb328bb623d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 17 Nov 2023 11:15:40 -0800 Subject: [PATCH 095/371] Update ilo_get_lcp_elements_by_minimum_viewport_widths() to account for URL metrics with no LCP element --- .../storage/data.php | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 7648828d9f..8d529cc0f5 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -291,9 +291,10 @@ 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. - * - * @TODO: If there is no LCP element at a given breakpoint, make sure to return null? + * 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. * * @param array $url_metrics URL metrics. * @param int[] $breakpoint_max_widths Breakpoint max widths. @@ -335,6 +336,8 @@ function ilo_get_lcp_elements_by_minimum_viewport_widths( array $url_metrics, ar $most_common_breadcrumb_index = key( $breadcrumb_counts ); $lcp_element_by_viewport_minimum_width[ $viewport_minimum_width ] = $breadcrumb_element[ $most_common_breadcrumb_index ]; + } elseif ( ! empty( $breakpoint_url_metrics ) ) { + $lcp_element_by_viewport_minimum_width[ $viewport_minimum_width ] = false; // No LCP image at this breakpoint. } } @@ -343,7 +346,22 @@ function ilo_get_lcp_elements_by_minimum_viewport_widths( array $url_metrics, ar return array_filter( $lcp_element_by_viewport_minimum_width, static function ( $lcp_element ) use ( &$last_lcp_element ) { - $include = ( ! $last_lcp_element || $last_lcp_element['breadcrumbs'] !== $lcp_element['breadcrumbs'] ); + $include = ( + // First element in list. + null === $last_lcp_element + || + ( is_array( $last_lcp_element ) && is_array( $lcp_element ) + ? + // This breakpoint and previous breakpoint had LCP element, and they were not the same element. + $last_lcp_element['breadcrumbs'] !== $lcp_element['breadcrumbs'] + : + // This LCP element and the last LCP element were not the same. In this case, either variable may be + // false or an array, but both cannot be an array. If both are false, we don't want to include since + // it is the same. If one is an array and the other is false, then do want to include because this + // indicates a difference at this breakpoint. + $last_lcp_element !== $lcp_element + ) + ); $last_lcp_element = $lcp_element; return $include; } From c88f358d567986611a122f1ad245d87b0cbb2ade Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 17 Nov 2023 16:41:26 -0800 Subject: [PATCH 096/371] WIP: Breadcrumb calculation on server --- .../detection/detect.js | 11 +- .../optimization.php | 217 ++++++++++++++++-- .../storage/rest-api.php | 2 +- 3 files changed, 214 insertions(+), 16 deletions(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 512832e7f2..431ade5a24 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -7,6 +7,8 @@ const consoleLogPrefix = '[Image Loading Optimization]'; const storageLockTimeSessionKey = 'iloStorageLockTime'; +const adminBarId = 'wpadminbar'; + /** * Checks whether storage is locked. * @@ -111,7 +113,12 @@ function getElementIndex( element ) { if ( ! element.parentElement ) { return 0; } - return [ ...element.parentElement.children ].indexOf( element ); + const children = [ ...element.parentElement.children ]; + let index = children.indexOf( element ); + if ( children.includes( document.getElementById( adminBarId ) ) ) { + --index; + } + return index; } /** @@ -238,7 +245,7 @@ export default async function detect( { // Obtain the admin bar element because we don't want to detect elements inside of it. const adminBar = - /** @type {?HTMLDivElement} */ doc.getElementById( 'wpadminbar' ); + /** @type {?HTMLDivElement} */ doc.getElementById( adminBarId ); // We need to capture the original elements and their breadcrumbs as early as possible in case JavaScript is // mutating the DOM from the original HTML rendered by the server, in which case the breadcrumbs obtained from the diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 70a19005ef..8934daf833 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -10,6 +10,70 @@ exit; // Exit if accessed directly. } +/** + * HTML elements that are self-closing. + * + * @link https://www.w3.org/TR/html5/syntax.html#serializing-html-fragments + * @link https://github.com/ampproject/amp-toolbox-php/blob/c79a0fe558a3c042aee4789bbf33376cca7a733d/src/Html/Tag.php#L206-L232 + * + * @var string[] + */ +const ILO_SELF_CLOSING_TAGS = array( + 'AREA', + 'BASE', + 'BASEFONT', + 'BGSOUND', + 'BR', + 'COL', + 'EMBED', + 'FRAME', + 'HR', + 'IMG', + 'INPUT', + 'KEYGEN', + 'LINK', + 'META', + 'PARAM', + 'SOURCE', + 'TRACK', + 'WBR', +); + +/** + * The set of HTML tags whose presence will implicitly close a

element. + * For example '

foo

bar

' should parse the same as '

foo

bar

'. + * + * @link https://www.w3.org/TR/html-markup/p.html + * @link https://github.com/ampproject/amp-toolbox-php/blob/c79a0fe558a3c042aee4789bbf33376cca7a733d/src/Html/Tag.php#L262-L293 + */ +const ILO_P_CLOSING_TAGS = array( + 'ADDRESS', + 'ARTICLE', + 'ASIDE', + 'BLOCKQUOTE', + 'DIR', + 'DL', + 'FIELDSET', + 'FOOTER', + 'FORM', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'HEADER', + 'HR', + 'MENU', + 'NAV', + 'OL', + 'P', + 'PRE', + 'SECTION', + 'TABLE', + 'UL', +); + /** * Adds template output buffer filter for optimization if eligible. */ @@ -21,6 +85,121 @@ function ilo_maybe_add_template_output_buffer_filter() { } add_action( 'wp', 'ilo_maybe_add_template_output_buffer_filter' ); +function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widths ): string { + $minimum_viewport_widths = array_keys( $lcp_images_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 ) { + // No LCP element at this breakpoint, so nothing to preload. + continue; + } + + $media_query = sprintf( 'screen and ( min-width: %dpx )', $minimum_viewport_widths[ $i ] ); + if ( isset( $minimum_viewport_widths[ $i + 1 ] ) ) { + $media_query .= sprintf( ' and ( max-width: %dpx )', $minimum_viewport_widths[ $i + 1 ] - 1 ); + } + + } + + return ''; +} + +function ilo_find_element_by_breadcrumbs( string $html, array $breadcrumbs ): array { + $p = new WP_HTML_Tag_Processor( $html ); + + /* + * The keys for the following two arrays correspond to each other. Given the following document: + * + * + * + * + * + *

Hello!

+ * + * + * + * + * The two upon processing the IMG element, the two arrays should be equal to the following: + * + * $open_stack_tags = array( 'HTML', 'BODY', 'IMG' ); + * $open_stack_indices = array( 0, 1, 1 ); + */ + $open_stack_tags = array(); + $open_stack_indices = array(); + while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { + $tag_name = $p->get_tag(); + if ( ! $p->is_tag_closer() ) { + + // Close an open P tag when a P-closing tag is encountered. + if ( in_array( $tag_name, ILO_P_CLOSING_TAGS, true ) ) { + $i = array_search( 'P', $open_stack_tags, true ); + if ( false !== $i ) { + array_splice( $open_stack_tags, $i ); + array_splice( $open_stack_indices, count( $open_stack_tags ) ); + } + } + + $level = count( $open_stack_tags ); + $open_stack_tags[] = $tag_name; + + if ( ! isset( $open_stack_indices[ $level ] ) ) { + $open_stack_indices[ $level ] = 0; + } elseif ( ! ( 'DIV' === $tag_name && $p->get_attribute( 'id' ) === 'wpadminbar' ) ) { + // Only increment the tag index at this level only if it isn't the admin bar, since the presence of the + // admin bar can throw off the indices. + ++$open_stack_indices[ $level ]; + } + + // TODO: Now check if $open_stack matches breadcrumbs. + + // Immediately pop off self-closing tags. + if ( in_array( $tag_name, ILO_SELF_CLOSING_TAGS, true ) ) { + array_pop( $open_stack_tags ); + } + } else { + // If the closing tag is for self-closing tag, we ignore it since it was already handled above. + if ( in_array( $tag_name, ILO_SELF_CLOSING_TAGS, true ) ) { + 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, $open_stack_tags, true ); + if ( false !== $i ) { + array_splice( $open_stack_tags, $i ); + $did_splice = true; + } + } + + if ( ! $did_splice ) { + $popped_tag_name = array_pop( $open_stack_tags ); + if ( $popped_tag_name !== $tag_name ) { + error_log( "Expected popped tag stack element {$popped_tag_name} to match the currently visited closing tag $tag_name." ); // phpcs:ignore + } + } + array_splice( $open_stack_indices, count( $open_stack_tags ) + 1 ); + } + + // ... + $src = $p->get_attribute( 'src' ); + $srcset = $p->get_attribute( 'srcset' ); + } + + return array(); +} + +function ilo_remove_fetchpriority_from_all_images( string $html ): string { + $p = new WP_HTML_Tag_Processor( $html ); + while ( $p->next_tag( array( 'tag_name' => 'IMG' ) ) ) { + if ( $p->get_attribute( 'fetchpriority' ) ) { + $p->set_attribute( 'data-wp-removed-fetchpriority', $p->get_attribute( 'fetchpriority' ) ); + $p->remove_attribute( 'fetchpriority' ); + } + } + return $p->get_updated_html(); +} + /** * Optimizes template output buffer. * @@ -28,24 +207,36 @@ function ilo_maybe_add_template_output_buffer_filter() { * @return string Filtered template output buffer. */ function ilo_optimize_template_output_buffer( string $buffer ): string { - $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); - $post = ilo_get_url_metrics_post( $slug ); - $page_metrics = ilo_parse_stored_url_metrics( $post ); + $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); + $post = ilo_get_url_metrics_post( $slug ); + $url_metrics = ilo_parse_stored_url_metrics( $post ); - $lcp_images_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $page_metrics, ilo_get_breakpoint_max_widths() ); + $lcp_images_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics, ilo_get_breakpoint_max_widths() ); + // TODO: We need to walk the document to find the breadcrumbs. if ( ! empty( $lcp_images_by_minimum_viewport_widths ) ) { - if ( count( $lcp_images_by_minimum_viewport_widths ) !== 1 ) { - $p = new WP_HTML_Tag_Processor( $buffer ); - while ( $p->next_tag( array( 'tag_name' => 'IMG' ) ) ) { - if ( $p->get_attribute( 'fetchpriority' ) ) { - $p->set_attribute( 'data-wp-removed-fetchpriority', $p->get_attribute( 'fetchpriority' ) ); - $p->remove_attribute( 'fetchpriority' ); - } - } - $buffer = $p->get_updated_html(); + $breakpoint_count_with_lcp_images = count( array_filter( $lcp_images_by_minimum_viewport_widths ) ); + + if ( 1 === count( $lcp_images_by_minimum_viewport_widths ) && 1 === $breakpoint_count_with_lcp_images ) { + // If there is exactly one LCP image for all breakpoints, ensure fetchpriority is set on that image only. + $buffer = ilo_remove_fetchpriority_from_all_images( $buffer ); + + $lcp_element = current( $lcp_images_by_minimum_viewport_widths ); + + } elseif ( 0 === $breakpoint_count_with_lcp_images ) { + // If there are no LCP images, remove fetchpriority from all images. + $buffer = ilo_remove_fetchpriority_from_all_images( $buffer ); + } else { + // Otherwise, there are two or more breakpoints have different LCP images, so we must remove fetchpriority + // from all images and add breakpoint-specific preload links. + $buffer = ilo_remove_fetchpriority_from_all_images( $buffer ); + + // TODO: We need to locate the elements by their breadcrumbs. + ilo_construct_preload_links( $lcp_images_by_minimum_viewport_widths ); + } } return $buffer; } + diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index aaa7c98500..a304eca6fd 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -136,7 +136,7 @@ function ilo_register_endpoint() { 'items' => array( 'type' => 'object', 'properties' => array( - 'tagName' => array( + 'tagName' => array( // TODO: Should this just be 'tag' instead? 'type' => 'string', 'required' => true, 'pattern' => '^[a-zA-Z0-9-]+$', From c20f967239265fe87fd6c4f59964764a2e605dca Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 17 Nov 2023 18:12:55 -0800 Subject: [PATCH 097/371] Set appropriate fetchpriority and otherwise add preload links --- .../detection/detect.js | 9 ++ .../optimization.php | 150 ++++++++++++++---- 2 files changed, 124 insertions(+), 35 deletions(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 431ade5a24..fa33a0b753 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -116,6 +116,15 @@ function getElementIndex( element ) { const children = [ ...element.parentElement.children ]; let index = children.indexOf( element ); if ( children.includes( document.getElementById( adminBarId ) ) ) { + // TODO: Should detection just be turned off when is_user_logged_in()? + --index; + } + if ( + children.includes( + document.querySelector( '.skip-link.screen-reader-text' ) + ) + ) { + // TODO: This is not good. --index; } return index; diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 8934daf833..c3f6b08ac8 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -85,26 +85,66 @@ function ilo_maybe_add_template_output_buffer_filter() { } add_action( 'wp', 'ilo_maybe_add_template_output_buffer_filter' ); +/** + * Constructs preload links. + * + * @param array $lcp_images_by_minimum_viewport_widths LCP images keyed by minimum viewport width, amended with attributes key for the IMG attributes. + * @return string Markup for one or more preload link tags. + */ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widths ): string { + $preload_links = array(); + $minimum_viewport_widths = array_keys( $lcp_images_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 ) { + if ( false === $lcp_element || empty( $lcp_element['attributes'] ) ) { // No LCP element at this breakpoint, so nothing to preload. continue; } + $img_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'] ); + } + + // Add media query. $media_query = sprintf( 'screen and ( min-width: %dpx )', $minimum_viewport_widths[ $i ] ); if ( isset( $minimum_viewport_widths[ $i + 1 ] ) ) { $media_query .= sprintf( ' and ( max-width: %dpx )', $minimum_viewport_widths[ $i + 1 ] - 1 ); } + $img_attributes['media'] = $media_query; + + // Construct preload link. + $link_tag = ' $value ) { + // Map img attribute name to link attribute name. + if ( 'srcset' === $name || 'sizes' === $name ) { + $name = 'image' . $name; + } elseif ( 'src' === $name ) { + $name = 'href'; + } + + $link_tag .= sprintf( ' %s="%s"', $name, esc_attr( $value ) ); + } + $link_tag .= '>'; + $preload_links[] = $link_tag; } - return ''; + return implode( "\n", $preload_links ); } -function ilo_find_element_by_breadcrumbs( string $html, array $breadcrumbs ): array { +/** + * Walks the provided HTML document, invoking the callback at each open tag. + * + * @param string $html Complete HTML document. + * @param callable $open_tag_callback Callback to invoke at each open tag. Callback is passed instance of + * WP_HTML_Tag_Processor as well as the breadcrumbs for the current element. + * @return string Updated HTML if modified by callback. + */ +function ilo_walk_document( string $html, callable $open_tag_callback ): string { $p = new WP_HTML_Tag_Processor( $html ); /* @@ -144,13 +184,28 @@ function ilo_find_element_by_breadcrumbs( string $html, array $breadcrumbs ): ar if ( ! isset( $open_stack_indices[ $level ] ) ) { $open_stack_indices[ $level ] = 0; - } elseif ( ! ( 'DIV' === $tag_name && $p->get_attribute( 'id' ) === 'wpadminbar' ) ) { - // Only increment the tag index at this level only if it isn't the admin bar, since the presence of the - // admin bar can throw off the indices. + } else { ++$open_stack_indices[ $level ]; } - // TODO: Now check if $open_stack matches breadcrumbs. + // TODO: We should consider not collecting metrics when the admin bar is shown and the user is logged-in. + // Only increment the tag index at this level only if it isn't the admin bar, since the presence of the + // admin bar can throw off the indices. + if ( 'DIV' === $tag_name && $p->get_attribute( 'id' ) === 'wpadminbar' ) { + --$open_stack_indices[ $level ]; + } + + // Construct the breadcrumbs to match the format from detect.js. + $breadcrumbs = array(); + foreach ( $open_stack_tags as $i => $breadcrumb_tag_name ) { + $breadcrumbs[] = array( + 'tagName' => $breadcrumb_tag_name, + 'index' => $open_stack_indices[ $i ], + ); + } + + // Invoke the callback to do processing. + $open_tag_callback( $p, $breadcrumbs ); // Immediately pop off self-closing tags. if ( in_array( $tag_name, ILO_SELF_CLOSING_TAGS, true ) ) { @@ -180,24 +235,21 @@ function ilo_find_element_by_breadcrumbs( string $html, array $breadcrumbs ): ar } array_splice( $open_stack_indices, count( $open_stack_tags ) + 1 ); } - - // ... - $src = $p->get_attribute( 'src' ); - $srcset = $p->get_attribute( 'srcset' ); } - return array(); + return $p->get_updated_html(); } -function ilo_remove_fetchpriority_from_all_images( string $html ): string { - $p = new WP_HTML_Tag_Processor( $html ); - while ( $p->next_tag( array( 'tag_name' => 'IMG' ) ) ) { - if ( $p->get_attribute( 'fetchpriority' ) ) { - $p->set_attribute( 'data-wp-removed-fetchpriority', $p->get_attribute( 'fetchpriority' ) ); - $p->remove_attribute( 'fetchpriority' ); - } +/** + * Removes fetchpriority from the current tag if present. + * + * @param WP_HTML_Tag_Processor $p Processor instance. + */ +function ilo_remove_fetchpriority_from_current_tag_processor_node( WP_HTML_Tag_Processor $p ) { + if ( $p->get_attribute( 'fetchpriority' ) ) { + $p->set_attribute( 'data-ilo-removed-fetchpriority', $p->get_attribute( 'fetchpriority' ) ); + $p->remove_attribute( 'fetchpriority' ); } - return $p->get_updated_html(); } /** @@ -213,30 +265,58 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $lcp_images_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics, ilo_get_breakpoint_max_widths() ); - // TODO: We need to walk the document to find the breadcrumbs. if ( ! empty( $lcp_images_by_minimum_viewport_widths ) ) { - $breakpoint_count_with_lcp_images = count( array_filter( $lcp_images_by_minimum_viewport_widths ) ); - - if ( 1 === count( $lcp_images_by_minimum_viewport_widths ) && 1 === $breakpoint_count_with_lcp_images ) { - // If there is exactly one LCP image for all breakpoints, ensure fetchpriority is set on that image only. - $buffer = ilo_remove_fetchpriority_from_all_images( $buffer ); + $breakpoint_lcp_images = array_filter( $lcp_images_by_minimum_viewport_widths ); + // If there is exactly one LCP image for all breakpoints, ensure fetchpriority is set on that image only. + if ( 1 === count( $lcp_images_by_minimum_viewport_widths ) && 1 === count( $breakpoint_lcp_images ) ) { $lcp_element = current( $lcp_images_by_minimum_viewport_widths ); - } elseif ( 0 === $breakpoint_count_with_lcp_images ) { - // If there are no LCP images, remove fetchpriority from all images. - $buffer = ilo_remove_fetchpriority_from_all_images( $buffer ); + $buffer = ilo_walk_document( + $buffer, + static function ( WP_HTML_Tag_Processor $p, array $breadcrumbs ) use ( $lcp_element ) { + if ( 'IMG' !== $p->get_tag() ) { + return; + } + if ( $breadcrumbs === $lcp_element['breadcrumbs'] ) { + $p->set_attribute( 'fetchpriority', 'high' ); + $p->set_attribute( 'data-ilo-added-fetchpriority', true ); + } else { + ilo_remove_fetchpriority_from_current_tag_processor_node( $p ); + } + } + ); + // TODO: We could also add the preload links here. } else { - // Otherwise, there are two or more breakpoints have different LCP images, so we must remove fetchpriority - // from all images and add breakpoint-specific preload links. - $buffer = ilo_remove_fetchpriority_from_all_images( $buffer ); + // If there is not exactly one LCP element, we need to remove fetchpriority from all images while also + // capturing the attributes from the LCP element which we can then use for preload links. + $buffer = ilo_walk_document( + $buffer, + static function ( WP_HTML_Tag_Processor $p, array $breadcrumbs ) use ( &$lcp_images_by_minimum_viewport_widths ) { + if ( 'IMG' !== $p->get_tag() ) { + return; + } + ilo_remove_fetchpriority_from_current_tag_processor_node( $p ); - // TODO: We need to locate the elements by their breadcrumbs. - ilo_construct_preload_links( $lcp_images_by_minimum_viewport_widths ); + // Capture the attributes from the LCP element to use in preload links. + if ( count( $lcp_images_by_minimum_viewport_widths ) > 1 ) { + foreach ( $lcp_images_by_minimum_viewport_widths as &$lcp_element ) { + if ( $lcp_element && $lcp_element['breadcrumbs'] === $breadcrumbs ) { + $lcp_element['attributes'] = array(); + foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { + $lcp_element['attributes'][ $attr_name ] = $p->get_attribute( $attr_name ); + } + } + } + } + } + ); + + $preload_links = ilo_construct_preload_links( $lcp_images_by_minimum_viewport_widths ); + $buffer = str_replace( '', $preload_links . '', $buffer ); } } return $buffer; } - From 08635fa32ae2683baf423f13ace2c2570c3440be Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Nov 2023 16:45:12 -0800 Subject: [PATCH 098/371] Use preg_replace() with limit 1 for injection or preload links at end of head --- modules/images/image-loading-optimization/optimization.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index c3f6b08ac8..a4c22e073b 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -128,12 +128,12 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt $link_tag .= sprintf( ' %s="%s"', $name, esc_attr( $value ) ); } - $link_tag .= '>'; + $link_tag .= ">\n"; $preload_links[] = $link_tag; } - return implode( "\n", $preload_links ); + return implode( '', $preload_links ); } /** @@ -314,7 +314,8 @@ static function ( WP_HTML_Tag_Processor $p, array $breadcrumbs ) use ( &$lcp_ima $preload_links = ilo_construct_preload_links( $lcp_images_by_minimum_viewport_widths ); - $buffer = str_replace( '', $preload_links . '', $buffer ); + // TODO: In the future, WP_HTML_Processor could be used to do this injection. However, given the simple replacement here this is not essential. + $buffer = preg_replace( '#(?=)#', $preload_links, $buffer, 1 ); } } From ec55c9a91358b7b4e4c1ba472a6966e7ff549f85 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Nov 2023 16:46:03 -0800 Subject: [PATCH 099/371] Improve variable naming --- .../image-loading-optimization/storage/data.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 8d529cc0f5..0b00ae225e 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -342,27 +342,27 @@ function ilo_get_lcp_elements_by_minimum_viewport_widths( array $url_metrics, ar } // Now we need to merge the breakpoints when there is an LCP element common between them. - $last_lcp_element = null; + $prev_lcp_element = null; return array_filter( $lcp_element_by_viewport_minimum_width, - static function ( $lcp_element ) use ( &$last_lcp_element ) { + static function ( $lcp_element ) use ( &$prev_lcp_element ) { $include = ( // First element in list. - null === $last_lcp_element + null === $prev_lcp_element || - ( is_array( $last_lcp_element ) && is_array( $lcp_element ) + ( is_array( $prev_lcp_element ) && is_array( $lcp_element ) ? // This breakpoint and previous breakpoint had LCP element, and they were not the same element. - $last_lcp_element['breadcrumbs'] !== $lcp_element['breadcrumbs'] + $prev_lcp_element['breadcrumbs'] !== $lcp_element['breadcrumbs'] : // This LCP element and the last LCP element were not the same. In this case, either variable may be // false or an array, but both cannot be an array. If both are false, we don't want to include since // it is the same. If one is an array and the other is false, then do want to include because this // indicates a difference at this breakpoint. - $last_lcp_element !== $lcp_element + $prev_lcp_element !== $lcp_element ) ); - $last_lcp_element = $lcp_element; + $prev_lcp_element = $lcp_element; return $include; } ); From 6e0f8093bb8b3eefd8fd0766a4902f6a5b1af90c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Nov 2023 16:51:15 -0800 Subject: [PATCH 100/371] Add todos for doing breadcrumbs exclusively on server --- .../images/image-loading-optimization/detection/detect.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index fa33a0b753..4ce20d054e 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -106,6 +106,8 @@ function error( ...message ) { /** * Gets element index among siblings. * + * @todo Eliminate this in favor of doing all breadcrumb generation exclusively on the server. + * * @param {Element} element Element. * @return {number} Index. */ @@ -124,7 +126,6 @@ function getElementIndex( element ) { document.querySelector( '.skip-link.screen-reader-text' ) ) ) { - // TODO: This is not good. --index; } return index; @@ -133,6 +134,8 @@ function getElementIndex( element ) { /** * Gets breadcrumbs for a given element. * + * @todo Eliminate this in favor of doing all breadcrumb generation exclusively on the server. + * * @param {Element} leafElement * @return {Breadcrumb[]} Breadcrumbs. */ @@ -271,6 +274,7 @@ export default async function detect( { /** @type {Map} */ const breadcrumbedElementsMap = new Map( [ ...breadcrumbedImages, ...breadcrumbedElementsWithBackgrounds ].map( + // TODO: Instead of generating breadcrumbs here, rely instead on server-generated breadcrumbs that are added to a data attribute by the server. ( element ) => [ element, getBreadcrumbs( element ) ] ) ); From e4299837e69e2cc3afc23faed257d925b0c2b3fa Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Nov 2023 16:53:40 -0800 Subject: [PATCH 101/371] Account for HEAD closing tag possibly being upper-case --- modules/images/image-loading-optimization/optimization.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index a4c22e073b..69fdcf9188 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -315,7 +315,7 @@ static function ( WP_HTML_Tag_Processor $p, array $breadcrumbs ) use ( &$lcp_ima $preload_links = ilo_construct_preload_links( $lcp_images_by_minimum_viewport_widths ); // TODO: In the future, WP_HTML_Processor could be used to do this injection. However, given the simple replacement here this is not essential. - $buffer = preg_replace( '#(?=)#', $preload_links, $buffer, 1 ); + $buffer = preg_replace( '#(?=)#i', $preload_links, $buffer, 1 ); } } From 8974ac8e734651510fd2d15a34bb38e5ee70bacf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Nov 2023 17:01:04 -0800 Subject: [PATCH 102/371] Disable on Customizer preview and non-GET responses --- .../images/image-loading-optimization/storage/data.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 0b00ae225e..6de16d7828 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -44,7 +44,14 @@ function ilo_get_url_metric_freshness_ttl(): int { * @return bool Whether response can be optimized. */ function ilo_can_optimize_response(): bool { - $able = ! is_search(); + $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. From acde9b4d9e6e4943f52a95b86dc234b16e69a5bc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 12:50:00 -0800 Subject: [PATCH 103/371] Introduce ILO_HTML_Tag_Processor --- .../class-ilo-html-tag-processor.php | 310 ++++++++++++++++++ .../optimization.php | 219 ++----------- 2 files changed, 333 insertions(+), 196 deletions(-) create mode 100644 modules/images/image-loading-optimization/class-ilo-html-tag-processor.php diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php new file mode 100644 index 0000000000..5e170291e0 --- /dev/null +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -0,0 +1,310 @@ + element. + * For example '

foo

bar

' should parse the same as '

foo

bar

'. + * + * @link https://www.w3.org/TR/html-markup/p.html + * @link https://github.com/ampproject/amp-toolbox-php/blob/c79a0fe558a3c042aee4789bbf33376cca7a733d/src/Html/Tag.php#L262-L293 + */ + const P_CLOSING_TAGS = array( + 'ADDRESS', + 'ARTICLE', + 'ASIDE', + 'BLOCKQUOTE', + 'DIR', + 'DL', + 'FIELDSET', + 'FOOTER', + 'FORM', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'HEADER', + 'HR', + 'MENU', + 'NAV', + 'OL', + 'P', + 'PRE', + 'SECTION', + 'TABLE', + 'UL', + ); + + /** + * Open stack tags. + * + * @var string[] + */ + private $open_stack_tags = array(); + + /** + * Open stag indices. + * + * @var int[] + */ + private $open_stack_indices = array(); + + /** + * Processor. + * + * @var WP_HTML_Tag_Processor + */ + private $processor; + + /** + * Constructor. + * + * @param string $html HTML to process. + */ + public function __construct( string $html ) { + $this->processor = new WP_HTML_Tag_Processor( $html ); + } + + /** + * Walk over the document. + * + * Whenever an open tag is encountered, invoke the supplied $open_tag_callback and pass the tag name and breadcrumbs. + * + * @param Closure $open_tag_callback Open tag callback. The processor instance is passed as the sole argument. + */ + public function walk( Closure $open_tag_callback ) { + $p = $this->processor; + + /* + * The keys for the following two arrays correspond to each other. Given the following document: + * + * + * + * + * + *

Hello!

+ * + * + * + * + * The two upon processing the IMG element, the two arrays should be equal to the following: + * + * $open_stack_tags = array( 'HTML', 'BODY', 'IMG' ); + * $open_stack_indices = array( 0, 1, 1 ); + */ + $this->open_stack_tags = array(); + $this->open_stack_indices = array(); + while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { + $tag_name = $p->get_tag(); + if ( ! $p->is_tag_closer() ) { + + // Close an open P tag when a P-closing tag is encountered. + if ( in_array( $tag_name, self::P_CLOSING_TAGS, true ) ) { + $i = array_search( 'P', $this->open_stack_tags, true ); + if ( false !== $i ) { + array_splice( $this->open_stack_tags, $i ); + array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) ); + } + } + + $level = count( $this->open_stack_tags ); + $this->open_stack_tags[] = $tag_name; + + if ( ! isset( $this->open_stack_indices[ $level ] ) ) { + $this->open_stack_indices[ $level ] = 0; + } else { + ++$this->open_stack_indices[ $level ]; + } + + // TODO: We should consider not collecting metrics when the admin bar is shown and the user is logged-in. + // Only increment the tag index at this level only if it isn't the admin bar, since the presence of the + // admin bar can throw off the indices. + if ( 'DIV' === $tag_name && $p->get_attribute( 'id' ) === 'wpadminbar' ) { + --$this->open_stack_indices[ $level ]; + } + + // Invoke the callback to do processing. + $open_tag_callback( $tag_name, $this->get_breadcrumbs() ); + + // Immediately pop off self-closing tags. + if ( in_array( $tag_name, self::SELF_CLOSING_TAGS, true ) ) { + array_pop( $this->open_stack_tags ); + } + } else { + // If the closing tag is for self-closing tag, we ignore it since it was already handled above. + if ( in_array( $tag_name, self::SELF_CLOSING_TAGS, true ) ) { + 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 ) { + error_log( "Expected popped tag stack element $popped_tag_name to match the currently visited closing tag $tag_name." ); // phpcs:ignore + } + } + array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) + 1 ); + } + } + } + + /** + * Returns the uppercase name of the matched tag. + * + * 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. + * + * @see WP_HTML_Tag_Processor::get_tag() + * + * @return string|null Name of currently matched tag in input HTML, or `null` if none found. + */ + public function get_tag() { + return $this->processor->get_tag(); + } + + /** + * Gets breadcrumbs for the current open tag. + * + * Breadcrumbs are constructed to match the format from detect.js. + * + * @return array Breadcrumbs. + */ + public function get_breadcrumbs(): array { + $breadcrumbs = array(); + foreach ( $this->open_stack_tags as $i => $breadcrumb_tag_name ) { + $breadcrumbs[] = array( + 'tagName' => $breadcrumb_tag_name, // TODO: Just 'tag'. + 'index' => $this->open_stack_indices[ $i ], + ); + } + return $breadcrumbs; + } + + /** + * Removes the fetchpriority attribute from the current node being walked over. + * + * Also sets an attribute to indicate that the attribute was removed. + * + * @return bool Whether an attribute was removed. + */ + public function remove_fetchpriority_attribute(): bool { + $p = $this->processor; + if ( $p->get_attribute( 'fetchpriority' ) ) { + $p->set_attribute( 'data-ilo-removed-fetchpriority', $p->get_attribute( 'fetchpriority' ) ); + return $p->remove_attribute( 'fetchpriority' ); + } else { + return false; + } + } + + /** + * Returns the value of a requested attribute from a matched tag opener if that attribute exists. + * + * 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. + * + * @see WP_HTML_Tag_Processor::get_attribute() + * + * @param string $name Name of attribute whose value is requested. + * @return string|true|null Value of attribute or `null` if not available. Boolean attributes return `true`. + */ + public function get_attribute( string $name ) { + return $this->processor->get_attribute( $name ); + } + + /** + * Updates or creates a new attribute on the currently matched tag with the passed value. + * + * 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. + * + * @see WP_HTML_Tag_Processor::set_attribute() + * + * @param string $name The attribute name to target. + * @param string|bool $value The new attribute value. + * @return bool Whether an attribute value was set. + */ + public function set_attribute( string $name, $value ): bool { + return $this->processor->set_attribute( $name, $value ); + } + + /** + * Removes an attribute from the currently-matched tag. + * + * 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. + * + * @see WP_HTML_Tag_Processor::remove_attribute() + * + * @param string $name The attribute name to remove. + * @return bool Whether an attribute was removed. + */ + public function remove_attribute( string $name ): bool { + return $this->processor->remove_attribute( $name ); + } + + /** + * Returns the string representation of the HTML Tag Processor. + * + * 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. + * + * @see WP_HTML_Tag_Processor::get_updated_html() + * + * @return string The processed HTML. + */ + public function get_updated_html(): string { + return $this->processor->get_updated_html(); + } +} diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 69fdcf9188..8abd71df1c 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -10,69 +10,7 @@ exit; // Exit if accessed directly. } -/** - * HTML elements that are self-closing. - * - * @link https://www.w3.org/TR/html5/syntax.html#serializing-html-fragments - * @link https://github.com/ampproject/amp-toolbox-php/blob/c79a0fe558a3c042aee4789bbf33376cca7a733d/src/Html/Tag.php#L206-L232 - * - * @var string[] - */ -const ILO_SELF_CLOSING_TAGS = array( - 'AREA', - 'BASE', - 'BASEFONT', - 'BGSOUND', - 'BR', - 'COL', - 'EMBED', - 'FRAME', - 'HR', - 'IMG', - 'INPUT', - 'KEYGEN', - 'LINK', - 'META', - 'PARAM', - 'SOURCE', - 'TRACK', - 'WBR', -); - -/** - * The set of HTML tags whose presence will implicitly close a

element. - * For example '

foo

bar

' should parse the same as '

foo

bar

'. - * - * @link https://www.w3.org/TR/html-markup/p.html - * @link https://github.com/ampproject/amp-toolbox-php/blob/c79a0fe558a3c042aee4789bbf33376cca7a733d/src/Html/Tag.php#L262-L293 - */ -const ILO_P_CLOSING_TAGS = array( - 'ADDRESS', - 'ARTICLE', - 'ASIDE', - 'BLOCKQUOTE', - 'DIR', - 'DL', - 'FIELDSET', - 'FOOTER', - 'FORM', - 'H1', - 'H2', - 'H3', - 'H4', - 'H5', - 'H6', - 'HEADER', - 'HR', - 'MENU', - 'NAV', - 'OL', - 'P', - 'PRE', - 'SECTION', - 'TABLE', - 'UL', -); +require_once __DIR__ . '/class-ilo-html-tag-processor.php'; /** * Adds template output buffer filter for optimization if eligible. @@ -136,122 +74,6 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt return implode( '', $preload_links ); } -/** - * Walks the provided HTML document, invoking the callback at each open tag. - * - * @param string $html Complete HTML document. - * @param callable $open_tag_callback Callback to invoke at each open tag. Callback is passed instance of - * WP_HTML_Tag_Processor as well as the breadcrumbs for the current element. - * @return string Updated HTML if modified by callback. - */ -function ilo_walk_document( string $html, callable $open_tag_callback ): string { - $p = new WP_HTML_Tag_Processor( $html ); - - /* - * The keys for the following two arrays correspond to each other. Given the following document: - * - * - * - * - * - *

Hello!

- * - * - * - * - * The two upon processing the IMG element, the two arrays should be equal to the following: - * - * $open_stack_tags = array( 'HTML', 'BODY', 'IMG' ); - * $open_stack_indices = array( 0, 1, 1 ); - */ - $open_stack_tags = array(); - $open_stack_indices = array(); - while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { - $tag_name = $p->get_tag(); - if ( ! $p->is_tag_closer() ) { - - // Close an open P tag when a P-closing tag is encountered. - if ( in_array( $tag_name, ILO_P_CLOSING_TAGS, true ) ) { - $i = array_search( 'P', $open_stack_tags, true ); - if ( false !== $i ) { - array_splice( $open_stack_tags, $i ); - array_splice( $open_stack_indices, count( $open_stack_tags ) ); - } - } - - $level = count( $open_stack_tags ); - $open_stack_tags[] = $tag_name; - - if ( ! isset( $open_stack_indices[ $level ] ) ) { - $open_stack_indices[ $level ] = 0; - } else { - ++$open_stack_indices[ $level ]; - } - - // TODO: We should consider not collecting metrics when the admin bar is shown and the user is logged-in. - // Only increment the tag index at this level only if it isn't the admin bar, since the presence of the - // admin bar can throw off the indices. - if ( 'DIV' === $tag_name && $p->get_attribute( 'id' ) === 'wpadminbar' ) { - --$open_stack_indices[ $level ]; - } - - // Construct the breadcrumbs to match the format from detect.js. - $breadcrumbs = array(); - foreach ( $open_stack_tags as $i => $breadcrumb_tag_name ) { - $breadcrumbs[] = array( - 'tagName' => $breadcrumb_tag_name, - 'index' => $open_stack_indices[ $i ], - ); - } - - // Invoke the callback to do processing. - $open_tag_callback( $p, $breadcrumbs ); - - // Immediately pop off self-closing tags. - if ( in_array( $tag_name, ILO_SELF_CLOSING_TAGS, true ) ) { - array_pop( $open_stack_tags ); - } - } else { - // If the closing tag is for self-closing tag, we ignore it since it was already handled above. - if ( in_array( $tag_name, ILO_SELF_CLOSING_TAGS, true ) ) { - 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, $open_stack_tags, true ); - if ( false !== $i ) { - array_splice( $open_stack_tags, $i ); - $did_splice = true; - } - } - - if ( ! $did_splice ) { - $popped_tag_name = array_pop( $open_stack_tags ); - if ( $popped_tag_name !== $tag_name ) { - error_log( "Expected popped tag stack element {$popped_tag_name} to match the currently visited closing tag $tag_name." ); // phpcs:ignore - } - } - array_splice( $open_stack_indices, count( $open_stack_tags ) + 1 ); - } - } - - return $p->get_updated_html(); -} - -/** - * Removes fetchpriority from the current tag if present. - * - * @param WP_HTML_Tag_Processor $p Processor instance. - */ -function ilo_remove_fetchpriority_from_current_tag_processor_node( WP_HTML_Tag_Processor $p ) { - if ( $p->get_attribute( 'fetchpriority' ) ) { - $p->set_attribute( 'data-ilo-removed-fetchpriority', $p->get_attribute( 'fetchpriority' ) ); - $p->remove_attribute( 'fetchpriority' ); - } -} - /** * Optimizes template output buffer. * @@ -272,45 +94,50 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { if ( 1 === count( $lcp_images_by_minimum_viewport_widths ) && 1 === count( $breakpoint_lcp_images ) ) { $lcp_element = current( $lcp_images_by_minimum_viewport_widths ); - $buffer = ilo_walk_document( - $buffer, - static function ( WP_HTML_Tag_Processor $p, array $breadcrumbs ) use ( $lcp_element ) { - if ( 'IMG' !== $p->get_tag() ) { + $processor = new ILO_HTML_Tag_Processor( $buffer ); + $processor->walk( + static function () use ( $processor, $lcp_element ) { + if ( $processor->get_tag() !== 'IMG' ) { return; } - if ( $breadcrumbs === $lcp_element['breadcrumbs'] ) { - $p->set_attribute( 'fetchpriority', 'high' ); - $p->set_attribute( 'data-ilo-added-fetchpriority', true ); + + if ( $processor->get_breadcrumbs() === $lcp_element['breadcrumbs'] ) { + $processor->set_attribute( 'fetchpriority', 'high' ); + $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); } else { - ilo_remove_fetchpriority_from_current_tag_processor_node( $p ); + $processor->remove_fetchpriority_attribute(); } } ); + $buffer = $processor->get_updated_html(); + // TODO: We could also add the preload links here. } else { // If there is not exactly one LCP element, we need to remove fetchpriority from all images while also // capturing the attributes from the LCP element which we can then use for preload links. - $buffer = ilo_walk_document( - $buffer, - static function ( WP_HTML_Tag_Processor $p, array $breadcrumbs ) use ( &$lcp_images_by_minimum_viewport_widths ) { - if ( 'IMG' !== $p->get_tag() ) { + $processor = new ILO_HTML_Tag_Processor( $buffer ); + $processor->walk( + static function () use ( $processor, &$lcp_images_by_minimum_viewport_widths ) { + if ( $processor->get_tag() !== 'IMG' ) { return; } - ilo_remove_fetchpriority_from_current_tag_processor_node( $p ); - // Capture the attributes from the LCP element to use in preload links. - if ( count( $lcp_images_by_minimum_viewport_widths ) > 1 ) { + $processor->remove_fetchpriority_attribute(); + + // Capture the attributes from the LCP elements to use in preload links. + if ( count( $lcp_images_by_minimum_viewport_widths ) > 1 ) { // TODO: Why? foreach ( $lcp_images_by_minimum_viewport_widths as &$lcp_element ) { - if ( $lcp_element && $lcp_element['breadcrumbs'] === $breadcrumbs ) { + if ( $lcp_element && $lcp_element['breadcrumbs'] === $processor->get_breadcrumbs() ) { $lcp_element['attributes'] = array(); foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { - $lcp_element['attributes'][ $attr_name ] = $p->get_attribute( $attr_name ); + $lcp_element['attributes'][ $attr_name ] = $processor->get_attribute( $attr_name ); } } } } } ); + $buffer = $processor->get_updated_html(); $preload_links = ilo_construct_preload_links( $lcp_images_by_minimum_viewport_widths ); From 024719d0fec1be69e769a9b2eb23fb074c081169 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 13:40:11 -0800 Subject: [PATCH 104/371] Allow callable not just Closure --- .../class-ilo-html-tag-processor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 5e170291e0..ed6b0e3c55 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -112,9 +112,9 @@ public function __construct( string $html ) { * * Whenever an open tag is encountered, invoke the supplied $open_tag_callback and pass the tag name and breadcrumbs. * - * @param Closure $open_tag_callback Open tag callback. The processor instance is passed as the sole argument. + * @param callable $open_tag_callback Open tag callback. The processor instance is passed as the sole argument. */ - public function walk( Closure $open_tag_callback ) { + public function walk( callable $open_tag_callback ) { $p = $this->processor; /* From 38cb821ffc694856631de3e3c759e569d5361bc1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 15:06:55 -0800 Subject: [PATCH 105/371] Prevent error when no ilo_url_metrics post --- modules/images/image-loading-optimization/optimization.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 8abd71df1c..5d81a51b1b 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -83,7 +83,7 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt function ilo_optimize_template_output_buffer( string $buffer ): string { $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); $post = ilo_get_url_metrics_post( $slug ); - $url_metrics = ilo_parse_stored_url_metrics( $post ); + $url_metrics = $post ? ilo_parse_stored_url_metrics( $post ) : array(); // TODO: If $post is null, short circuit? $lcp_images_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics, ilo_get_breakpoint_max_widths() ); From 234e2fed0ca7841d78e21e8444d1867895d218be Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 15:08:15 -0800 Subject: [PATCH 106/371] Clarify logic in ilo_optimize_template_output_buffer --- .../optimization.php | 32 +++++++++++-------- .../storage/data.php | 2 +- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 5d81a51b1b..6bea0eb455 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -85,14 +85,19 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $post = ilo_get_url_metrics_post( $slug ); $url_metrics = $post ? ilo_parse_stored_url_metrics( $post ) : array(); // TODO: If $post is null, short circuit? - $lcp_images_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics, ilo_get_breakpoint_max_widths() ); + $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics, ilo_get_breakpoint_max_widths() ); - if ( ! empty( $lcp_images_by_minimum_viewport_widths ) ) { - $breakpoint_lcp_images = array_filter( $lcp_images_by_minimum_viewport_widths ); + if ( ! empty( $lcp_elements_by_minimum_viewport_widths ) ) { + // TODO: What if we just don't have enough data for the other breakpoints yet? That is if count(ilo_group_url_metrics_by_breakpoint) !== count($breakpoint_max_widths)+1. // If there is exactly one LCP image for all breakpoints, ensure fetchpriority is set on that image only. - if ( 1 === count( $lcp_images_by_minimum_viewport_widths ) && 1 === count( $breakpoint_lcp_images ) ) { - $lcp_element = current( $lcp_images_by_minimum_viewport_widths ); + if ( + // All breakpoints share the same LCP element (or all have none at all). + 1 === count( $lcp_elements_by_minimum_viewport_widths ) && + // The breakpoints don't share a common lack of an LCP element. + ! in_array( false, $lcp_elements_by_minimum_viewport_widths, true ) + ) { + $lcp_element = current( $lcp_elements_by_minimum_viewport_widths ); $processor = new ILO_HTML_Tag_Processor( $buffer ); $processor->walk( @@ -102,6 +107,7 @@ static function () use ( $processor, $lcp_element ) { } if ( $processor->get_breadcrumbs() === $lcp_element['breadcrumbs'] ) { + // TODO: If it already has the attribute, include an attribute to indicate server-side heuristics were successful. $processor->set_attribute( 'fetchpriority', 'high' ); $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); } else { @@ -117,7 +123,7 @@ static function () use ( $processor, $lcp_element ) { // capturing the attributes from the LCP element which we can then use for preload links. $processor = new ILO_HTML_Tag_Processor( $buffer ); $processor->walk( - static function () use ( $processor, &$lcp_images_by_minimum_viewport_widths ) { + static function () use ( $processor, &$lcp_elements_by_minimum_viewport_widths ) { if ( $processor->get_tag() !== 'IMG' ) { return; } @@ -125,13 +131,11 @@ static function () use ( $processor, &$lcp_images_by_minimum_viewport_widths ) { $processor->remove_fetchpriority_attribute(); // Capture the attributes from the LCP elements to use in preload links. - if ( count( $lcp_images_by_minimum_viewport_widths ) > 1 ) { // TODO: Why? - foreach ( $lcp_images_by_minimum_viewport_widths as &$lcp_element ) { - if ( $lcp_element && $lcp_element['breadcrumbs'] === $processor->get_breadcrumbs() ) { - $lcp_element['attributes'] = array(); - foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { - $lcp_element['attributes'][ $attr_name ] = $processor->get_attribute( $attr_name ); - } + foreach ( $lcp_elements_by_minimum_viewport_widths as &$lcp_element ) { + if ( $lcp_element && $lcp_element['breadcrumbs'] === $processor->get_breadcrumbs() ) { + $lcp_element['attributes'] = array(); + foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { + $lcp_element['attributes'][ $attr_name ] = $processor->get_attribute( $attr_name ); } } } @@ -139,7 +143,7 @@ static function () use ( $processor, &$lcp_images_by_minimum_viewport_widths ) { ); $buffer = $processor->get_updated_html(); - $preload_links = ilo_construct_preload_links( $lcp_images_by_minimum_viewport_widths ); + $preload_links = ilo_construct_preload_links( $lcp_elements_by_minimum_viewport_widths ); // TODO: In the future, WP_HTML_Processor could be used to do this injection. However, given the simple replacement here this is not essential. $buffer = preg_replace( '#(?=)#i', $preload_links, $buffer, 1 ); diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 6de16d7828..33139a2f1b 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -305,7 +305,7 @@ static function ( $breakpoint ) { * * @param array $url_metrics URL metrics. * @param int[] $breakpoint_max_widths Breakpoint max widths. - * @return array LCP elements keyed by its minimum viewport width. + * @return array LCP elements keyed by its minimum viewport width. If there is no LCP element at a breakpoint, then `false` is used. */ function ilo_get_lcp_elements_by_minimum_viewport_widths( array $url_metrics, array $breakpoint_max_widths ): array { $grouped_url_metrics = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths ); From 1585a7868272a47358ba7ee3f186dd5dd74b6332 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 15:10:55 -0800 Subject: [PATCH 107/371] Set attribute when server-side heuristics were correct --- .../images/image-loading-optimization/optimization.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 6bea0eb455..dcd2becb0d 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -107,9 +107,12 @@ static function () use ( $processor, $lcp_element ) { } if ( $processor->get_breadcrumbs() === $lcp_element['breadcrumbs'] ) { - // TODO: If it already has the attribute, include an attribute to indicate server-side heuristics were successful. - $processor->set_attribute( 'fetchpriority', 'high' ); - $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); + if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { + $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); + } else { + $processor->set_attribute( 'fetchpriority', 'high' ); + $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); + } } else { $processor->remove_fetchpriority_attribute(); } From 292d1c4eecbf0956332996123be487c5053eebcc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 15:27:21 -0800 Subject: [PATCH 108/371] Prevent setting fetchpriority on IMG when not all breakpoints have data yet collected --- .../optimization.php | 26 ++++++++++++++----- .../storage/data.php | 6 ++--- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index dcd2becb0d..0a02a7bd6d 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -81,21 +81,33 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt * @return string Filtered template output buffer. */ function ilo_optimize_template_output_buffer( string $buffer ): string { - $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); - $post = ilo_get_url_metrics_post( $slug ); - $url_metrics = $post ? ilo_parse_stored_url_metrics( $post ) : array(); // TODO: If $post is null, short circuit? + $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); + $post = ilo_get_url_metrics_post( $slug ); - $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics, ilo_get_breakpoint_max_widths() ); + // No URL metrics are present, so there's nothing we can do. + if ( ! $post ) { + return $buffer; + } + + $url_metrics = ilo_parse_stored_url_metrics( $post ); + + $breakpoint_max_widths = ilo_get_breakpoint_max_widths(); + $url_metrics_grouped_by_breakpoint = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths ); + $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics_grouped_by_breakpoint ); if ( ! empty( $lcp_elements_by_minimum_viewport_widths ) ) { - // TODO: What if we just don't have enough data for the other breakpoints yet? That is if count(ilo_group_url_metrics_by_breakpoint) !== count($breakpoint_max_widths)+1. - // If there is exactly one LCP image for all breakpoints, ensure fetchpriority is set on that image only. + // TODO: Handle case when the LCP element is not an image at all, but rather a background-image. + // Use the fetchpriority attribute on the image when all breakpoints have the same LCP element. if ( // All breakpoints share the same LCP element (or all have none at all). - 1 === count( $lcp_elements_by_minimum_viewport_widths ) && + 1 === count( $lcp_elements_by_minimum_viewport_widths ) + && // The breakpoints don't share a common lack of an LCP element. ! in_array( false, $lcp_elements_by_minimum_viewport_widths, true ) + && + // All breakpoints have URL metrics being reported. + count( array_filter( $url_metrics_grouped_by_breakpoint ) ) === count( $breakpoint_max_widths ) + 1 ) { $lcp_element = current( $lcp_elements_by_minimum_viewport_widths ); diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 33139a2f1b..331033ca77 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -303,12 +303,10 @@ static function ( $breakpoint ) { * 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. * - * @param array $url_metrics URL metrics. - * @param int[] $breakpoint_max_widths Breakpoint max widths. + * @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. */ -function ilo_get_lcp_elements_by_minimum_viewport_widths( array $url_metrics, array $breakpoint_max_widths ): array { - $grouped_url_metrics = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths ); +function ilo_get_lcp_elements_by_minimum_viewport_widths( array $grouped_url_metrics ): array { $lcp_element_by_viewport_minimum_width = array(); foreach ( $grouped_url_metrics as $viewport_minimum_width => $breakpoint_url_metrics ) { From fca7f8d07b20d92a56f4d6e40156997b0706da16 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 15:28:36 -0800 Subject: [PATCH 109/371] Remove needless if statement --- .../optimization.php | 111 +++++++++--------- 1 file changed, 54 insertions(+), 57 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 0a02a7bd6d..7fd6597be7 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -95,74 +95,71 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $url_metrics_grouped_by_breakpoint = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths ); $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics_grouped_by_breakpoint ); - if ( ! empty( $lcp_elements_by_minimum_viewport_widths ) ) { - - // TODO: Handle case when the LCP element is not an image at all, but rather a background-image. - // Use the fetchpriority attribute on the image when all breakpoints have the same LCP element. - if ( - // All breakpoints share the same LCP element (or all have none at all). - 1 === count( $lcp_elements_by_minimum_viewport_widths ) - && - // The breakpoints don't share a common lack of an LCP element. - ! in_array( false, $lcp_elements_by_minimum_viewport_widths, true ) - && - // All breakpoints have URL metrics being reported. - count( array_filter( $url_metrics_grouped_by_breakpoint ) ) === count( $breakpoint_max_widths ) + 1 - ) { - $lcp_element = current( $lcp_elements_by_minimum_viewport_widths ); - - $processor = new ILO_HTML_Tag_Processor( $buffer ); - $processor->walk( - static function () use ( $processor, $lcp_element ) { - if ( $processor->get_tag() !== 'IMG' ) { - return; - } + // TODO: Handle case when the LCP element is not an image at all, but rather a background-image. + // Use the fetchpriority attribute on the image when all breakpoints have the same LCP element. + if ( + // All breakpoints share the same LCP element (or all have none at all). + 1 === count( $lcp_elements_by_minimum_viewport_widths ) + && + // The breakpoints don't share a common lack of an LCP element. + ! in_array( false, $lcp_elements_by_minimum_viewport_widths, true ) + && + // All breakpoints have URL metrics being reported. + count( array_filter( $url_metrics_grouped_by_breakpoint ) ) === count( $breakpoint_max_widths ) + 1 + ) { + $lcp_element = current( $lcp_elements_by_minimum_viewport_widths ); + + $processor = new ILO_HTML_Tag_Processor( $buffer ); + $processor->walk( + static function () use ( $processor, $lcp_element ) { + if ( $processor->get_tag() !== 'IMG' ) { + return; + } - if ( $processor->get_breadcrumbs() === $lcp_element['breadcrumbs'] ) { - if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { - $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); - } else { - $processor->set_attribute( 'fetchpriority', 'high' ); - $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); - } + if ( $processor->get_breadcrumbs() === $lcp_element['breadcrumbs'] ) { + if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { + $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); } else { - $processor->remove_fetchpriority_attribute(); + $processor->set_attribute( 'fetchpriority', 'high' ); + $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); } + } else { + $processor->remove_fetchpriority_attribute(); + } + } + ); + $buffer = $processor->get_updated_html(); + + // TODO: We could also add the preload links here. + } else { + // If there is not exactly one LCP element, we need to remove fetchpriority from all images while also + // capturing the attributes from the LCP element which we can then use for preload links. + $processor = new ILO_HTML_Tag_Processor( $buffer ); + $processor->walk( + static function () use ( $processor, &$lcp_elements_by_minimum_viewport_widths ) { + if ( $processor->get_tag() !== 'IMG' ) { + return; } - ); - $buffer = $processor->get_updated_html(); - - // TODO: We could also add the preload links here. - } else { - // If there is not exactly one LCP element, we need to remove fetchpriority from all images while also - // capturing the attributes from the LCP element which we can then use for preload links. - $processor = new ILO_HTML_Tag_Processor( $buffer ); - $processor->walk( - static function () use ( $processor, &$lcp_elements_by_minimum_viewport_widths ) { - if ( $processor->get_tag() !== 'IMG' ) { - return; - } - $processor->remove_fetchpriority_attribute(); + $processor->remove_fetchpriority_attribute(); - // Capture the attributes from the LCP elements to use in preload links. - foreach ( $lcp_elements_by_minimum_viewport_widths as &$lcp_element ) { - if ( $lcp_element && $lcp_element['breadcrumbs'] === $processor->get_breadcrumbs() ) { - $lcp_element['attributes'] = array(); - foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { - $lcp_element['attributes'][ $attr_name ] = $processor->get_attribute( $attr_name ); - } + // Capture the attributes from the LCP elements to use in preload links. + foreach ( $lcp_elements_by_minimum_viewport_widths as &$lcp_element ) { + if ( $lcp_element && $lcp_element['breadcrumbs'] === $processor->get_breadcrumbs() ) { + $lcp_element['attributes'] = array(); + foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { + $lcp_element['attributes'][ $attr_name ] = $processor->get_attribute( $attr_name ); } } } - ); - $buffer = $processor->get_updated_html(); + } + ); + $buffer = $processor->get_updated_html(); - $preload_links = ilo_construct_preload_links( $lcp_elements_by_minimum_viewport_widths ); + $preload_links = ilo_construct_preload_links( $lcp_elements_by_minimum_viewport_widths ); - // TODO: In the future, WP_HTML_Processor could be used to do this injection. However, given the simple replacement here this is not essential. - $buffer = preg_replace( '#(?=)#i', $preload_links, $buffer, 1 ); - } + // TODO: In the future, WP_HTML_Processor could be used to do this injection. However, given the simple replacement here this is not essential. + $buffer = preg_replace( '#(?=)#i', $preload_links, $buffer, 1 ); } return $buffer; From 3cda8873bf4bfe84e72e0724ae512a35027f6b6b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 15:34:47 -0800 Subject: [PATCH 110/371] Disable background image detection until implemented on server --- .../detection/detect.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 4ce20d054e..45a8d25f0c 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -265,15 +265,18 @@ export default async function detect( { const breadcrumbedImages = doc.body.querySelectorAll( 'img' ); // We do the same for elements with background images which are not data: URLs. - const breadcrumbedElementsWithBackgrounds = Array.from( - doc.body.querySelectorAll( '[style*="background"]' ) - ).filter( ( /** @type {Element} */ el ) => - /url\(\s*['"](?!=data:)/.test( el.style.backgroundImage ) - ); + // TODO: Re-enable background image support when server-side is implemented. + // const breadcrumbedElementsWithBackgrounds = Array.from( + // doc.body.querySelectorAll( '[style*="background"]' ) + // ).filter( ( /** @type {Element} */ el ) => + // /url\(\s*['"](?!=data:)/.test( el.style.backgroundImage ) + // ); /** @type {Map} */ const breadcrumbedElementsMap = new Map( - [ ...breadcrumbedImages, ...breadcrumbedElementsWithBackgrounds ].map( + [ + ...breadcrumbedImages /*, ...breadcrumbedElementsWithBackgrounds*/, + ].map( // TODO: Instead of generating breadcrumbs here, rely instead on server-generated breadcrumbs that are added to a data attribute by the server. ( element ) => [ element, getBreadcrumbs( element ) ] ) From f00addc80f6c222578ef30e9a12170046e050ea3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 15:39:22 -0800 Subject: [PATCH 111/371] Use tag instead of tagName in breadcrumbs --- .../class-ilo-html-tag-processor.php | 6 +++--- .../images/image-loading-optimization/detection/detect.js | 6 +++--- .../images/image-loading-optimization/storage/rest-api.php | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index ed6b0e3c55..e412b43e79 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -218,14 +218,14 @@ public function get_tag() { * * Breadcrumbs are constructed to match the format from detect.js. * - * @return array Breadcrumbs. + * @return array Breadcrumbs. */ public function get_breadcrumbs(): array { $breadcrumbs = array(); foreach ( $this->open_stack_tags as $i => $breadcrumb_tag_name ) { $breadcrumbs[] = array( - 'tagName' => $breadcrumb_tag_name, // TODO: Just 'tag'. - 'index' => $this->open_stack_indices[ $i ], + 'tag' => $breadcrumb_tag_name, + 'index' => $this->open_stack_indices[ $i ], ); } return $breadcrumbs; diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 45a8d25f0c..9619f39e8d 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -80,8 +80,8 @@ function error( ...message ) { /** * @typedef {Object} Breadcrumb - * @property {number} index - Index of element among sibling elements. - * @property {string} tagName - Tag name. + * @property {number} index - Index of element among sibling elements. + * @property {string} tag - Tag name. */ /** @@ -146,7 +146,7 @@ function getBreadcrumbs( leafElement ) { let element = leafElement; while ( element instanceof Element ) { breadcrumbs.unshift( { - tagName: element.tagName, + tag: element.tagName, index: getElementIndex( element ), } ); element = element.parentElement; diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index a304eca6fd..617cde3fa8 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -136,12 +136,12 @@ function ilo_register_endpoint() { 'items' => array( 'type' => 'object', 'properties' => array( - 'tagName' => array( // TODO: Should this just be 'tag' instead? + 'tag' => array( 'type' => 'string', 'required' => true, 'pattern' => '^[a-zA-Z0-9-]+$', ), - 'index' => array( + 'index' => array( 'type' => 'int', 'required' => true, 'minimum' => 0, From ce1db2fa4ff4c66b42525af6daf6fdfc1434718b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 15:54:51 -0800 Subject: [PATCH 112/371] Prevent adding media query to preload link when just min-width:0 --- .../image-loading-optimization/optimization.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 7fd6597be7..07f599215b 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -47,12 +47,14 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt unset( $img_attributes['src'] ); } - // Add media query. - $media_query = sprintf( 'screen and ( min-width: %dpx )', $minimum_viewport_widths[ $i ] ); - if ( isset( $minimum_viewport_widths[ $i + 1 ] ) ) { - $media_query .= sprintf( ' and ( max-width: %dpx )', $minimum_viewport_widths[ $i + 1 ] - 1 ); + // Add media query if it's going to be something other than just `min-width: 0px`. + if ( $minimum_viewport_widths[ $i ] > 0 || isset( $minimum_viewport_widths[ $i + 1 ] ) ) { + $media_query = sprintf( '( min-width: %dpx )', $minimum_viewport_widths[ $i ] ); + if ( isset( $minimum_viewport_widths[ $i + 1 ] ) ) { + $media_query .= sprintf( ' and ( max-width: %dpx )', $minimum_viewport_widths[ $i + 1 ] - 1 ); + } + $img_attributes['media'] = $media_query; } - $img_attributes['media'] = $media_query; // Construct preload link. $link_tag = ' Date: Tue, 28 Nov 2023 16:00:28 -0800 Subject: [PATCH 113/371] Add preload links always and consolidate code paths --- .../optimization.php | 83 +++++++++---------- 1 file changed, 39 insertions(+), 44 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 07f599215b..5c8c99238a 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -98,7 +98,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics_grouped_by_breakpoint ); // TODO: Handle case when the LCP element is not an image at all, but rather a background-image. - // Use the fetchpriority attribute on the image when all breakpoints have the same LCP element. + // Prepare to set fetchpriority attribute on the image when all breakpoints have the same LCP element. if ( // All breakpoints share the same LCP element (or all have none at all). 1 === count( $lcp_elements_by_minimum_viewport_widths ) @@ -109,59 +109,54 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // All breakpoints have URL metrics being reported. count( array_filter( $url_metrics_grouped_by_breakpoint ) ) === count( $breakpoint_max_widths ) + 1 ) { - $lcp_element = current( $lcp_elements_by_minimum_viewport_widths ); - - $processor = new ILO_HTML_Tag_Processor( $buffer ); - $processor->walk( - static function () use ( $processor, $lcp_element ) { - if ( $processor->get_tag() !== 'IMG' ) { - return; - } + $common_lcp_element = current( $lcp_elements_by_minimum_viewport_widths ); + } else { + $common_lcp_element = null; + } - if ( $processor->get_breadcrumbs() === $lcp_element['breadcrumbs'] ) { - if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { - $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); - } else { - $processor->set_attribute( 'fetchpriority', 'high' ); - $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); - } - } else { - $processor->remove_fetchpriority_attribute(); - } + // Walk over all IMG tags in the document and ensure fetchpriority is set/removed, and gather IMG attributes for preloading. + $processor = new ILO_HTML_Tag_Processor( $buffer ); + $processor->walk( + static function () use ( $processor, $common_lcp_element, &$lcp_elements_by_minimum_viewport_widths ) { + if ( $processor->get_tag() !== 'IMG' ) { + return; } - ); - $buffer = $processor->get_updated_html(); - // TODO: We could also add the preload links here. - } else { - // If there is not exactly one LCP element, we need to remove fetchpriority from all images while also - // capturing the attributes from the LCP element which we can then use for preload links. - $processor = new ILO_HTML_Tag_Processor( $buffer ); - $processor->walk( - static function () use ( $processor, &$lcp_elements_by_minimum_viewport_widths ) { - if ( $processor->get_tag() !== 'IMG' ) { - return; + // Ensure the fetchpriority attribute is set on the element properly. + if ( $common_lcp_element && $processor->get_breadcrumbs() === $common_lcp_element['breadcrumbs'] ) { + if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { + $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); + } else { + $processor->set_attribute( 'fetchpriority', 'high' ); + $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); } - + } else { $processor->remove_fetchpriority_attribute(); + } - // Capture the attributes from the LCP elements to use in preload links. - foreach ( $lcp_elements_by_minimum_viewport_widths as &$lcp_element ) { - if ( $lcp_element && $lcp_element['breadcrumbs'] === $processor->get_breadcrumbs() ) { - $lcp_element['attributes'] = array(); - foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { - $lcp_element['attributes'][ $attr_name ] = $processor->get_attribute( $attr_name ); - } + // Capture the attributes from the LCP elements to use in preload links. + foreach ( $lcp_elements_by_minimum_viewport_widths as &$lcp_element ) { + if ( $lcp_element && $lcp_element['breadcrumbs'] === $processor->get_breadcrumbs() ) { + $lcp_element['attributes'] = array(); + foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { + $lcp_element['attributes'][ $attr_name ] = $processor->get_attribute( $attr_name ); } } } + } + ); + $buffer = $processor->get_updated_html(); + + // Inject any preload links at the end of the HEAD. In the future, WP_HTML_Processor could be used to do this injection. + // However, given the simple replacement here this is not essential. + $preload_links = ilo_construct_preload_links( $lcp_elements_by_minimum_viewport_widths ); + if ( $preload_links ) { + $buffer = preg_replace( + '#(?=)#i', + $preload_links, + $buffer, + 1 ); - $buffer = $processor->get_updated_html(); - - $preload_links = ilo_construct_preload_links( $lcp_elements_by_minimum_viewport_widths ); - - // TODO: In the future, WP_HTML_Processor could be used to do this injection. However, given the simple replacement here this is not essential. - $buffer = preg_replace( '#(?=)#i', $preload_links, $buffer, 1 ); } return $buffer; From f0ee6b2770472c2a8144e472896ff838438385d2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 16:04:17 -0800 Subject: [PATCH 114/371] Add since and private access tags --- .../class-ilo-html-tag-processor.php | 1 + .../images/image-loading-optimization/optimization.php | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index e412b43e79..38d14196e2 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -10,6 +10,7 @@ * Subclass of WP_HTML_Tag_Processor that adds support for breadcrumbs and a visiting callback. * * @since n.e.x.t + * @access private */ class ILO_HTML_Tag_Processor { diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 5c8c99238a..fbde9b1960 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -14,6 +14,9 @@ /** * Adds template output buffer filter for optimization if eligible. + * + * @since n.e.x.t + * @access private */ function ilo_maybe_add_template_output_buffer_filter() { if ( ! ilo_can_optimize_response() ) { @@ -26,6 +29,9 @@ function ilo_maybe_add_template_output_buffer_filter() { /** * 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. * @return string Markup for one or more preload link tags. */ @@ -79,6 +85,9 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt /** * Optimizes template output buffer. * + * @since n.e.x.t + * @access private + * * @param string $buffer Template output buffer. * @return string Filtered template output buffer. */ From 1e495dbdb9bf5b030f1f7152ff4b9b062c8d5c6c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 16:30:29 -0800 Subject: [PATCH 115/371] Update return tag phpdoc for ilo_construct_preload_links() --- modules/images/image-loading-optimization/optimization.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index fbde9b1960..ad62e4064f 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -33,7 +33,7 @@ function ilo_maybe_add_template_output_buffer_filter() { * @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. - * @return string Markup for one or more preload link tags. + * @return string Markup for zero or more preload link tags. */ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widths ): string { $preload_links = array(); From 8065801a75da549c25d79b663894a10410b4a029 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 16:31:01 -0800 Subject: [PATCH 116/371] Add missing since and private access tags to ilo_get_lcp_elements_by_minimum_viewport_widths() --- modules/images/image-loading-optimization/storage/data.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 331033ca77..3c5f20910b 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -303,6 +303,9 @@ static function ( $breakpoint ) { * 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. */ From 131a9a0bcaab48c830c2e59bab5d40f609ed8d4f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 16:33:20 -0800 Subject: [PATCH 117/371] Move GH comment into code comment --- .../image-loading-optimization/class-ilo-html-tag-processor.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 38d14196e2..3646ced4a6 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -142,6 +142,8 @@ public function walk( callable $open_tag_callback ) { if ( ! $p->is_tag_closer() ) { // Close an open P tag when a P-closing tag is encountered. + // TODO: There are quite a few more cases of optional closing tags: https://html.spec.whatwg.org/multipage/syntax.html#optional-tags + // Nevertheless, given WordPress's legacy of XHTML compatibility, the lack of closing tags may not be common enough to warrant worrying about any of them. if ( in_array( $tag_name, self::P_CLOSING_TAGS, true ) ) { $i = array_search( 'P', $this->open_stack_tags, true ); if ( false !== $i ) { From ee83ba00365969e9bed18c65cab0f9cce114925f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 16:46:46 -0800 Subject: [PATCH 118/371] Remove needless remove_fetchpriority_attribute method --- .../class-ilo-html-tag-processor.php | 17 ----------------- .../image-loading-optimization/optimization.php | 5 +++-- .../image-loading-optimization/storage/data.php | 2 +- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 3646ced4a6..ebf19d6aba 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -234,23 +234,6 @@ public function get_breadcrumbs(): array { return $breadcrumbs; } - /** - * Removes the fetchpriority attribute from the current node being walked over. - * - * Also sets an attribute to indicate that the attribute was removed. - * - * @return bool Whether an attribute was removed. - */ - public function remove_fetchpriority_attribute(): bool { - $p = $this->processor; - if ( $p->get_attribute( 'fetchpriority' ) ) { - $p->set_attribute( 'data-ilo-removed-fetchpriority', $p->get_attribute( 'fetchpriority' ) ); - return $p->remove_attribute( 'fetchpriority' ); - } else { - return false; - } - } - /** * Returns the value of a requested attribute from a matched tag opener if that attribute exists. * diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index ad62e4064f..7aa7b10809 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -139,8 +139,9 @@ static function () use ( $processor, $common_lcp_element, &$lcp_elements_by_mini $processor->set_attribute( 'fetchpriority', 'high' ); $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); } - } else { - $processor->remove_fetchpriority_attribute(); + } elseif ( $processor->get_attribute( 'fetchpriority' ) ) { + $processor->set_attribute( 'data-ilo-removed-fetchpriority', $processor->get_attribute( 'fetchpriority' ) ); + $processor->remove_attribute( 'fetchpriority' ); } // Capture the attributes from the LCP elements to use in preload links. diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 3c5f20910b..506a88b6cb 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -349,7 +349,7 @@ function ilo_get_lcp_elements_by_minimum_viewport_widths( array $grouped_url_met } } - // Now we need to merge the breakpoints when there is an LCP element common between them. + // Now merge the breakpoints when there is an LCP element common between them. $prev_lcp_element = null; return array_filter( $lcp_element_by_viewport_minimum_width, From 3d991286d80421a87a1922ef62f43594117a79ae Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 16:59:49 -0800 Subject: [PATCH 119/371] Prevent removing fetchpriority when all breakpoints do not have URL metrics collected --- .../images/image-loading-optimization/optimization.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 7aa7b10809..1e40e1ca87 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -105,6 +105,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $breakpoint_max_widths = ilo_get_breakpoint_max_widths(); $url_metrics_grouped_by_breakpoint = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths ); $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics_grouped_by_breakpoint ); + $all_breakpoints_have_url_metrics = count( array_filter( $url_metrics_grouped_by_breakpoint ) ) === count( $breakpoint_max_widths ) + 1; // TODO: Handle case when the LCP element is not an image at all, but rather a background-image. // Prepare to set fetchpriority attribute on the image when all breakpoints have the same LCP element. @@ -116,7 +117,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { ! in_array( false, $lcp_elements_by_minimum_viewport_widths, true ) && // All breakpoints have URL metrics being reported. - count( array_filter( $url_metrics_grouped_by_breakpoint ) ) === count( $breakpoint_max_widths ) + 1 + $all_breakpoints_have_url_metrics ) { $common_lcp_element = current( $lcp_elements_by_minimum_viewport_widths ); } else { @@ -126,7 +127,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // Walk over all IMG tags in the document and ensure fetchpriority is set/removed, and gather IMG attributes for preloading. $processor = new ILO_HTML_Tag_Processor( $buffer ); $processor->walk( - static function () use ( $processor, $common_lcp_element, &$lcp_elements_by_minimum_viewport_widths ) { + static function () use ( $processor, $common_lcp_element, $all_breakpoints_have_url_metrics, &$lcp_elements_by_minimum_viewport_widths ) { if ( $processor->get_tag() !== 'IMG' ) { return; } @@ -139,7 +140,9 @@ static function () use ( $processor, $common_lcp_element, &$lcp_elements_by_mini $processor->set_attribute( 'fetchpriority', 'high' ); $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); } - } elseif ( $processor->get_attribute( 'fetchpriority' ) ) { + } elseif ( $all_breakpoints_have_url_metrics && $processor->get_attribute( 'fetchpriority' ) ) { + // Note: The $all_breakpoints_have_url_metrics condition here allows for server-side heuristics to + // continue to apply while waiting for all breakpoints to have metrics collected for them. $processor->set_attribute( 'data-ilo-removed-fetchpriority', $processor->get_attribute( 'fetchpriority' ) ); $processor->remove_attribute( 'fetchpriority' ); } From d42601cf3570e5d02f5dec0daf49b88578eb5159 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 17:15:45 -0800 Subject: [PATCH 120/371] Use wp_trigger_error() in ILO_HTML_Tag_Processor and improve phpdoc --- .../class-ilo-html-tag-processor.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index ebf19d6aba..0c72d866ec 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -7,12 +7,12 @@ */ /** - * Subclass of WP_HTML_Tag_Processor that adds support for breadcrumbs and a visiting callback. + * Processor leveraging WP_HTML_Tag_Processor which walks over a document and gathers breadcrumbs and invokes a callback for each open tag. * * @since n.e.x.t * @access private */ -class ILO_HTML_Tag_Processor { +final class ILO_HTML_Tag_Processor { /** * HTML elements that are self-closing. @@ -193,8 +193,18 @@ public function walk( callable $open_tag_callback ) { if ( ! $did_splice ) { $popped_tag_name = array_pop( $this->open_stack_tags ); - if ( $popped_tag_name !== $tag_name ) { - error_log( "Expected popped tag stack element $popped_tag_name to match the currently visited closing tag $tag_name." ); // phpcs:ignore + 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 ); From 4db5161b3f756a4e23e8c7eb1b02ce4db37bad65 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Nov 2023 17:29:11 -0800 Subject: [PATCH 121/371] Add var phpdoc tag to class constant --- .../image-loading-optimization/class-ilo-html-tag-processor.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 0c72d866ec..33861d2a1f 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -49,6 +49,8 @@ final class ILO_HTML_Tag_Processor { * * @link https://www.w3.org/TR/html-markup/p.html * @link https://github.com/ampproject/amp-toolbox-php/blob/c79a0fe558a3c042aee4789bbf33376cca7a733d/src/Html/Tag.php#L262-L293 + * + * @var string[] */ const P_CLOSING_TAGS = array( 'ADDRESS', From 2c3cf3a945b661270155032d832711f7aee4701b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 29 Nov 2023 13:03:56 -0800 Subject: [PATCH 122/371] Use a generator instead of a callback --- .../class-ilo-html-tag-processor.php | 30 ++++------- .../optimization.php | 50 +++++++++---------- 2 files changed, 33 insertions(+), 47 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 33861d2a1f..9a4d373624 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -7,7 +7,7 @@ */ /** - * Processor leveraging WP_HTML_Tag_Processor which walks over a document and gathers breadcrumbs and invokes a callback for each open tag. + * Processor leveraging WP_HTML_Tag_Processor which gathers breadcrumbs which can be queried while iterating the open_tags() generator . * * @since n.e.x.t * @access private @@ -111,13 +111,14 @@ public function __construct( string $html ) { } /** - * Walk over the document. + * Gets all open tags in the document. * - * Whenever an open tag is encountered, invoke the supplied $open_tag_callback and pass the tag name and breadcrumbs. + * 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. * - * @param callable $open_tag_callback Open tag callback. The processor instance is passed as the sole argument. + * @return Generator Tag name of current open tag. */ - public function walk( callable $open_tag_callback ) { + public function open_tags(): Generator { $p = $this->processor; /* @@ -170,8 +171,9 @@ public function walk( callable $open_tag_callback ) { --$this->open_stack_indices[ $level ]; } - // Invoke the callback to do processing. - $open_tag_callback( $tag_name, $this->get_breadcrumbs() ); + // Now that the breadcrumbs are constructed, yield the tag name so that they can be queried if desired. + // Other mutations may be performed to the open tag's attributes by the callee at this point as well. + yield $tag_name; // Immediately pop off self-closing tags. if ( in_array( $tag_name, self::SELF_CLOSING_TAGS, true ) ) { @@ -214,20 +216,6 @@ public function walk( callable $open_tag_callback ) { } } - /** - * Returns the uppercase name of the matched tag. - * - * 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. - * - * @see WP_HTML_Tag_Processor::get_tag() - * - * @return string|null Name of currently matched tag in input HTML, or `null` if none found. - */ - public function get_tag() { - return $this->processor->get_tag(); - } - /** * Gets breadcrumbs for the current open tag. * diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 1e40e1ca87..206c351747 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -126,38 +126,36 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // Walk over all IMG tags in the document and ensure fetchpriority is set/removed, and gather IMG attributes for preloading. $processor = new ILO_HTML_Tag_Processor( $buffer ); - $processor->walk( - static function () use ( $processor, $common_lcp_element, $all_breakpoints_have_url_metrics, &$lcp_elements_by_minimum_viewport_widths ) { - if ( $processor->get_tag() !== 'IMG' ) { - return; - } + foreach ( $processor->open_tags() as $tag_name ) { + if ( 'IMG' !== $tag_name ) { + continue; + } - // Ensure the fetchpriority attribute is set on the element properly. - if ( $common_lcp_element && $processor->get_breadcrumbs() === $common_lcp_element['breadcrumbs'] ) { - if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { - $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); - } else { - $processor->set_attribute( 'fetchpriority', 'high' ); - $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); - } - } elseif ( $all_breakpoints_have_url_metrics && $processor->get_attribute( 'fetchpriority' ) ) { - // Note: The $all_breakpoints_have_url_metrics condition here allows for server-side heuristics to - // continue to apply while waiting for all breakpoints to have metrics collected for them. - $processor->set_attribute( 'data-ilo-removed-fetchpriority', $processor->get_attribute( 'fetchpriority' ) ); - $processor->remove_attribute( 'fetchpriority' ); + // Ensure the fetchpriority attribute is set on the element properly. + if ( $common_lcp_element && $processor->get_breadcrumbs() === $common_lcp_element['breadcrumbs'] ) { + if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { + $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); + } else { + $processor->set_attribute( 'fetchpriority', 'high' ); + $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); } + } elseif ( $all_breakpoints_have_url_metrics && $processor->get_attribute( 'fetchpriority' ) ) { + // Note: The $all_breakpoints_have_url_metrics condition here allows for server-side heuristics to + // continue to apply while waiting for all breakpoints to have metrics collected for them. + $processor->set_attribute( 'data-ilo-removed-fetchpriority', $processor->get_attribute( 'fetchpriority' ) ); + $processor->remove_attribute( 'fetchpriority' ); + } - // Capture the attributes from the LCP elements to use in preload links. - foreach ( $lcp_elements_by_minimum_viewport_widths as &$lcp_element ) { - if ( $lcp_element && $lcp_element['breadcrumbs'] === $processor->get_breadcrumbs() ) { - $lcp_element['attributes'] = array(); - foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { - $lcp_element['attributes'][ $attr_name ] = $processor->get_attribute( $attr_name ); - } + // Capture the attributes from the LCP elements to use in preload links. + foreach ( $lcp_elements_by_minimum_viewport_widths as &$lcp_element ) { + if ( $lcp_element && $lcp_element['breadcrumbs'] === $processor->get_breadcrumbs() ) { + $lcp_element['attributes'] = array(); + foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { + $lcp_element['attributes'][ $attr_name ] = $processor->get_attribute( $attr_name ); } } } - ); + } $buffer = $processor->get_updated_html(); // Inject any preload links at the end of the HEAD. In the future, WP_HTML_Processor could be used to do this injection. From 3d7b5fae41d209b3dc1bcaa8d5cabcb36d480e8c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 29 Nov 2023 20:43:32 -0800 Subject: [PATCH 123/371] Fix comment typo Co-authored-by: Adam Silverstein --- .../image-loading-optimization/class-ilo-html-tag-processor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 9a4d373624..9b24fbbd94 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -133,7 +133,7 @@ public function open_tags(): Generator { * * * - * The two upon processing the IMG element, the two arrays should be equal to the following: + * Upon processing the IMG element, the two arrays should be equal to the following: * * $open_stack_tags = array( 'HTML', 'BODY', 'IMG' ); * $open_stack_indices = array( 0, 1, 1 ); From 957d2c45b69ae4ed7a216c7ed50d0369bf120268 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Nov 2023 11:13:53 -0800 Subject: [PATCH 124/371] Improve naming of void tags and add missing tags which close P --- .../class-ilo-html-tag-processor.php | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 9b24fbbd94..6514d9ef2a 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -15,29 +15,30 @@ final class ILO_HTML_Tag_Processor { /** - * HTML elements that are self-closing. + * HTML void tags (i.e. those which are self-closing). * - * @link https://www.w3.org/TR/html5/syntax.html#serializing-html-fragments - * @link https://github.com/ampproject/amp-toolbox-php/blob/c79a0fe558a3c042aee4789bbf33376cca7a733d/src/Html/Tag.php#L206-L232 + * @link https://html.spec.whatwg.org/multipage/syntax.html#void-elements + * @see WP_HTML_Processor::is_void() + * @todo Reuse `WP_HTML_Processor::is_void()` once WordPress 6.4 is the minimum-supported version. * * @var string[] */ - const SELF_CLOSING_TAGS = array( + const VOID_TAGS = array( 'AREA', 'BASE', - 'BASEFONT', - 'BGSOUND', + 'BASEFONT', // Obsolete. + 'BGSOUND', // Obsolete. 'BR', 'COL', 'EMBED', - 'FRAME', + 'FRAME', // Deprecated. 'HR', 'IMG', 'INPUT', - 'KEYGEN', + 'KEYGEN', // Obsolete. 'LINK', 'META', - 'PARAM', + 'PARAM', // Deprecated. 'SOURCE', 'TRACK', 'WBR', @@ -47,8 +48,7 @@ final class ILO_HTML_Tag_Processor { * The set of HTML tags whose presence will implicitly close a

element. * For example '

foo

bar

' should parse the same as '

foo

bar

'. * - * @link https://www.w3.org/TR/html-markup/p.html - * @link https://github.com/ampproject/amp-toolbox-php/blob/c79a0fe558a3c042aee4789bbf33376cca7a733d/src/Html/Tag.php#L262-L293 + * @link https://html.spec.whatwg.org/multipage/grouping-content.html#the-p-element * * @var string[] */ @@ -57,9 +57,12 @@ final class ILO_HTML_Tag_Processor { 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', - 'DIR', + 'DETAILS', + 'DIV', 'DL', 'FIELDSET', + 'FIGCAPTION', + 'FIGURE', 'FOOTER', 'FORM', 'H1', @@ -69,12 +72,15 @@ final class ILO_HTML_Tag_Processor { 'H5', 'H6', 'HEADER', + 'HGROUP', 'HR', + 'MAIN', 'MENU', 'NAV', 'OL', 'P', 'PRE', + 'SEARCH', 'SECTION', 'TABLE', 'UL', @@ -176,12 +182,12 @@ public function open_tags(): Generator { yield $tag_name; // Immediately pop off self-closing tags. - if ( in_array( $tag_name, self::SELF_CLOSING_TAGS, true ) ) { + if ( in_array( $tag_name, self::VOID_TAGS, true ) ) { array_pop( $this->open_stack_tags ); } } else { // If the closing tag is for self-closing tag, we ignore it since it was already handled above. - if ( in_array( $tag_name, self::SELF_CLOSING_TAGS, true ) ) { + if ( in_array( $tag_name, self::VOID_TAGS, true ) ) { continue; } From e8a36b44f6c0cf621fec8e22999485ad46dfb8bd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 1 Dec 2023 09:39:51 -0800 Subject: [PATCH 125/371] Remove todo related to user being logged-in --- .../image-loading-optimization/class-ilo-html-tag-processor.php | 1 - modules/images/image-loading-optimization/detection/detect.js | 1 - 2 files changed, 2 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 6514d9ef2a..26a40b46c9 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -170,7 +170,6 @@ public function open_tags(): Generator { ++$this->open_stack_indices[ $level ]; } - // TODO: We should consider not collecting metrics when the admin bar is shown and the user is logged-in. // Only increment the tag index at this level only if it isn't the admin bar, since the presence of the // admin bar can throw off the indices. if ( 'DIV' === $tag_name && $p->get_attribute( 'id' ) === 'wpadminbar' ) { diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 9619f39e8d..873192cf3d 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -118,7 +118,6 @@ function getElementIndex( element ) { const children = [ ...element.parentElement.children ]; let index = children.indexOf( element ); if ( children.includes( document.getElementById( adminBarId ) ) ) { - // TODO: Should detection just be turned off when is_user_logged_in()? --index; } if ( From 0cca4dd2bf49afcfa9af51feaf58a83f7a5f57c8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 1 Dec 2023 13:18:56 -0800 Subject: [PATCH 126/371] Add note about how ILO_HTML_Tag_Processor is needed until WP_HTML_Processor is fully implemented --- .../class-ilo-html-tag-processor.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 26a40b46c9..9a018a91d1 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -7,7 +7,9 @@ */ /** - * Processor leveraging WP_HTML_Tag_Processor which gathers breadcrumbs which can be queried while iterating the open_tags() generator . + * Processor leveraging WP_HTML_Tag_Processor which gathers breadcrumbs which can be queried while iterating the open_tags() generator. + * + * Eventually this class should be made largely obsolete once `WP_HTML_Processor` is fully implemented to support all HTML tags. * * @since n.e.x.t * @access private From 83d0362170501b5356cea8cb403ba29913ea0ce6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 1 Dec 2023 13:25:28 -0800 Subject: [PATCH 127/371] Move class file include to load.php --- modules/images/image-loading-optimization/load.php | 1 + modules/images/image-loading-optimization/optimization.php | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index b253f9d8b3..9a754b037a 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -25,4 +25,5 @@ require_once __DIR__ . '/detection.php'; +require_once __DIR__ . '/class-ilo-html-tag-processor.php'; require_once __DIR__ . '/optimization.php'; diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 206c351747..e2937d48f0 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -10,8 +10,6 @@ exit; // Exit if accessed directly. } -require_once __DIR__ . '/class-ilo-html-tag-processor.php'; - /** * Adds template output buffer filter for optimization if eligible. * From 605fb02e546d7b4c1e80fbd094c5e3dd2ab77299 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 1 Dec 2023 13:33:06 -0800 Subject: [PATCH 128/371] Add TODO to remove loading attribute --- modules/images/image-loading-optimization/optimization.php | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index e2937d48f0..7c0ba3f2e5 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -134,6 +134,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); } else { + // TODO: When optimizing lazy-loading, this should also remove any `loading` attribute here. $processor->set_attribute( 'fetchpriority', 'high' ); $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); } From 74145a460146521da14bfdc2f769a5faccf22f29 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 1 Dec 2023 14:03:55 -0800 Subject: [PATCH 129/371] Optimize looking up LCP element by breadcrumb --- .../class-ilo-html-tag-processor.php | 2 + .../optimization.php | 40 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 9a018a91d1..0a384b8ee0 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -228,6 +228,8 @@ public function open_tags(): Generator { * * Breadcrumbs are constructed to match the format from detect.js. * + * TODO: Consider rather each breadcrumb being a (tag,index) tuple. + * * @return array Breadcrumbs. */ public function get_breadcrumbs(): array { diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 7c0ba3f2e5..11c111a467 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -80,6 +80,24 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt return implode( '', $preload_links ); } +/** + * Constructs a breadcrumbs string from a breadcrumbs array. + * + * @param array $breadcrumbs Breadcrumbs. + * @return string Breadcrumb string. + */ +function ilo_construct_breadcrumbs_string( array $breadcrumbs ): string { + return implode( + ' ', + array_map( + static function ( $breadcrumb ) { + return sprintf( '%s,%s', $breadcrumb['tag'], $breadcrumb['index'] ); + }, + $breadcrumbs + ) + ); +} + /** * Optimizes template output buffer. * @@ -105,6 +123,15 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics_grouped_by_breakpoint ); $all_breakpoints_have_url_metrics = count( array_filter( $url_metrics_grouped_by_breakpoint ) ) === count( $breakpoint_max_widths ) + 1; + // Optimize looking up the LCP element by breadcrumb. + $lcp_element_minimum_viewport_width_by_breadcrumb = array(); + foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { + if ( false !== $lcp_element ) { + $breadcrumb_string = ilo_construct_breadcrumbs_string( $lcp_element['breadcrumbs'] ); + $lcp_element_minimum_viewport_width_by_breadcrumb[ $breadcrumb_string ] = $minimum_viewport_width; + } + } + // TODO: Handle case when the LCP element is not an image at all, but rather a background-image. // Prepare to set fetchpriority attribute on the image when all breakpoints have the same LCP element. if ( @@ -146,13 +173,14 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { } // Capture the attributes from the LCP elements to use in preload links. - foreach ( $lcp_elements_by_minimum_viewport_widths as &$lcp_element ) { - if ( $lcp_element && $lcp_element['breadcrumbs'] === $processor->get_breadcrumbs() ) { - $lcp_element['attributes'] = array(); - foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { - $lcp_element['attributes'][ $attr_name ] = $processor->get_attribute( $attr_name ); - } + $breadcrumb_string = ilo_construct_breadcrumbs_string( $processor->get_breadcrumbs() ); + if ( isset( $lcp_element_minimum_viewport_width_by_breadcrumb[ $breadcrumb_string ] ) ) { + $attributes = array(); + foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { + $attributes[ $attr_name ] = $processor->get_attribute( $attr_name ); } + $minimum_viewport_width = $lcp_element_minimum_viewport_width_by_breadcrumb[ $breadcrumb_string ]; + $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['attributes'] = $attributes; } } $buffer = $processor->get_updated_html(); From 2758c0dba01a489e0b204cfcbb57d66b4d47b5ef Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 1 Dec 2023 14:27:00 -0800 Subject: [PATCH 130/371] Make ilo_construct_preload_links() easier to read --- .../images/image-loading-optimization/optimization.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 11c111a467..faa1725533 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -52,10 +52,12 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt } // Add media query if it's going to be something other than just `min-width: 0px`. - if ( $minimum_viewport_widths[ $i ] > 0 || isset( $minimum_viewport_widths[ $i + 1 ] ) ) { - $media_query = sprintf( '( min-width: %dpx )', $minimum_viewport_widths[ $i ] ); - if ( isset( $minimum_viewport_widths[ $i + 1 ] ) ) { - $media_query .= sprintf( ' and ( max-width: %dpx )', $minimum_viewport_widths[ $i + 1 ] - 1 ); + $minimum_viewport_width = $minimum_viewport_widths[ $i ]; + $maximum_viewport_width = isset( $minimum_viewport_widths[ $i + 1 ] ) ? $minimum_viewport_widths[ $i + 1 ] - 1 : null; + if ( $minimum_viewport_width > 0 || null !== $maximum_viewport_width ) { + $media_query = sprintf( '( min-width: %dpx )', $minimum_viewport_width ); + if ( null !== $maximum_viewport_width ) { + $media_query .= sprintf( ' and ( max-width: %dpx )', $maximum_viewport_width ); } $img_attributes['media'] = $media_query; } From 9d8261535314ef0bfe79ba2294ab0150fa54f764 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 1 Dec 2023 17:19:19 -0800 Subject: [PATCH 131/371] Never include loading=lazy on the LCP image common across all breakpoints --- modules/images/image-loading-optimization/optimization.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index faa1725533..355358c08c 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -163,10 +163,15 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); } else { - // TODO: When optimizing lazy-loading, this should also remove any `loading` attribute here. $processor->set_attribute( 'fetchpriority', 'high' ); $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); } + + // Never include loading=lazy on the LCP image common across all breakpoints. + if ( 'lazy' === $processor->get_attribute( 'loading' ) ) { + $processor->set_attribute( 'data-ilo-removed-loading', $processor->get_attribute( 'loading' ) ); + $processor->remove_attribute( 'loading' ); + } } elseif ( $all_breakpoints_have_url_metrics && $processor->get_attribute( 'fetchpriority' ) ) { // Note: The $all_breakpoints_have_url_metrics condition here allows for server-side heuristics to // continue to apply while waiting for all breakpoints to have metrics collected for them. From 9bfcdc948d35b70352905988e5582f13de4b80e7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 4 Dec 2023 11:36:05 -0800 Subject: [PATCH 132/371] Account for the same element being LCP on different breakpoints --- modules/images/image-loading-optimization/optimization.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 355358c08c..eea4c24dda 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -130,7 +130,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { if ( false !== $lcp_element ) { $breadcrumb_string = ilo_construct_breadcrumbs_string( $lcp_element['breadcrumbs'] ); - $lcp_element_minimum_viewport_width_by_breadcrumb[ $breadcrumb_string ] = $minimum_viewport_width; + $lcp_element_minimum_viewport_width_by_breadcrumb[ $breadcrumb_string ][] = $minimum_viewport_width; } } @@ -186,8 +186,9 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { $attributes[ $attr_name ] = $processor->get_attribute( $attr_name ); } - $minimum_viewport_width = $lcp_element_minimum_viewport_width_by_breadcrumb[ $breadcrumb_string ]; - $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['attributes'] = $attributes; + foreach ( $lcp_element_minimum_viewport_width_by_breadcrumb[ $breadcrumb_string ] as $minimum_viewport_width ) { + $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['attributes'] = $attributes; + } } } $buffer = $processor->get_updated_html(); From a50a8a2a270284585b3346abc8e1cf02def7756d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 4 Dec 2023 10:45:54 -0800 Subject: [PATCH 133/371] Improve construction of breadcrumbs --- .../class-ilo-html-tag-processor.php | 11 ++------ .../optimization.php | 28 ++++++++++--------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 0a384b8ee0..d0064b88bb 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -226,19 +226,14 @@ public function open_tags(): Generator { /** * Gets breadcrumbs for the current open tag. * - * Breadcrumbs are constructed to match the format from detect.js. + * A breadcrumb consists of a tag name and its sibling index. * - * TODO: Consider rather each breadcrumb being a (tag,index) tuple. - * - * @return array Breadcrumbs. + * @return array Breadcrumbs. */ public function get_breadcrumbs(): array { $breadcrumbs = array(); foreach ( $this->open_stack_tags as $i => $breadcrumb_tag_name ) { - $breadcrumbs[] = array( - 'tag' => $breadcrumb_tag_name, - 'index' => $this->open_stack_indices[ $i ], - ); + $breadcrumbs[] = array( $breadcrumb_tag_name, $this->open_stack_indices[ $i ] ); } return $breadcrumbs; } diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index eea4c24dda..c6b75b6fc3 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -83,17 +83,19 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt } /** - * Constructs a breadcrumbs string from a breadcrumbs array. + * Gets XPath from a breadcrumbs array. * - * @param array $breadcrumbs Breadcrumbs. - * @return string Breadcrumb string. + * @param array $breadcrumbs Breadcrumbs. + * @return string Breadcrumb XPath. */ -function ilo_construct_breadcrumbs_string( array $breadcrumbs ): string { +function ilo_get_breadcrumbs_xpath( array $breadcrumbs ): string { return implode( - ' ', + '', array_map( static function ( $breadcrumb ) { - return sprintf( '%s,%s', $breadcrumb['tag'], $breadcrumb['index'] ); + // 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]`. + return sprintf( '/*[%d][self::%s]', $breadcrumb[1], $breadcrumb[0] ); }, $breadcrumbs ) @@ -125,12 +127,12 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics_grouped_by_breakpoint ); $all_breakpoints_have_url_metrics = count( array_filter( $url_metrics_grouped_by_breakpoint ) ) === count( $breakpoint_max_widths ) + 1; - // Optimize looking up the LCP element by breadcrumb. - $lcp_element_minimum_viewport_width_by_breadcrumb = array(); + // Optimize looking up the LCP element by XPath. + $lcp_element_minimum_viewport_width_by_xpath = array(); foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { if ( false !== $lcp_element ) { - $breadcrumb_string = ilo_construct_breadcrumbs_string( $lcp_element['breadcrumbs'] ); - $lcp_element_minimum_viewport_width_by_breadcrumb[ $breadcrumb_string ][] = $minimum_viewport_width; + $breadcrumbs_xpath = ilo_get_breadcrumbs_xpath( $lcp_element['breadcrumbs'] ); + $lcp_element_minimum_viewport_width_by_xpath[ $breadcrumbs_xpath ][] = $minimum_viewport_width; } } @@ -180,13 +182,13 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { } // Capture the attributes from the LCP elements to use in preload links. - $breadcrumb_string = ilo_construct_breadcrumbs_string( $processor->get_breadcrumbs() ); - if ( isset( $lcp_element_minimum_viewport_width_by_breadcrumb[ $breadcrumb_string ] ) ) { + $breadcrumbs_xpath = ilo_get_breadcrumbs_xpath( $processor->get_breadcrumbs() ); + if ( isset( $lcp_element_minimum_viewport_width_by_xpath[ $breadcrumbs_xpath ] ) ) { $attributes = array(); foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { $attributes[ $attr_name ] = $processor->get_attribute( $attr_name ); } - foreach ( $lcp_element_minimum_viewport_width_by_breadcrumb[ $breadcrumb_string ] as $minimum_viewport_width ) { + foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $breadcrumbs_xpath ] as $minimum_viewport_width ) { $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['attributes'] = $attributes; } } From 6498b6b7c963c1f76d01c959a7c851ddf1a44fcc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 4 Dec 2023 11:31:09 -0800 Subject: [PATCH 134/371] Eliminate use of client-side breadcrumbs --- .../image-loading-optimization/detection.php | 10 ++- .../detection/detect.js | 70 +++---------------- .../optimization.php | 36 +++++++--- .../storage/data.php | 7 +- .../storage/rest-api.php | 28 +++----- 5 files changed, 60 insertions(+), 91 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index c54229fc86..4e619ffa78 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -23,10 +23,18 @@ function ilo_print_detection_script() { $query_vars = ilo_get_normalized_query_vars(); $slug = ilo_get_url_metrics_slug( $query_vars ); + $post = ilo_get_url_metrics_post( $slug ); $microtime = microtime( true ); + // TODO: Eliminate this conditional in favor of calling ilo_print_detection_script() inside of ilo_optimize_template_output_buffer() if $needs_detection. // Abort if we already have all the sample size we need for all breakpoints. - $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths_now_for_slug( $slug ); + $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( + $post ? ilo_parse_stored_url_metrics( $post ) : array(), + microtime( true ), + ilo_get_breakpoint_max_widths(), + ilo_get_url_metrics_breakpoint_sample_size(), + ilo_get_url_metric_freshness_ttl() + ); if ( ! ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { return; } diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 873192cf3d..a994d06eee 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -103,57 +103,6 @@ function error( ...message ) { * @property {ElementMetrics[]} elements - Metrics for the elements observed on the page. */ -/** - * Gets element index among siblings. - * - * @todo Eliminate this in favor of doing all breadcrumb generation exclusively on the server. - * - * @param {Element} element Element. - * @return {number} Index. - */ -function getElementIndex( element ) { - if ( ! element.parentElement ) { - return 0; - } - const children = [ ...element.parentElement.children ]; - let index = children.indexOf( element ); - if ( children.includes( document.getElementById( adminBarId ) ) ) { - --index; - } - if ( - children.includes( - document.querySelector( '.skip-link.screen-reader-text' ) - ) - ) { - --index; - } - return index; -} - -/** - * Gets breadcrumbs for a given element. - * - * @todo Eliminate this in favor of doing all breadcrumb generation exclusively on the server. - * - * @param {Element} leafElement - * @return {Breadcrumb[]} Breadcrumbs. - */ -function getBreadcrumbs( leafElement ) { - /** @type {Breadcrumb[]} */ - const breadcrumbs = []; - - let element = leafElement; - while ( element instanceof Element ) { - breadcrumbs.unshift( { - tag: element.tagName, - index: getElementIndex( element ), - } ); - element = element.parentElement; - } - - return breadcrumbs; -} - /** * Checks whether the URL metric(s) for the provided viewport width is needed. * @@ -258,26 +207,29 @@ export default async function detect( { const adminBar = /** @type {?HTMLDivElement} */ doc.getElementById( adminBarId ); - // We need to capture the original elements and their breadcrumbs as early as possible in case JavaScript is - // mutating the DOM from the original HTML rendered by the server, in which case the breadcrumbs obtained from the - // client will no longer be valid on the server. As such, the results are stored in an array and not any live list. - const breadcrumbedImages = doc.body.querySelectorAll( 'img' ); + // TODO: This query no longer needs to be done as early as possible since the server is adding the breadcrumbs. + const breadcrumbedImages = doc.body.querySelectorAll( + 'img[data-ilo-breadcrumbs]' // TODO: Or 'data-ilo-xpath'. + ); // We do the same for elements with background images which are not data: URLs. // TODO: Re-enable background image support when server-side is implemented. // const breadcrumbedElementsWithBackgrounds = Array.from( - // doc.body.querySelectorAll( '[style*="background"]' ) + // doc.body.querySelectorAll( '[data-ilo-breadcrumbs][style*="background"]' ) // ).filter( ( /** @type {Element} */ el ) => // /url\(\s*['"](?!=data:)/.test( el.style.backgroundImage ) // ); - /** @type {Map} */ + /** @type {Map} */ const breadcrumbedElementsMap = new Map( [ ...breadcrumbedImages /*, ...breadcrumbedElementsWithBackgrounds*/, ].map( - // TODO: Instead of generating breadcrumbs here, rely instead on server-generated breadcrumbs that are added to a data attribute by the server. - ( element ) => [ element, getBreadcrumbs( element ) ] + /** + * @param {HTMLElement} element + * @return {[HTMLElement, string]} Tuple of element and its breadcrumbs. + */ + ( element ) => [ element, element.dataset.iloBreadcrumbs ] // TODO: Rename to iloXpath. ) ); diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index c6b75b6fc3..766f07a0b9 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -107,6 +107,7 @@ static function ( $breadcrumb ) { * * @since n.e.x.t * @access private + * @todo This should also inject the detection script currently output via ilo_print_detection_script(). * * @param string $buffer Template output buffer. * @return string Filtered template output buffer. @@ -115,12 +116,23 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); $post = ilo_get_url_metrics_post( $slug ); - // No URL metrics are present, so there's nothing we can do. - if ( ! $post ) { - return $buffer; - } - - $url_metrics = ilo_parse_stored_url_metrics( $post ); + $url_metrics = $post ? ilo_parse_stored_url_metrics( $post ) : array(); + + // Abort if we already have all the sample size we need for all breakpoints. + // TODO: Also inject detection script from ilo_print_detection_script() when this is true instead of printing at wp_footer. + $needs_detection = ( + ! $post + || + ilo_needs_url_metric_for_breakpoint( + ilo_get_needed_minimum_viewport_widths( + $url_metrics, + microtime( true ), + ilo_get_breakpoint_max_widths(), + ilo_get_url_metrics_breakpoint_sample_size(), + ilo_get_url_metric_freshness_ttl() + ) + ) + ); $breakpoint_max_widths = ilo_get_breakpoint_max_widths(); $url_metrics_grouped_by_breakpoint = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths ); @@ -131,8 +143,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $lcp_element_minimum_viewport_width_by_xpath = array(); foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { if ( false !== $lcp_element ) { - $breadcrumbs_xpath = ilo_get_breadcrumbs_xpath( $lcp_element['breadcrumbs'] ); - $lcp_element_minimum_viewport_width_by_xpath[ $breadcrumbs_xpath ][] = $minimum_viewport_width; + $lcp_element_minimum_viewport_width_by_xpath[ $lcp_element['breadcrumbs'] ][] = $minimum_viewport_width; // TODO: Rename 'breadcrumbs' to 'xpath'. } } @@ -160,8 +171,10 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { continue; } + $breadcrumbs_xpath = ilo_get_breadcrumbs_xpath( $processor->get_breadcrumbs() ); + // Ensure the fetchpriority attribute is set on the element properly. - if ( $common_lcp_element && $processor->get_breadcrumbs() === $common_lcp_element['breadcrumbs'] ) { + if ( $common_lcp_element && $breadcrumbs_xpath === $common_lcp_element['breadcrumbs'] ) { // TODO: Rename 'breadcrumbs' to 'xpath'. if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); } else { @@ -182,7 +195,6 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { } // Capture the attributes from the LCP elements to use in preload links. - $breadcrumbs_xpath = ilo_get_breadcrumbs_xpath( $processor->get_breadcrumbs() ); if ( isset( $lcp_element_minimum_viewport_width_by_xpath[ $breadcrumbs_xpath ] ) ) { $attributes = array(); foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { @@ -192,6 +204,10 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['attributes'] = $attributes; } } + + if ( $needs_detection ) { + $processor->set_attribute( 'data-ilo-breadcrumbs', $breadcrumbs_xpath ); + } } $buffer = $processor->get_updated_html(); diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 506a88b6cb..2e396b1af6 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -423,13 +423,12 @@ function ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $curr * * @see ilo_get_needed_minimum_viewport_widths() * - * @param string $slug URL metrics slug. + * @param array $url_metrics URL metrics slug. * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. */ -function ilo_get_needed_minimum_viewport_widths_now_for_slug( string $slug ): array { - $post = ilo_get_url_metrics_post( $slug ); +function ilo_get_needed_minimum_viewport_widths_now_for_slug( array $url_metrics ): array { return ilo_get_needed_minimum_viewport_widths( - $post instanceof WP_Post ? ilo_parse_stored_url_metrics( $post ) : array(), + $url_metrics, microtime( true ), ilo_get_breakpoint_max_widths(), ilo_get_url_metrics_breakpoint_sample_size(), diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 617cde3fa8..e1dde83925 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -131,23 +131,9 @@ function ilo_register_endpoint() { 'type' => 'bool', ), 'breadcrumbs' => array( - 'type' => 'array', + 'type' => 'string', 'required' => true, - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'tag' => array( - 'type' => 'string', - 'required' => true, - 'pattern' => '^[a-zA-Z0-9-]+$', - ), - 'index' => array( - 'type' => 'int', - 'required' => true, - 'minimum' => 0, - ), - ), - ), + 'pattern' => '^(/\*\[\d+\]\[self::.+?\])+$', // e.g. `/*[1][self::html]/*[2][self::body]`. ), 'intersectionRatio' => array( 'type' => 'number', @@ -176,7 +162,15 @@ function ilo_register_endpoint() { * @return WP_REST_Response|WP_Error Response. */ function ilo_handle_rest_request( WP_REST_Request $request ) { - $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths_now_for_slug( $request->get_param( 'slug' ) ); + $post = ilo_get_url_metrics_post( $request->get_param( 'slug' ) ); + + $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( + $post ? ilo_parse_stored_url_metrics( $post ) : array(), + microtime( true ), + ilo_get_breakpoint_max_widths(), + ilo_get_url_metrics_breakpoint_sample_size(), + ilo_get_url_metric_freshness_ttl() + ); if ( ! ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { return new WP_Error( 'no_url_metric_needed', From 0bbe2165da6f5d695ad4054b7fd2021a39561ce7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 4 Dec 2023 11:52:56 -0800 Subject: [PATCH 135/371] Replace breadcrumbs with xpath where relevant --- .../detection/detect.js | 24 +++++++------------ .../optimization.php | 14 +++++------ .../storage/data.php | 7 +++--- .../storage/rest-api.php | 2 +- 4 files changed, 20 insertions(+), 27 deletions(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index a994d06eee..b1d371d5e2 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -78,17 +78,11 @@ function error( ...message ) { console.error( consoleLogPrefix, ...message ); } -/** - * @typedef {Object} Breadcrumb - * @property {number} index - Index of element among sibling elements. - * @property {string} tag - Tag name. - */ - /** * @typedef {Object} ElementMetrics * @property {boolean} isLCP - Whether it is the LCP candidate. * @property {boolean} isLCPCandidate - Whether it is among the LCP candidates. - * @property {Breadcrumb[]} breadcrumbs - Breadcrumbs. + * @property {string} xpath - XPath. * @property {number} intersectionRatio - Intersection ratio. * @property {DOMRectReadOnly} intersectionRect - Intersection rectangle. * @property {DOMRectReadOnly} boundingClientRect - Bounding client rectangle. @@ -209,7 +203,7 @@ export default async function detect( { // TODO: This query no longer needs to be done as early as possible since the server is adding the breadcrumbs. const breadcrumbedImages = doc.body.querySelectorAll( - 'img[data-ilo-breadcrumbs]' // TODO: Or 'data-ilo-xpath'. + 'img[data-ilo-xpath]' ); // We do the same for elements with background images which are not data: URLs. @@ -227,9 +221,9 @@ export default async function detect( { ].map( /** * @param {HTMLElement} element - * @return {[HTMLElement, string]} Tuple of element and its breadcrumbs. + * @return {[HTMLElement, string]} Tuple of element and its XPath. */ - ( element ) => [ element, element.dataset.iloBreadcrumbs ] // TODO: Rename to iloXpath. + ( element ) => [ element, element.dataset.iloXpath ] ) ); @@ -343,12 +337,10 @@ export default async function detect( { const lcpMetric = lcpMetricCandidates.at( -1 ); for ( const elementIntersection of elementIntersections ) { - const breadcrumbs = breadcrumbedElementsMap.get( - elementIntersection.target - ); - if ( ! breadcrumbs ) { + const xpath = breadcrumbedElementsMap.get( elementIntersection.target ); + if ( ! xpath ) { if ( isDebug ) { - error( 'Unable to look up breadcrumbs for element' ); + error( 'Unable to look up XPath for element' ); } continue; } @@ -364,7 +356,7 @@ export default async function detect( { lcpMetricCandidate.entries[ 0 ]?.element === elementIntersection.target ), - breadcrumbs, + xpath, intersectionRatio: elementIntersection.intersectionRatio, intersectionRect: elementIntersection.intersectionRect, boundingClientRect: elementIntersection.boundingClientRect, diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 766f07a0b9..8e870d94a5 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -86,7 +86,7 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt * Gets XPath from a breadcrumbs array. * * @param array $breadcrumbs Breadcrumbs. - * @return string Breadcrumb XPath. + * @return string XPath. */ function ilo_get_breadcrumbs_xpath( array $breadcrumbs ): string { return implode( @@ -143,7 +143,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $lcp_element_minimum_viewport_width_by_xpath = array(); foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { if ( false !== $lcp_element ) { - $lcp_element_minimum_viewport_width_by_xpath[ $lcp_element['breadcrumbs'] ][] = $minimum_viewport_width; // TODO: Rename 'breadcrumbs' to 'xpath'. + $lcp_element_minimum_viewport_width_by_xpath[ $lcp_element['xpath'] ][] = $minimum_viewport_width; } } @@ -171,10 +171,10 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { continue; } - $breadcrumbs_xpath = ilo_get_breadcrumbs_xpath( $processor->get_breadcrumbs() ); + $xpath = ilo_get_breadcrumbs_xpath( $processor->get_breadcrumbs() ); // Ensure the fetchpriority attribute is set on the element properly. - if ( $common_lcp_element && $breadcrumbs_xpath === $common_lcp_element['breadcrumbs'] ) { // TODO: Rename 'breadcrumbs' to 'xpath'. + if ( $common_lcp_element && $xpath === $common_lcp_element['xpath'] ) { if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); } else { @@ -195,18 +195,18 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { } // Capture the attributes from the LCP elements to use in preload links. - if ( isset( $lcp_element_minimum_viewport_width_by_xpath[ $breadcrumbs_xpath ] ) ) { + if ( isset( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] ) ) { $attributes = array(); foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin', 'integrity' ) as $attr_name ) { $attributes[ $attr_name ] = $processor->get_attribute( $attr_name ); } - foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $breadcrumbs_xpath ] as $minimum_viewport_width ) { + foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] as $minimum_viewport_width ) { $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['attributes'] = $attributes; } } if ( $needs_detection ) { - $processor->set_attribute( 'data-ilo-breadcrumbs', $breadcrumbs_xpath ); + $processor->set_attribute( 'data-ilo-xpath', $xpath ); } } $buffer = $processor->get_updated_html(); diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 2e396b1af6..2a10c61db2 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -325,10 +325,10 @@ function ilo_get_lcp_elements_by_minimum_viewport_widths( array $grouped_url_met continue; } - $i = array_search( $element['breadcrumbs'], $seen_breadcrumbs, true ); + $i = array_search( $element['xpath'], $seen_breadcrumbs, true ); if ( false === $i ) { $i = count( $seen_breadcrumbs ); - $seen_breadcrumbs[ $i ] = $element['breadcrumbs']; + $seen_breadcrumbs[ $i ] = $element['xpath']; $breadcrumb_counts[ $i ] = 0; } @@ -361,7 +361,7 @@ static function ( $lcp_element ) use ( &$prev_lcp_element ) { ( is_array( $prev_lcp_element ) && is_array( $lcp_element ) ? // This breakpoint and previous breakpoint had LCP element, and they were not the same element. - $prev_lcp_element['breadcrumbs'] !== $lcp_element['breadcrumbs'] + $prev_lcp_element['xpath'] !== $lcp_element['xpath'] : // This LCP element and the last LCP element were not the same. In this case, either variable may be // false or an array, but both cannot be an array. If both are false, we don't want to include since @@ -422,6 +422,7 @@ function ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $curr * @access private * * @see ilo_get_needed_minimum_viewport_widths() + * @todo This is not being used at the moment. * * @param array $url_metrics URL metrics slug. * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index e1dde83925..647d6bfac7 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -130,7 +130,7 @@ function ilo_register_endpoint() { 'isLCPCandidate' => array( 'type' => 'bool', ), - 'breadcrumbs' => array( + 'xpath' => array( 'type' => 'string', 'required' => true, 'pattern' => '^(/\*\[\d+\]\[self::.+?\])+$', // e.g. `/*[1][self::html]/*[2][self::body]`. From 1da6d5d861549cca05ad4364157fe9946222afa4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 4 Dec 2023 12:12:16 -0800 Subject: [PATCH 136/371] Inject detection script during optimization --- .../image-loading-optimization/detection.php | 32 +++------------- .../optimization.php | 37 ++++++++++--------- 2 files changed, 25 insertions(+), 44 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 4e619ffa78..288d45714a 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -15,30 +15,11 @@ * * @since n.e.x.t * @access private + * + * @param string $slug URL metrics slug. + * @param array $needed_minimum_viewport_widths Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. */ -function ilo_print_detection_script() { - if ( ! ilo_can_optimize_response() ) { - return; - } - - $query_vars = ilo_get_normalized_query_vars(); - $slug = ilo_get_url_metrics_slug( $query_vars ); - $post = ilo_get_url_metrics_post( $slug ); - $microtime = microtime( true ); - - // TODO: Eliminate this conditional in favor of calling ilo_print_detection_script() inside of ilo_optimize_template_output_buffer() if $needs_detection. - // Abort if we already have all the sample size we need for all breakpoints. - $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( - $post ? ilo_parse_stored_url_metrics( $post ) : array(), - microtime( true ), - ilo_get_breakpoint_max_widths(), - ilo_get_url_metrics_breakpoint_sample_size(), - ilo_get_url_metric_freshness_ttl() - ); - if ( ! ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { - return; - } - +function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_widths ): string { /** * Filters the time window between serve time and run time in which loading detection is allowed to run. * @@ -55,7 +36,7 @@ function ilo_print_detection_script() { $detection_time_window = apply_filters( 'perflab_image_loading_detection_time_window', 5000 ); $detect_args = array( - 'serveTime' => $microtime * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. + 'serveTime' => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. 'detectionTimeWindow' => $detection_time_window, 'isDebug' => WP_DEBUG, 'restApiEndpoint' => rest_url( ILO_REST_API_NAMESPACE . ILO_URL_METRICS_ROUTE ), @@ -65,7 +46,7 @@ function ilo_print_detection_script() { 'neededMinimumViewportWidths' => $needed_minimum_viewport_widths, 'storageLockTTL' => ilo_get_url_metric_storage_lock_ttl(), ); - wp_print_inline_script_tag( + return wp_get_inline_script_tag( sprintf( 'import detect from %s; detect( %s );', wp_json_encode( add_query_arg( 'ver', IMAGE_LOADING_OPTIMIZATION_VERSION, plugin_dir_url( __FILE__ ) . 'detection/detect.js' ) ), @@ -74,4 +55,3 @@ function ilo_print_detection_script() { array( 'type' => 'module' ) ); } -add_action( 'wp_print_footer_scripts', 'ilo_print_detection_script' ); diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 8e870d94a5..54d1f709b0 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -107,7 +107,6 @@ static function ( $breadcrumb ) { * * @since n.e.x.t * @access private - * @todo This should also inject the detection script currently output via ilo_print_detection_script(). * * @param string $buffer Template output buffer. * @return string Filtered template output buffer. @@ -118,22 +117,17 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $url_metrics = $post ? ilo_parse_stored_url_metrics( $post ) : array(); - // Abort if we already have all the sample size we need for all breakpoints. - // TODO: Also inject detection script from ilo_print_detection_script() when this is true instead of printing at wp_footer. - $needs_detection = ( - ! $post - || - ilo_needs_url_metric_for_breakpoint( - ilo_get_needed_minimum_viewport_widths( - $url_metrics, - microtime( true ), - ilo_get_breakpoint_max_widths(), - ilo_get_url_metrics_breakpoint_sample_size(), - ilo_get_url_metric_freshness_ttl() - ) - ) + $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( + $url_metrics, + microtime( true ), + ilo_get_breakpoint_max_widths(), + ilo_get_url_metrics_breakpoint_sample_size(), + ilo_get_url_metric_freshness_ttl() ); + // 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 ); + $breakpoint_max_widths = ilo_get_breakpoint_max_widths(); $url_metrics_grouped_by_breakpoint = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths ); $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics_grouped_by_breakpoint ); @@ -213,11 +207,18 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // Inject any preload links at the end of the HEAD. In the future, WP_HTML_Processor could be used to do this injection. // However, given the simple replacement here this is not essential. - $preload_links = ilo_construct_preload_links( $lcp_elements_by_minimum_viewport_widths ); - if ( $preload_links ) { + $head_injection = ilo_construct_preload_links( $lcp_elements_by_minimum_viewport_widths ); + + // Inject detection script. + // TODO: When optimizing above, if we find that there is a stored LCP element but it fails to match, it should perhaps set $needs_detection to true and send the request with an override nonce. + if ( $needs_detection ) { + $head_injection .= ilo_get_detection_script( $slug, $needed_minimum_viewport_widths ); + } + + if ( $head_injection ) { $buffer = preg_replace( '#(?=)#i', - $preload_links, + $head_injection, $buffer, 1 ); From c58609c65b817a545c690bfacc485e213f6250d1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 4 Dec 2023 12:19:45 -0800 Subject: [PATCH 137/371] Move XPath generator to ILO_HTML_Tag_Processor class --- .../class-ilo-html-tag-processor.php | 23 ++++++++++++++----- .../optimization.php | 22 +----------------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index d0064b88bb..7aa4038115 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -7,7 +7,7 @@ */ /** - * Processor leveraging WP_HTML_Tag_Processor which gathers breadcrumbs which can be queried while iterating the open_tags() generator. + * Processor leveraging WP_HTML_Tag_Processor which gathers breadcrumbs which can be obtained as XPath while iterating the open_tags() generator. * * Eventually this class should be made largely obsolete once `WP_HTML_Processor` is fully implemented to support all HTML tags. * @@ -228,14 +228,25 @@ public function open_tags(): Generator { * * A breadcrumb consists of a tag name and its sibling index. * - * @return array Breadcrumbs. + * @return Generator Breadcrumb. */ - public function get_breadcrumbs(): array { - $breadcrumbs = array(); + private function get_breadcrumbs(): Generator { foreach ( $this->open_stack_tags as $i => $breadcrumb_tag_name ) { - $breadcrumbs[] = array( $breadcrumb_tag_name, $this->open_stack_indices[ $i ] ); + yield array( $breadcrumb_tag_name, $this->open_stack_indices[ $i ] ); } - return $breadcrumbs; + } + + /** + * Gets XPath for the current node. + * + * @return string XPath. + */ + public function get_xpath(): string { + $xpath = ''; + foreach ( $this->get_breadcrumbs() as list( $tag_name, $index ) ) { + $xpath .= sprintf( '/*[%d][self::%s]', $index, $tag_name ); + } + return $xpath; } /** diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 54d1f709b0..6f96f150e3 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -82,26 +82,6 @@ function ilo_construct_preload_links( array $lcp_images_by_minimum_viewport_widt return implode( '', $preload_links ); } -/** - * Gets XPath from a breadcrumbs array. - * - * @param array $breadcrumbs Breadcrumbs. - * @return string XPath. - */ -function ilo_get_breadcrumbs_xpath( array $breadcrumbs ): string { - return implode( - '', - array_map( - static function ( $breadcrumb ) { - // 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]`. - return sprintf( '/*[%d][self::%s]', $breadcrumb[1], $breadcrumb[0] ); - }, - $breadcrumbs - ) - ); -} - /** * Optimizes template output buffer. * @@ -165,7 +145,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { continue; } - $xpath = ilo_get_breadcrumbs_xpath( $processor->get_breadcrumbs() ); + $xpath = $processor->get_xpath(); // Ensure the fetchpriority attribute is set on the element properly. if ( $common_lcp_element && $xpath === $common_lcp_element['xpath'] ) { From 4468d939f4544fd3d3027f679d4b744bdf0b382d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 4 Dec 2023 13:14:39 -0800 Subject: [PATCH 138/371] Remove unused function --- .../class-ilo-html-tag-processor.php | 2 +- .../storage/data.php | 24 ------------------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 7aa4038115..dfb7cbfc25 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -237,7 +237,7 @@ private function get_breadcrumbs(): Generator { } /** - * Gets XPath for the current node. + * Gets XPath for the current open tag. * * @return string XPath. */ diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 2a10c61db2..35eda471bc 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -413,30 +413,6 @@ function ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $curr return $needed_minimum_viewport_widths; } -/** - * Gets needed minimum viewport widths by slug for the current time. - * - * This is a convenience wrapper on top of ilo_get_needed_minimum_viewport_widths() to reduce code duplication. - * - * @since n.e.x.t - * @access private - * - * @see ilo_get_needed_minimum_viewport_widths() - * @todo This is not being used at the moment. - * - * @param array $url_metrics URL metrics slug. - * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. - */ -function ilo_get_needed_minimum_viewport_widths_now_for_slug( array $url_metrics ): array { - return ilo_get_needed_minimum_viewport_widths( - $url_metrics, - microtime( true ), - ilo_get_breakpoint_max_widths(), - ilo_get_url_metrics_breakpoint_sample_size(), - ilo_get_url_metric_freshness_ttl() - ); -} - /** * Checks whether there is a URL metric needed for one of the breakpoints. * From 3102f4c5ef8fbf4cd586930b621fe6e769577796 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 4 Dec 2023 13:20:38 -0800 Subject: [PATCH 139/371] Improve phpdoc for get_xpath --- .../class-ilo-html-tag-processor.php | 3 +++ modules/images/image-loading-optimization/storage/rest-api.php | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index dfb7cbfc25..65a4516f23 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -239,6 +239,9 @@ private function get_breadcrumbs(): Generator { /** * 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]`. + * * @return string XPath. */ public function get_xpath(): string { diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 647d6bfac7..607e094717 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -133,7 +133,7 @@ function ilo_register_endpoint() { 'xpath' => array( 'type' => 'string', 'required' => true, - 'pattern' => '^(/\*\[\d+\]\[self::.+?\])+$', // e.g. `/*[1][self::html]/*[2][self::body]`. + 'pattern' => '^(/\*\[\d+\]\[self::.+?\])+$', // See ILO_HTML_Tag_Processor::get_xpath() for format. ), 'intersectionRatio' => array( 'type' => 'number', From 255aa45539bc83841eeca03679b6ba269c7534ca Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 4 Dec 2023 13:24:10 -0800 Subject: [PATCH 140/371] Update data attribute in comment --- modules/images/image-loading-optimization/detection/detect.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index b1d371d5e2..142a765e90 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -209,7 +209,7 @@ export default async function detect( { // We do the same for elements with background images which are not data: URLs. // TODO: Re-enable background image support when server-side is implemented. // const breadcrumbedElementsWithBackgrounds = Array.from( - // doc.body.querySelectorAll( '[data-ilo-breadcrumbs][style*="background"]' ) + // doc.body.querySelectorAll( '[data-ilo-xpath][style*="background"]' ) // ).filter( ( /** @type {Element} */ el ) => // /url\(\s*['"](?!=data:)/.test( el.style.backgroundImage ) // ); From 185ebf6ceb449d164a875c5b5c745f28cdffa4af Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 5 Dec 2023 15:26:11 -0800 Subject: [PATCH 141/371] Clarify LCP element language in comment --- modules/images/image-loading-optimization/optimization.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index eea4c24dda..5127181df8 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -140,7 +140,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // All breakpoints share the same LCP element (or all have none at all). 1 === count( $lcp_elements_by_minimum_viewport_widths ) && - // The breakpoints don't share a common lack of an LCP element. + // The breakpoints don't share a common lack of an LCP image. ! in_array( false, $lcp_elements_by_minimum_viewport_widths, true ) && // All breakpoints have URL metrics being reported. From 1d259b5ae89b36facf4c0d0686ad7b9d4e945e37 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 5 Dec 2023 15:28:02 -0800 Subject: [PATCH 142/371] Add comment explaining why for loop is used Co-authored-by: Felix Arntz --- modules/images/image-loading-optimization/optimization.php | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 5127181df8..0d619635d7 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -36,6 +36,7 @@ function ilo_maybe_add_template_output_buffer_filter() { function ilo_construct_preload_links( array $lcp_images_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 ); for ( $i = 0, $len = count( $minimum_viewport_widths ); $i < $len; $i++ ) { $lcp_element = $lcp_images_by_minimum_viewport_widths[ $minimum_viewport_widths[ $i ] ]; From 518367857b61eccc422600e4f435509a40e5fee9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 5 Dec 2023 10:33:38 -0800 Subject: [PATCH 143/371] Add tests for detection.php and fix prefix on filter --- .../image-loading-optimization/detection.php | 2 +- .../detection-tests.php | 76 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 tests/modules/images/image-loading-optimization/detection-tests.php diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 288d45714a..7eb999ec68 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -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. diff --git a/tests/modules/images/image-loading-optimization/detection-tests.php b/tests/modules/images/image-loading-optimization/detection-tests.php new file mode 100644 index 0000000000..63a8ccb8e3 --- /dev/null +++ b/tests/modules/images/image-loading-optimization/detection-tests.php @@ -0,0 +1,76 @@ +}> + */ + public function data_provider_ilo_get_detection_script(): array { + return array( + 'no_filters' => array( + 'set_up' => static function () {}, + 'expected_exports' => array( + 'detectionTimeWindow' => 5000, + 'storageLockTTL' => MINUTE_IN_SECONDS, + ), + ), + 'filtered' => array( + 'set_up' => static function () { + add_filter( + 'ilo_detection_time_window', + static function (): int { + return 2500; + } + ); + add_filter( + 'ilo_url_metric_storage_lock_ttl', + static function (): int { + return HOUR_IN_SECONDS; + } + ); + }, + 'expected_exports' => array( + 'detectionTimeWindow' => 2500, + 'storageLockTTL' => HOUR_IN_SECONDS, + ), + ), + ); + } + + /** + * Make sure the expected script is printed. + * + * @test + * @dataProvider data_provider_ilo_get_detection_script + * @covers ::ilo_get_detection_script + * + * @param Closure $set_up Set up callback. + * @param array}> $expected_exports Expected exports. + */ + public function test_ilo_get_detection_script_returns_script( Closure $set_up, array $expected_exports ) { + $set_up(); + $slug = ilo_get_url_metrics_slug( array( 'p' => '1' ) ); + $needed_minimum_viewport_widths = array( + array( 480, false ), + array( 600, false ), + array( 782, true ) + ); + $script = ilo_get_detection_script( $slug, $needed_minimum_viewport_widths ); + + $this->assertStringContainsString( '' + ), + 'bad_nonce' => array( + 'nonce' => 'not even a hash' + ), + 'invalid_nonce' => array( + 'nonce' => ilo_get_url_metrics_storage_nonce( ilo_get_url_metrics_slug( array( 'different' => 'query vars' ) ) ) + ), + 'invalid_viewport_type' => array( + 'viewport' => '640x480' + ), + 'invalid_viewport_values' => array( + 'viewport' => array( 'breadth' => 100, 'depth' => 200 ) + ), + 'invalid_elements_type' => array( + 'elements' => 'bad' + ), + 'invalid_elements_prop_is_lcp' => array( + 'elements' => array( + array_merge( + $valid_element, + array( + 'isLCP' => 'totally!', + ) + ) + ), + ), + 'invalid_elements_prop_xpath' => array( + 'elements' => array( + array_merge( + $valid_element, + array( + 'xpath' => 'html > body img', + ) + ) + ), + ), + 'invalid_elements_prop_intersection_ratio' => array( + 'elements' => array( + array_merge( + $valid_element, + array( + 'intersectionRatio' => -1, + ) + ) + ), + ), + ) + ); + } + + /** + * Test bad params. + * + * @test + * @covers ::ilo_register_endpoint + * @covers ::ilo_handle_rest_request + * @dataProvider data_provider_invalid_params + */ + public function test_rest_request_bad_params( array $params ) { + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] ); + } + + /** + * Test REST API request when metric storage is locked. + * + * @test + * @covers ::ilo_register_endpoint + * @covers ::ilo_handle_rest_request + */ + public function test_rest_request_locked() { + ilo_set_url_metric_storage_lock(); + + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $this->get_valid_params() ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status() ); + $this->assertSame( 'url_metric_storage_locked', $response->get_data()['code'] ); + } + + + /** + * Test sending viewport data that isn't needed. + * + * @test + * @covers ::ilo_register_endpoint + * @covers ::ilo_handle_rest_request + */ + public function test_rest_request_breakpoint_not_needed() { + add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); + + // First fully populate the sample for a given breakpoint. + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + $viewport_widths = array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ); + foreach ( $viewport_widths as $breakpoint_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + $valid_params = $this->get_valid_params(); + $valid_params['viewport']['width'] = $breakpoint_width; + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $valid_params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + } + } + + // The next request with the same sample size will be rejected. + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $this->get_valid_params() ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status() ); + } + + /** + * Gets valid params. + * + * @return array + */ + private function get_valid_params(): array { + $slug = ilo_get_url_metrics_slug( array() ); + return array_merge( + array( + 'url' => home_url( '/' ), + 'slug' => $slug, + 'nonce' => ilo_get_url_metrics_storage_nonce( $slug ), + ), + $this->get_sample_validated_url_metric() + ); + } + + /** + * Gets sample validated URL metric data. + * + * @return array + */ + private function get_sample_validated_url_metric(): array { + return array( + 'viewport' => array( + 'width' => 480, + 'height' => 640, + ), + 'elements' => array( + array( + 'isLCP' => true, + 'isLCPCandidate' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]/*[1][self::MAIN]/*[0][self::DIV]/*[0][self::FIGURE]/*[0][self::IMG]', + 'intersectionRatio' => 1, + ), + ), + ); + } +} From 5c83545e1e06390ecb29fd08ed4db3f2aee127dd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 6 Dec 2023 09:56:29 -0800 Subject: [PATCH 150/371] Discontinue excluding all of WordPress-Extra sniffs from tests and fix issues --- phpcs.xml.dist | 21 ++++++- tests/bootstrap.php | 2 +- tests/load-tests.php | 14 ++--- .../audit-autoloaded-options-test.php | 1 - .../dominant-color-test.php | 4 +- .../detection-tests.php | 16 ++--- .../storage/lock-tests.php | 12 ++-- .../storage/post-type-tests.php | 26 ++++---- .../storage/rest-api-tests.php | 59 ++++++++++--------- .../images/webp-uploads/helper-tests.php | 2 +- .../images/webp-uploads/load-tests.php | 6 +- .../images/webp-uploads/rest-api-tests.php | 2 +- .../audit-enqueued-assets-helper-test.php | 4 +- .../audit-enqueued-assets-test.php | 2 - tests/server-timing/load-tests.php | 48 +++++++++++---- .../perflab-server-timing-tests.php | 22 +++---- .../something/demo-module-2/activate.php | 2 +- .../something/demo-module-2/deactivate.php | 2 +- .../class-audit-assets-transients-set.php | 1 - .../class-site-health-mock-responses.php | 1 - tests/utils/Constraint/ImageHasSizeSource.php | 1 - tests/utils/Constraint/ImageHasSource.php | 1 - tests/utils/TestCase/ImagesTestCase.php | 2 +- 23 files changed, 142 insertions(+), 109 deletions(-) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 9b34e57217..af746663c2 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -6,9 +6,7 @@ - - tests/* - + @@ -73,5 +71,22 @@ tests/utils/* + + + tests/* + + + tests/* + + + tests/* + + + tests/* + + + tests/* + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9a7fc87a18..3018bda910 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -41,7 +41,7 @@ require_once $_test_root . '/includes/functions.php'; tests_add_filter( 'plugins_loaded', - static function() { + static function () { require_once TESTS_PLUGIN_DIR . '/admin/load.php'; require_once TESTS_PLUGIN_DIR . '/admin/server-timing.php'; $module_files = glob( TESTS_PLUGIN_DIR . '/modules/*/*/load.php' ); diff --git a/tests/load-tests.php b/tests/load-tests.php index 5f2ceedc5c..6c7dd2fc2d 100644 --- a/tests/load-tests.php +++ b/tests/load-tests.php @@ -78,10 +78,10 @@ public function test_perflab_get_module_settings() { $has_passed_default = false; add_filter( 'default_option_' . PERFLAB_MODULES_SETTING, - static function( $default, $option, $passed_default ) use ( &$has_passed_default ) { + static function ( $current_default, $option, $passed_default ) use ( &$has_passed_default ) { // This callback just records whether there is a default value being passed. $has_passed_default = $passed_default; - return $default; + return $current_default; }, 10, 3 @@ -134,7 +134,7 @@ public function test_perflab_get_active_modules() { $expected_active_modules = array_keys( array_filter( perflab_get_modules_setting_default(), - static function( $module_settings ) { + static function ( $module_settings ) { return $module_settings['enabled']; } ) @@ -158,7 +158,7 @@ public function test_perflab_get_generator_content() { array_pop( $active_modules ); add_filter( 'perflab_active_modules', - static function() use ( $active_modules ) { + static function () use ( $active_modules ) { return $active_modules; } ); @@ -229,7 +229,7 @@ private function get_expected_default_option() { $default_enabled_modules = require PERFLAB_PLUGIN_DIR_PATH . 'default-enabled-modules.php'; return array_reduce( $default_enabled_modules, - static function( $module_settings, $module_dir ) { + static function ( $module_settings, $module_dir ) { $module_settings[ $module_dir ] = array( 'enabled' => true ); return $module_settings; }, @@ -312,13 +312,13 @@ private function set_up_mock_filesystem() { add_filter( 'filesystem_method_file', - static function() { + static function () { return __DIR__ . '/utils/Filesystem/WP_Filesystem_MockFilesystem.php'; } ); add_filter( 'filesystem_method', - static function() { + static function () { return 'MockFilesystem'; } ); diff --git a/tests/modules/database/audit-autoloaded-options/audit-autoloaded-options-test.php b/tests/modules/database/audit-autoloaded-options/audit-autoloaded-options-test.php index 3a4a238d00..6a2586731e 100644 --- a/tests/modules/database/audit-autoloaded-options/audit-autoloaded-options-test.php +++ b/tests/modules/database/audit-autoloaded-options/audit-autoloaded-options-test.php @@ -86,4 +86,3 @@ public static function delete_autoloaded_option() { delete_option( self::AUTOLOADED_OPTION_KEY ); } } - diff --git a/tests/modules/images/dominant-color-images/dominant-color-test.php b/tests/modules/images/dominant-color-images/dominant-color-test.php index 1bf6c77cb4..02ebf80dc1 100644 --- a/tests/modules/images/dominant-color-images/dominant-color-test.php +++ b/tests/modules/images/dominant-color-images/dominant-color-test.php @@ -92,7 +92,7 @@ public function test_tag_add_adjust_to_image_attributes( $image_path, $expected_ $filtered_image_tags_added = dominant_color_img_tag_add_dominant_color( $filtered_image_mock_lazy_load, 'the_content', $attachment_id ); - $this->assertStringContainsString( 'data-has-transparency="' . json_encode( $expected_transparency ) . '"', $filtered_image_tags_added ); + $this->assertStringContainsString( 'data-has-transparency="' . wp_json_encode( $expected_transparency ) . '"', $filtered_image_tags_added ); foreach ( $expected_color as $color ) { if ( false !== strpos( $color, $filtered_image_tags_added ) ) { @@ -210,7 +210,7 @@ public function data_provider_dominant_color_check_inline_style() { public function test_dominant_color_update_attachment_image_attributes( $style_attr, $expected ) { $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/dominant-color-images/red.jpg' ); - $attachment_image = wp_get_attachment_image( $attachment_id, 'full', '', array( "style" => $style_attr ) ); + $attachment_image = wp_get_attachment_image( $attachment_id, 'full', '', array( 'style' => $style_attr ) ); $this->assertStringContainsString( $expected, $attachment_image ); } diff --git a/tests/modules/images/image-loading-optimization/detection-tests.php b/tests/modules/images/image-loading-optimization/detection-tests.php index 259b9616bb..1fe1db6235 100644 --- a/tests/modules/images/image-loading-optimization/detection-tests.php +++ b/tests/modules/images/image-loading-optimization/detection-tests.php @@ -16,14 +16,14 @@ class Image_Loading_Optimization_Detection_Tests extends WP_UnitTestCase { public function data_provider_ilo_get_detection_script(): array { return array( 'unfiltered' => array( - 'set_up' => static function () {}, + 'set_up' => static function () {}, 'expected_exports' => array( 'detectionTimeWindow' => 5000, - 'storageLockTTL' => MINUTE_IN_SECONDS, + 'storageLockTTL' => MINUTE_IN_SECONDS, ), ), - 'filtered' => array( - 'set_up' => static function () { + 'filtered' => array( + 'set_up' => static function () { add_filter( 'ilo_detection_time_window', static function (): int { @@ -39,7 +39,7 @@ static function (): int { }, 'expected_exports' => array( 'detectionTimeWindow' => 2500, - 'storageLockTTL' => HOUR_IN_SECONDS, + 'storageLockTTL' => HOUR_IN_SECONDS, ), ), ); @@ -57,13 +57,13 @@ static function (): int { */ public function test_ilo_get_detection_script_returns_script( Closure $set_up, array $expected_exports ) { $set_up(); - $slug = ilo_get_url_metrics_slug( array( 'p' => '1' ) ); + $slug = ilo_get_url_metrics_slug( array( 'p' => '1' ) ); $needed_minimum_viewport_widths = array( array( 480, false ), array( 600, false ), - array( 782, true ) + array( 782, true ), ); - $script = ilo_get_detection_script( $slug, $needed_minimum_viewport_widths ); + $script = ilo_get_detection_script( $slug, $needed_minimum_viewport_widths ); $this->assertStringContainsString( '' + 'bad_slug' => array( + 'slug' => '', ), - 'bad_nonce' => array( - 'nonce' => 'not even a hash' + 'bad_nonce' => array( + 'nonce' => 'not even a hash', ), - 'invalid_nonce' => array( - 'nonce' => ilo_get_url_metrics_storage_nonce( ilo_get_url_metrics_slug( array( 'different' => 'query vars' ) ) ) + 'invalid_nonce' => array( + 'nonce' => ilo_get_url_metrics_storage_nonce( ilo_get_url_metrics_slug( array( 'different' => 'query vars' ) ) ), ), - 'invalid_viewport_type' => array( - 'viewport' => '640x480' + 'invalid_viewport_type' => array( + 'viewport' => '640x480', ), - 'invalid_viewport_values' => array( - 'viewport' => array( 'breadth' => 100, 'depth' => 200 ) + 'invalid_viewport_values' => array( + 'viewport' => array( + 'breadth' => 100, + 'depth' => 200, + ), ), - 'invalid_elements_type' => array( - 'elements' => 'bad' + 'invalid_elements_type' => array( + 'elements' => 'bad', ), - 'invalid_elements_prop_is_lcp' => array( + 'invalid_elements_prop_is_lcp' => array( 'elements' => array( array_merge( $valid_element, array( 'isLCP' => 'totally!', ) - ) + ), ), ), - 'invalid_elements_prop_xpath' => array( + 'invalid_elements_prop_xpath' => array( 'elements' => array( array_merge( $valid_element, array( 'xpath' => 'html > body img', ) - ) + ), ), ), 'invalid_elements_prop_intersection_ratio' => array( @@ -116,9 +119,9 @@ function ( $params ) { array_merge( $valid_element, array( - 'intersectionRatio' => -1, + 'intersectionRatio' => - 1, ) - ) + ), ), ), ) @@ -175,9 +178,9 @@ public function test_rest_request_breakpoint_not_needed() { $viewport_widths = array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ); foreach ( $viewport_widths as $breakpoint_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - $valid_params = $this->get_valid_params(); + $valid_params = $this->get_valid_params(); $valid_params['viewport']['width'] = $breakpoint_width; - $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request = new WP_REST_Request( 'POST', self::ROUTE ); $request->set_body_params( $valid_params ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -221,10 +224,10 @@ private function get_sample_validated_url_metric(): array { ), 'elements' => array( array( - 'isLCP' => true, - 'isLCPCandidate' => true, - 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]/*[1][self::MAIN]/*[0][self::DIV]/*[0][self::FIGURE]/*[0][self::IMG]', - 'intersectionRatio' => 1, + 'isLCP' => true, + 'isLCPCandidate' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]/*[1][self::MAIN]/*[0][self::DIV]/*[0][self::FIGURE]/*[0][self::IMG]', + 'intersectionRatio' => 1, ), ), ); diff --git a/tests/modules/images/webp-uploads/helper-tests.php b/tests/modules/images/webp-uploads/helper-tests.php index a4ebe0dd58..9fa6fbbd67 100644 --- a/tests/modules/images/webp-uploads/helper-tests.php +++ b/tests/modules/images/webp-uploads/helper-tests.php @@ -520,7 +520,7 @@ public function test_webp_uploads_in_frontend_body_within_wp_head() { $result = null; add_action( 'wp_head', - static function() use ( &$result ) { + static function () use ( &$result ) { $result = webp_uploads_in_frontend_body(); } ); diff --git a/tests/modules/images/webp-uploads/load-tests.php b/tests/modules/images/webp-uploads/load-tests.php index 7251b4aced..cf8ce7e566 100644 --- a/tests/modules/images/webp-uploads/load-tests.php +++ b/tests/modules/images/webp-uploads/load-tests.php @@ -544,7 +544,7 @@ public function it_should_not_replace_the_references_to_a_jpg_image_when_disable add_filter( 'webp_uploads_content_image_mimes', - static function( $mime_types ) { + static function ( $mime_types ) { unset( $mime_types[ array_search( 'image/webp', $mime_types, true ) ] ); return $mime_types; } @@ -733,7 +733,7 @@ public function it_should_prevent_replacing_an_image_uploaded_via_external_sourc add_filter( 'webp_uploads_pre_replace_additional_image_source', - static function() { + static function () { return ''; } ); @@ -899,7 +899,7 @@ public function it_should_not_add_fallback_script_if_content_has_no_updated_imag public function it_should_create_mime_types_for_allowed_sizes_only_via_filter() { add_filter( 'webp_uploads_image_sizes_with_additional_mime_type_support', - static function( $sizes ) { + static function ( $sizes ) { $sizes['allowed_size_400x300'] = true; return $sizes; } diff --git a/tests/modules/images/webp-uploads/rest-api-tests.php b/tests/modules/images/webp-uploads/rest-api-tests.php index b1fe715cc6..2dc5145d7c 100644 --- a/tests/modules/images/webp-uploads/rest-api-tests.php +++ b/tests/modules/images/webp-uploads/rest-api-tests.php @@ -24,7 +24,7 @@ public function it_should_add_sources_to_rest_response() { add_filter( 'webp_uploads_upload_image_mime_transforms', - static function( $transforms ) { + static function ( $transforms ) { $transforms['image/jpeg'] = array( 'image/jpeg', 'image/webp' ); return $transforms; } diff --git a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-helper-test.php b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-helper-test.php index fcf69cae69..d81f38d671 100644 --- a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-helper-test.php +++ b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-helper-test.php @@ -101,12 +101,10 @@ public function test_perflab_aea_get_path_from_resource_url_outside_wp_setup() { $expected_path = WP_CONTENT_DIR . '/themes/test-theme/style.css'; add_filter( 'content_url', - static function( $url ) { + static function ( $url ) { return site_url() . '/content'; } ); $this->assertSame( $expected_path, perflab_aea_get_path_from_resource_url( $test_url ) ); } - } - diff --git a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php index 975944bb17..c4a640b923 100644 --- a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php +++ b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php @@ -78,7 +78,6 @@ public function test_perflab_aea_audit_enqueued_scripts() { ), $transient ); - } /** @@ -287,4 +286,3 @@ public function mock_data_perflab_aea_enqueued_css_assets_test_callback( $number return Site_Health_Mock_Responses::return_aea_enqueued_css_assets_test_callback_more_than_threshold( $number_of_assets ); } } - diff --git a/tests/server-timing/load-tests.php b/tests/server-timing/load-tests.php index 7d6fd3e2c7..dcffb6ccdc 100644 --- a/tests/server-timing/load-tests.php +++ b/tests/server-timing/load-tests.php @@ -25,7 +25,7 @@ public function test_perflab_server_timing_register_metric() { perflab_server_timing_register_metric( 'test-metric', array( - 'measure_callback' => static function( $metric ) { + 'measure_callback' => static function ( $metric ) { $metric->set_value( 100 ); }, 'access_cap' => 'exist', @@ -42,7 +42,7 @@ public function test_perflab_server_timing_use_output_buffer() { } public function test_perflab_wrap_server_timing() { - $cb = static function() { + $cb = static function () { return 123; }; @@ -64,7 +64,7 @@ public function test_perflab_register_server_timing_setting() { // Reset relevant globals. $wp_registered_settings = array(); - $new_allowed_options = array(); + $new_allowed_options = array(); perflab_register_server_timing_setting(); @@ -103,37 +103,61 @@ public function data_perflab_sanitize_server_timing_setting() { ), 'empty list, array' => array( array( 'benchmarking_actions' => array() ), - array( 'benchmarking_actions' => array(), 'output_buffering' => false ), + array( + 'benchmarking_actions' => array(), + 'output_buffering' => false, + ), ), 'empty list, string' => array( array( 'benchmarking_actions' => '' ), - array( 'benchmarking_actions' => array(), 'output_buffering' => false ), + array( + 'benchmarking_actions' => array(), + 'output_buffering' => false, + ), ), 'empty list, string with whitespace' => array( array( 'benchmarking_actions' => ' ' ), - array( 'benchmarking_actions' => array(), 'output_buffering' => false ), + array( + 'benchmarking_actions' => array(), + 'output_buffering' => false, + ), ), 'regular list, array' => array( array( 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ) ), - array( 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), 'output_buffering' => false ), + array( + 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), + 'output_buffering' => false, + ), ), 'regular list, string' => array( array( 'benchmarking_actions' => "after_setup_theme\ninit\nwp_loaded" ), - array( 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), 'output_buffering' => false ), + array( + 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), + 'output_buffering' => false, + ), ), 'regular list, string with whitespace' => array( array( 'benchmarking_actions' => "after_setup_ theme \ninit \n\nwp_loaded\n" ), - array( 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), 'output_buffering' => false ), + array( + 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), + 'output_buffering' => false, + ), ), 'regular list, array with duplicates' => array( array( 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded', 'init' ) ), - array( 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), 'output_buffering' => false ), + array( + 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), + 'output_buffering' => false, + ), ), 'regular list, array with special hook chars' => array( array( 'benchmarking_actions' => array( 'namespace/hookname', 'namespace.hookname' ) ), - array( 'benchmarking_actions' => array( 'namespace/hookname', 'namespace.hookname' ), 'output_buffering' => false ), + array( + 'benchmarking_actions' => array( 'namespace/hookname', 'namespace.hookname' ), + 'output_buffering' => false, + ), ), - 'output buffering enabled' => array( + 'output buffering enabled' => array( array( 'output_buffering' => 'on' ), array( 'output_buffering' => true ), ), diff --git a/tests/server-timing/perflab-server-timing-tests.php b/tests/server-timing/perflab-server-timing-tests.php index 360d4547b1..33d7ca28be 100644 --- a/tests/server-timing/perflab-server-timing-tests.php +++ b/tests/server-timing/perflab-server-timing-tests.php @@ -38,7 +38,7 @@ public function test_register_metric_stores_metrics_and_runs_measure_callback() $this->server_timing->register_metric( 'test-metric', array( - 'measure_callback' => static function() use ( &$called ) { + 'measure_callback' => static function () use ( &$called ) { $called = true; }, 'access_cap' => 'exist', @@ -52,7 +52,7 @@ public function test_register_metric_stores_metrics_and_runs_measure_callback() public function test_register_metric_runs_measure_callback_based_on_access_cap() { $called = false; $args = array( - 'measure_callback' => static function() use ( &$called ) { + 'measure_callback' => static function () use ( &$called ) { $called = true; }, 'access_cap' => 'manage_options', // Admin capability. @@ -130,13 +130,13 @@ public function test_get_header( $expected, $metrics ) { } public function data_get_header() { - $measure_42 = static function( $metric ) { + $measure_42 = static function ( $metric ) { $metric->set_value( 42 ); }; - $measure_300 = static function( $metric ) { + $measure_300 = static function ( $metric ) { $metric->set_value( 300 ); }; - $measure_12point345 = static function( $metric ) { + $measure_12point345 = static function ( $metric ) { $metric->set_value( 12.345 ); }; @@ -188,23 +188,23 @@ public function data_get_header() { } public function get_data_to_test_use_output_buffer() { - $enable_option = static function () { - $option = (array) get_option( PERFLAB_SERVER_TIMING_SETTING ); + $enable_option = static function () { + $option = (array) get_option( PERFLAB_SERVER_TIMING_SETTING ); $option['output_buffering'] = true; update_option( PERFLAB_SERVER_TIMING_SETTING, $option ); }; $disable_option = static function () { - $option = (array) get_option( PERFLAB_SERVER_TIMING_SETTING ); + $option = (array) get_option( PERFLAB_SERVER_TIMING_SETTING ); $option['output_buffering'] = false; update_option( PERFLAB_SERVER_TIMING_SETTING, $option ); }; return array( - 'default' => array( + 'default' => array( 'set_up' => static function () {}, 'expected' => false, ), - 'option-enabled' => array( + 'option-enabled' => array( 'set_up' => $enable_option, 'expected' => true, ), @@ -212,7 +212,7 @@ public function get_data_to_test_use_output_buffer() { 'set_up' => $disable_option, 'expected' => false, ), - 'filter-enabled' => array( + 'filter-enabled' => array( 'set_up' => static function () use ( $disable_option ) { $disable_option(); add_filter( 'perflab_server_timing_use_output_buffer', '__return_true' ); diff --git a/tests/testdata/demo-modules/something/demo-module-2/activate.php b/tests/testdata/demo-modules/something/demo-module-2/activate.php index d49a00348b..8f85b81094 100644 --- a/tests/testdata/demo-modules/something/demo-module-2/activate.php +++ b/tests/testdata/demo-modules/something/demo-module-2/activate.php @@ -6,6 +6,6 @@ * @package performance-lab */ -return static function() { +return static function () { update_option( 'test_demo_module_activation_status', 'activated' ); }; diff --git a/tests/testdata/demo-modules/something/demo-module-2/deactivate.php b/tests/testdata/demo-modules/something/demo-module-2/deactivate.php index 241717d180..b08dcf3c96 100644 --- a/tests/testdata/demo-modules/something/demo-module-2/deactivate.php +++ b/tests/testdata/demo-modules/something/demo-module-2/deactivate.php @@ -6,6 +6,6 @@ * @package performance-lab */ -return static function() { +return static function () { update_option( 'test_demo_module_activation_status', 'deactivated' ); }; diff --git a/tests/testdata/modules/site-health/audit-enqueued-assets/class-audit-assets-transients-set.php b/tests/testdata/modules/site-health/audit-enqueued-assets/class-audit-assets-transients-set.php index b634dcf992..661ae7fe1b 100644 --- a/tests/testdata/modules/site-health/audit-enqueued-assets/class-audit-assets-transients-set.php +++ b/tests/testdata/modules/site-health/audit-enqueued-assets/class-audit-assets-transients-set.php @@ -62,4 +62,3 @@ public static function set_style_transient_with_no_data() { delete_transient( self::STYLES_TRANSIENT ); } } - diff --git a/tests/testdata/modules/site-health/audit-enqueued-assets/class-site-health-mock-responses.php b/tests/testdata/modules/site-health/audit-enqueued-assets/class-site-health-mock-responses.php index 04854898a5..78e42b6a8b 100644 --- a/tests/testdata/modules/site-health/audit-enqueued-assets/class-site-health-mock-responses.php +++ b/tests/testdata/modules/site-health/audit-enqueued-assets/class-site-health-mock-responses.php @@ -177,4 +177,3 @@ public static function return_aea_enqueued_css_assets_test_callback_more_than_th return $result; } } - diff --git a/tests/utils/Constraint/ImageHasSizeSource.php b/tests/utils/Constraint/ImageHasSizeSource.php index 4c690c7286..0688ce6c1e 100644 --- a/tests/utils/Constraint/ImageHasSizeSource.php +++ b/tests/utils/Constraint/ImageHasSizeSource.php @@ -64,5 +64,4 @@ protected function matches( $attachment_id ): bool { return $this->verify_sources( $metadata['sizes'][ $this->size ]['sources'] ); } - } diff --git a/tests/utils/Constraint/ImageHasSource.php b/tests/utils/Constraint/ImageHasSource.php index 2c55f01e28..7ac978d108 100644 --- a/tests/utils/Constraint/ImageHasSource.php +++ b/tests/utils/Constraint/ImageHasSource.php @@ -136,5 +136,4 @@ protected function verify_sources( $sources ) { protected function failureDescription( $attachment_id ): string { return sprintf( 'an image %s', $this->toString() ); } - } diff --git a/tests/utils/TestCase/ImagesTestCase.php b/tests/utils/TestCase/ImagesTestCase.php index b5e4715e32..a5a62f0750 100644 --- a/tests/utils/TestCase/ImagesTestCase.php +++ b/tests/utils/TestCase/ImagesTestCase.php @@ -116,7 +116,7 @@ public static function assertSizeNameIsHashed( $size_name, $hashed_size_name, $m public function opt_in_to_jpeg_and_webp() { add_filter( 'webp_uploads_upload_image_mime_transforms', - static function( $transforms ) { + static function ( $transforms ) { $transforms['image/jpeg'] = array( 'image/jpeg', 'image/webp' ); $transforms['image/webp'] = array( 'image/webp', 'image/jpeg' ); return $transforms; From 7d8afd81346e55e82e1b1fd5983048aa21cbf845 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 6 Dec 2023 11:22:03 -0800 Subject: [PATCH 151/371] Add half of tests for storage/data.php functions --- .../storage/data-tests.php | 183 ++++++++++++++++-- 1 file changed, 166 insertions(+), 17 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 0234dff376..93fc7a61d3 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -8,6 +8,11 @@ class Image_Loading_Optimization_Storage_Data_Tests extends WP_UnitTestCase { + public function tear_down() { + unset( $GLOBALS['wp_customize'] ); + parent::tear_down(); + } + /** * Test ilo_get_url_metric_freshness_ttl(). * @@ -15,7 +20,64 @@ class Image_Loading_Optimization_Storage_Data_Tests extends WP_UnitTestCase { * @covers ::ilo_get_url_metric_freshness_ttl */ public function test_ilo_get_url_metric_freshness_ttl() { - $this->markTestIncomplete(); + $this->assertSame( DAY_IN_SECONDS, ilo_get_url_metric_freshness_ttl() ); + + add_filter( + 'ilo_url_metric_freshness_ttl', + static function (): int { + return HOUR_IN_SECONDS; + } + ); + + $this->assertSame( HOUR_IN_SECONDS, ilo_get_url_metric_freshness_ttl() ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_test_ilo_can_optimize_response(): array { + return array( + 'homepage' => array( + 'set_up' => function () { + $this->go_to( home_url( '/' ) ); + }, + 'expected' => true, + ), + 'homepage_filtered' => array( + 'set_up' => function () { + $this->go_to( home_url( '/' ) ); + add_filter( 'ilo_can_optimize_response', '__return_false' ); + }, + 'expected' => false, + ), + 'search' => array( + 'set_up' => function () { + self::factory()->post->create( array( 'post_title' => 'Hello' ) ); + $this->go_to( home_url( '?s=Hello' ) ); + }, + 'expected' => false, + ), + 'customizer_preview' => array( + 'set_up' => function () { + $this->go_to( home_url( '/' ) ); + global $wp_customize; + /** @noinspection PhpIncludeInspection */ + require_once ABSPATH . 'wp-includes/class-wp-customize-manager.php'; + $wp_customize = new WP_Customize_Manager(); + $wp_customize->start_previewing_theme(); + }, + 'expected' => false, + ), + 'post_request' => array( + 'set_up' => function () { + $this->go_to( home_url( '/' ) ); + $_SERVER['REQUEST_METHOD'] = 'POST'; + }, + 'expected' => false, + ), + ); } /** @@ -23,9 +85,63 @@ public function test_ilo_get_url_metric_freshness_ttl() { * * @test * @covers ::ilo_can_optimize_response + * @dataProvider data_provider_test_ilo_can_optimize_response */ - public function test_ilo_can_optimize_response() { - $this->markTestIncomplete(); + public function test_ilo_can_optimize_response( Closure $set_up, bool $expected ) { + $set_up(); + $this->assertSame( $expected, ilo_can_optimize_response() ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_test_ilo_get_normalized_query_vars(): array { + return array( + 'homepage' => array( + 'set_up' => function (): array { + $this->go_to( home_url( '/' ) ); + return array(); + }, + ), + 'post' => array( + 'set_up' => function (): array { + $post_id = self::factory()->post->create(); + $this->go_to( get_permalink( $post_id ) ); + return array( 'p' => (string) $post_id ); + }, + ), + 'date-archive' => array( + 'set_up' => function (): array { + $post_id = self::factory()->post->create(); + $date = get_post_datetime( $post_id ); + + $this->go_to( + add_query_arg( + array( + 'day' => $date->format( 'j' ), + 'year' => $date->format( 'Y' ), + 'monthnum' => $date->format( 'm' ), + 'bogus' => 'ignore me', + ), + home_url() + ) + ); + return array( + 'year' => $date->format( 'Y' ), + 'monthnum' => $date->format( 'm' ), + 'day' => $date->format( 'j' ), + ); + }, + ), + '404' => array( + 'set_up' => function () { + $this->go_to( home_url( '/?p=1000000' ) ); + return array( 'error' => 404 ); + }, + ), + ); } /** @@ -33,9 +149,11 @@ public function test_ilo_can_optimize_response() { * * @test * @covers ::ilo_get_normalized_query_vars + * @dataProvider data_provider_test_ilo_get_normalized_query_vars */ - public function test_ilo_get_normalized_query_vars() { - $this->markTestIncomplete(); + public function test_ilo_get_normalized_query_vars( Closure $set_up ) { + $expected = $set_up(); + $this->assertSame( $expected, ilo_get_normalized_query_vars() ); } /** @@ -45,7 +163,12 @@ public function test_ilo_get_normalized_query_vars() { * @covers ::ilo_get_url_metrics_slug */ public function test_ilo_get_url_metrics_slug() { - $this->markTestIncomplete(); + $first = ilo_get_url_metrics_slug( array() ); + $second = ilo_get_url_metrics_slug( array( 'p' => 1 ) ); + $this->assertNotEquals( $second, $first ); + foreach ( array( $first, $second ) as $slug ) { + $this->assertMatchesRegularExpression( '/^[0-9a-f]{32}$/', $slug ); + } } /** @@ -53,19 +176,45 @@ public function test_ilo_get_url_metrics_slug() { * * @test * @covers ::ilo_get_url_metrics_storage_nonce - */ - public function test_ilo_get_url_metrics_storage_nonce() { - $this->markTestIncomplete(); - } - - /** - * Test ilo_verify_url_metrics_storage_nonce(). - * - * @test * @covers ::ilo_verify_url_metrics_storage_nonce */ - public function test_ilo_verify_url_metrics_storage_nonce() { - $this->markTestIncomplete(); + public function test_ilo_get_url_metrics_storage_nonce_and_ilo_verify_url_metrics_storage_nonce() { + $user_id = self::factory()->user->create(); + + $nonce_life_actions = array(); + add_filter( + 'nonce_life', + static function ( int $life, string $action ) use ( &$nonce_life_actions ): int { + $nonce_life_actions[] = $action; + return $life; + }, + 10, + 2 + ); + + // Create first nonce for unauthenticated user. + $slug = ilo_get_url_metrics_slug( array() ); + $nonce1 = ilo_get_url_metrics_storage_nonce( $slug ); + $this->assertMatchesRegularExpression( '/^[0-9a-f]{10}$/', $nonce1 ); + $this->assertSame( 1, ilo_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); + $this->assertCount( 2, $nonce_life_actions ); + + // Create second nonce for unauthenticated user. + $nonce2 = ilo_get_url_metrics_storage_nonce( $slug ); + $this->assertSame( $nonce1, $nonce2 ); + $this->assertCount( 3, $nonce_life_actions ); + + // Create third nonce, this time for authenticated user. + wp_set_current_user( $user_id ); + $nonce3 = ilo_get_url_metrics_storage_nonce( $slug ); + $this->assertNotEquals( $nonce3, $nonce2 ); + $this->assertSame( 0, ilo_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); + $this->assertSame( 1, ilo_verify_url_metrics_storage_nonce( $nonce3, $slug ) ); + $this->assertCount( 6, $nonce_life_actions ); + + foreach ( $nonce_life_actions as $nonce_life_action ) { + $this->assertSame( "store_url_metrics:{$slug}", $nonce_life_action ); + } } /** From 00b9ccb1a5a446b71f0df7d1faa40bb0a7aff9d1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 6 Dec 2023 16:15:21 -0800 Subject: [PATCH 152/371] Make ilo_unshift_url_metrics() easier to test --- .../images/image-loading-optimization/storage/data.php | 8 +++++--- .../image-loading-optimization/storage/post-type.php | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 51012d718f..019af8af05 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -152,14 +152,16 @@ function ilo_verify_url_metrics_storage_nonce( string $nonce, string $slug ): in * * @param array $url_metrics URL metrics. * @param array $validated_url_metric Validated URL metric. See JSON Schema defined in ilo_register_endpoint(). + * @param int[] $breakpoints Breakpoint max widths. + * @param int $sample_size Sample size for URL metrics at a given breakpoint. * @return array Updated URL metrics. */ -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 { 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 ); + // Make sure there is at most $sample_size number of URL metrics for each breakpoint. + // TODO: Consider array_map() instead. foreach ( $grouped_url_metrics as &$breakpoint_url_metrics ) { if ( count( $breakpoint_url_metrics ) > $sample_size ) { diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index 2959743073..0deb6a08ff 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -178,7 +178,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. From c9d41c33333b3484dbb7fcfa3f3a8bd22727bf2f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 7 Dec 2023 12:12:49 -0800 Subject: [PATCH 153/371] Add test for ilo_unshift_url_metrics() and improve logic --- .../storage/data.php | 41 ++++--- .../storage/post-type.php | 1 - .../storage/data-tests.php | 108 +++++++++++++++--- 3 files changed, 118 insertions(+), 32 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 019af8af05..411ac97869 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -150,35 +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. * @param array $validated_url_metric Validated URL metric. See JSON Schema defined in ilo_register_endpoint(). * @param int[] $breakpoints Breakpoint max widths. * @param int $sample_size Sample size for URL metrics at a given breakpoint. - * @return array Updated URL metrics. + * @return array Updated URL metrics, with timestamp key added. */ 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 ); $grouped_url_metrics = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoints ); // Make sure there is at most $sample_size number of URL metrics for each breakpoint. - // TODO: Consider array_map() instead. - 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; + $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 ); } diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index 0deb6a08ff..c68fd8484f 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -160,7 +160,6 @@ static function ( $url_metric ) use ( $trigger_error ) { * @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( diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 93fc7a61d3..aec5fb1ad6 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -217,14 +217,89 @@ static function ( int $life, string $action ) use ( &$nonce_life_actions ): int } } + public function data_provider_sample_size_and_breakpoints(): array { + return array( + '3 sample size and 2 breakpoints' => array( + 'sample_size' => 3, + 'breakpoints' => array( 480, 782 ), + 'viewport_widths' => array( 400, 600, 800 ), + ), + '1 sample size and 1 breakpoint' => array( + 'sample_size' => 1, + 'breakpoints' => array( 480 ), + 'viewport_widths' => array( 400, 800 ), + ), + ); + } + /** - * Test ilo_unshift_url_metrics(). + * Test ilo_unshift_url_metrics() and its use of ilo_group_url_metrics_by_breakpoint(). * * @test * @covers ::ilo_unshift_url_metrics + * @covers ::ilo_group_url_metrics_by_breakpoint + * @dataProvider data_provider_sample_size_and_breakpoints */ - public function test_ilo_unshift_url_metrics() { - $this->markTestIncomplete(); + public function test_ilo_unshift_url_metrics_and_ilo_group_url_metrics_by_breakpoint( int $sample_size, array $breakpoints, array $viewport_widths ) { + $old_timestamp = 1701978742; + + // Fully populate the sample size for the breakpoints. + $all_url_metrics = array(); + foreach ( $viewport_widths as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + $url_metric = $this->get_validated_url_metric(); + $url_metric['viewport']['width'] = $viewport_width; + + $all_url_metrics = ilo_unshift_url_metrics( + $all_url_metrics, + $url_metric, + $breakpoints, + $sample_size + ); + } + } + $max_possible_url_metrics_count = $sample_size * ( count( $breakpoints ) + 1 ); + $this->assertCount( + $max_possible_url_metrics_count, + $all_url_metrics, + sprintf( 'Expected there to be exactly sample size (%d) times the number of breakpoint groups (which is %d + 1)', $sample_size, count( $breakpoints ) ) + ); + + // Make sure that ilo_unshift_url_metrics() added a timestamp and then force them to all be old. + $all_url_metrics = array_map( + function ( $url_metric ) use ( $old_timestamp ) { + $this->assertArrayHasKey( 'timestamp', $url_metric, 'Expected a timestamp to have been added to a URL metric.' ); + $url_metric['timestamp'] = $old_timestamp; + return $url_metric; + }, + $all_url_metrics + ); + + // Try adding one URL metric for each breakpoint group. + foreach ( $viewport_widths as $viewport_width ) { + $url_metric = $this->get_validated_url_metric(); + $url_metric['viewport']['width'] = $viewport_width; + + $all_url_metrics = ilo_unshift_url_metrics( + $all_url_metrics, + $url_metric, + $breakpoints, + $sample_size + ); + } + $this->assertCount( + $max_possible_url_metrics_count, + $all_url_metrics, + 'Expected the total count of URL metrics to not exceed the multiple of the sample size.' + ); + $new_count = 0; + foreach ( $all_url_metrics as $url_metric ) { + if ( $url_metric['timestamp'] > $old_timestamp ) { + ++$new_count; + } + } + $this->assertGreaterThan( 0, $new_count, 'Expected there to be at least one new URL metric.' ); + $this->assertSame( count( $viewport_widths ), $new_count, 'Expected the new URL metrics to all have been added.' ); } /** @@ -247,16 +322,6 @@ public function test_ilo_get_url_metrics_breakpoint_sample_size() { $this->markTestIncomplete(); } - /** - * Test ilo_group_url_metrics_by_breakpoint(). - * - * @test - * @covers ::ilo_group_url_metrics_by_breakpoint - */ - public function test_ilo_group_url_metrics_by_breakpoint() { - $this->markTestIncomplete(); - } - /** * Test ilo_get_lcp_elements_by_minimum_viewport_widths(). * @@ -286,4 +351,21 @@ public function test_ilo_get_needed_minimum_viewport_widths() { public function test_ilo_needs_url_metric_for_breakpoint() { $this->markTestIncomplete(); } + + private function get_validated_url_metric(): array { + return array( + 'viewport' => array( + 'width' => 480, + 'height' => 640, + ), + 'elements' => array( + array( + 'isLCP' => true, + 'isLCPCandidate' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]/*[1][self::MAIN]/*[0][self::DIV]/*[0][self::FIGURE]/*[0][self::IMG]', + 'intersectionRatio' => 1, + ), + ), + ); + } } From ea08edadcea469391fe79eee0ad67ceb69edd75f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 7 Dec 2023 12:20:15 -0800 Subject: [PATCH 154/371] Add tests for functions to get sample size and breakpoint max widths --- .../storage/data-tests.php | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index aec5fb1ad6..faa6c7e774 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -309,7 +309,23 @@ function ( $url_metric ) use ( $old_timestamp ) { * @covers ::ilo_get_breakpoint_max_widths */ public function test_ilo_get_breakpoint_max_widths() { - $this->markTestIncomplete(); + $this->assertSame( + array( 480, 600, 782 ), + ilo_get_breakpoint_max_widths() + ); + + $filtered_breakpoints = array( 2000, 500, '1000', 3000 ); + + add_filter( + 'ilo_breakpoint_max_widths', + static function () use ( $filtered_breakpoints ) { + return $filtered_breakpoints; + } + ); + + $filtered_breakpoints = array_map( 'intval', $filtered_breakpoints ); + sort( $filtered_breakpoints ); + $this->assertSame( $filtered_breakpoints, ilo_get_breakpoint_max_widths() ); } /** @@ -319,7 +335,16 @@ public function test_ilo_get_breakpoint_max_widths() { * @covers ::ilo_get_url_metrics_breakpoint_sample_size */ public function test_ilo_get_url_metrics_breakpoint_sample_size() { - $this->markTestIncomplete(); + $this->assertSame( 3, ilo_get_url_metrics_breakpoint_sample_size() ); + + add_filter( + 'ilo_url_metrics_breakpoint_sample_size', + static function () { + return '1'; + } + ); + + $this->assertSame( 1, ilo_get_url_metrics_breakpoint_sample_size() ); } /** From 0f922f32085fe4a1207872d236622752cb7f51e0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 7 Dec 2023 15:13:17 -0800 Subject: [PATCH 155/371] Add test for ilo_group_url_metrics_by_breakpoint --- .../storage/data.php | 6 +- .../storage/data-tests.php | 64 ++++++++++++++++++- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 411ac97869..652bcbb75a 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -273,14 +273,14 @@ 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'] ) ) { @@ -288,7 +288,7 @@ static function ( $breakpoint ) { } $current_minimum_viewport = 0; - foreach ( $viewport_minimum_widths as $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 { diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index faa6c7e774..74cf2b7d3c 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -233,14 +233,13 @@ public function data_provider_sample_size_and_breakpoints(): array { } /** - * Test ilo_unshift_url_metrics() and its use of ilo_group_url_metrics_by_breakpoint(). + * Test ilo_unshift_url_metrics(). * * @test * @covers ::ilo_unshift_url_metrics - * @covers ::ilo_group_url_metrics_by_breakpoint * @dataProvider data_provider_sample_size_and_breakpoints */ - public function test_ilo_unshift_url_metrics_and_ilo_group_url_metrics_by_breakpoint( int $sample_size, array $breakpoints, array $viewport_widths ) { + public function test_ilo_unshift_url_metrics( int $sample_size, array $breakpoints, array $viewport_widths ) { $old_timestamp = 1701978742; // Fully populate the sample size for the breakpoints. @@ -347,6 +346,65 @@ static function () { $this->assertSame( 1, ilo_get_url_metrics_breakpoint_sample_size() ); } + public function data_provider_test_ilo_group_url_metrics_by_breakpoint(): array { + return array( + '2-breakpoints-and-3-viewport-widths' => array( + 'breakpoints' => array( 480, 640 ), + 'viewport_widths' => array( 400, 600, 800 ), + ), + '1-breakpoint-and-4-viewport-widths' => array( + 'breakpoints' => array( 480 ), + 'viewport_widths' => array( 400, 600, 800, 1000 ), + ), + ); + } + + /** + * Test ilo_group_url_metrics_by_breakpoint(). + * + * @test + * @covers ::ilo_group_url_metrics_by_breakpoint + * @dataProvider data_provider_test_ilo_group_url_metrics_by_breakpoint + */ + public function test_ilo_group_url_metrics_by_breakpoint( array $breakpoints, array $viewport_widths ) { + $url_metrics = array(); + foreach ( $viewport_widths as $viewport_width ) { + $url_metric = $this->get_validated_url_metric(); + $url_metric['viewport']['width'] = $viewport_width; + $url_metrics[] = $url_metric; + } + + $grouped_url_metrics = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoints ); + $this->assertCount( count( $breakpoints ) + 1, $grouped_url_metrics, 'Expected number of breakpoint groups to always be one greater than the number of breakpoints.' ); + $minimum_viewport_widths = array_keys( $grouped_url_metrics ); + $this->assertSame( 0, array_shift( $minimum_viewport_widths ), 'Expected the first minimum viewport width to always be zero.' ); + foreach ( $breakpoints as $breakpoint ) { + $this->assertSame( $breakpoint + 1, array_shift( $minimum_viewport_widths ) ); + } + + $minimum_viewport_widths = array_keys( $grouped_url_metrics ); + for ( $i = 0, $len = count( $minimum_viewport_widths ); $i < $len; $i++ ) { + $minimum_viewport_width = $minimum_viewport_widths[ $i ]; + $maximum_viewport_width = $minimum_viewport_widths[ $i + 1 ] ?? null; + if ( 0 === $i ) { + $this->assertSame( 0, $minimum_viewport_width ); + } else { + $this->assertGreaterThan( 0, $minimum_viewport_width ); + } + if ( isset( $maximum_viewport_width ) ) { + $this->assertLessThan( $maximum_viewport_width, $minimum_viewport_width ); + } + + $this->assertIsArray( $grouped_url_metrics[ $minimum_viewport_width ] ); + foreach ( $grouped_url_metrics[ $minimum_viewport_width ] as $url_metric ) { + $this->assertGreaterThanOrEqual( $minimum_viewport_width, $url_metric['viewport']['width'] ); + if ( isset( $maximum_viewport_width ) ) { + $this->assertLessThanOrEqual( $maximum_viewport_width, $url_metric['viewport']['width'] ); + } + } + } + } + /** * Test ilo_get_lcp_elements_by_minimum_viewport_widths(). * From a6d6997f6d52fbdd082e0cf904ad7e30a5d62cb1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 7 Dec 2023 15:53:46 -0800 Subject: [PATCH 156/371] Add tests for ilo_get_lcp_elements_by_minimum_viewport_widths() --- .../storage/data-tests.php | 144 +++++++++++++++--- 1 file changed, 122 insertions(+), 22 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 74cf2b7d3c..c12ee04499 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -246,12 +246,9 @@ public function test_ilo_unshift_url_metrics( int $sample_size, array $breakpoin $all_url_metrics = array(); foreach ( $viewport_widths as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - $url_metric = $this->get_validated_url_metric(); - $url_metric['viewport']['width'] = $viewport_width; - $all_url_metrics = ilo_unshift_url_metrics( $all_url_metrics, - $url_metric, + $this->get_validated_url_metric( $viewport_width ), $breakpoints, $sample_size ); @@ -276,12 +273,9 @@ function ( $url_metric ) use ( $old_timestamp ) { // Try adding one URL metric for each breakpoint group. foreach ( $viewport_widths as $viewport_width ) { - $url_metric = $this->get_validated_url_metric(); - $url_metric['viewport']['width'] = $viewport_width; - $all_url_metrics = ilo_unshift_url_metrics( $all_url_metrics, - $url_metric, + $this->get_validated_url_metric( $viewport_width ), $breakpoints, $sample_size ); @@ -350,7 +344,7 @@ public function data_provider_test_ilo_group_url_metrics_by_breakpoint(): array return array( '2-breakpoints-and-3-viewport-widths' => array( 'breakpoints' => array( 480, 640 ), - 'viewport_widths' => array( 400, 600, 800 ), + 'viewport_widths' => array( 400, 480, 800 ), ), '1-breakpoint-and-4-viewport-widths' => array( 'breakpoints' => array( 480 ), @@ -367,12 +361,12 @@ public function data_provider_test_ilo_group_url_metrics_by_breakpoint(): array * @dataProvider data_provider_test_ilo_group_url_metrics_by_breakpoint */ public function test_ilo_group_url_metrics_by_breakpoint( array $breakpoints, array $viewport_widths ) { - $url_metrics = array(); - foreach ( $viewport_widths as $viewport_width ) { - $url_metric = $this->get_validated_url_metric(); - $url_metric['viewport']['width'] = $viewport_width; - $url_metrics[] = $url_metric; - } + $url_metrics = array_map( + function ( $viewport_width ) { + return $this->get_validated_url_metric( $viewport_width ); + }, + $viewport_widths + ); $grouped_url_metrics = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoints ); $this->assertCount( count( $breakpoints ) + 1, $grouped_url_metrics, 'Expected number of breakpoint groups to always be one greater than the number of breakpoints.' ); @@ -405,14 +399,104 @@ public function test_ilo_group_url_metrics_by_breakpoint( array $breakpoints, ar } } + public function data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widths(): array { + return array( + 'common_lcp_element_across_breakpoints' => array( + 'grouped_url_metrics' => array( + 0 => array( + $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. + $this->get_validated_url_metric( 599, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + ), + 600 => array( + $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + ), + 800 => array( + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), + ), + ), + 'different_lcp_elements_across_breakpoint' => array( + 'grouped_url_metrics' => array( + 0 => array( + $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. + $this->get_validated_url_metric( 599, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + ), + 600 => array( + $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), + 600 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + ), + ), + 'same_lcp_element_across_non_consecutive_breakpoints' => array( + 'grouped_url_metrics' => array( + 0 => array( + $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + ), + 400 => array( + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'HEADER', 'IMG' ), false ), + ), + 600 => array( + $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + 400 => false, // The (image) element is either not visible at this breakpoint or it is not LCP element. + 600 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + ), + ), + 'no_lcp_image_elements' => array( + 'grouped_url_metrics' => array( + 0 => array( + $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'IMG' ), false ), + ), + 600 => array( + $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'IMG' ), false ), + ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => false, + ), + ), + ); + } + /** * Test ilo_get_lcp_elements_by_minimum_viewport_widths(). * * @test * @covers ::ilo_get_lcp_elements_by_minimum_viewport_widths + * @dataProvider data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widths */ - public function test_ilo_get_lcp_elements_by_minimum_viewport_widths() { - $this->markTestIncomplete(); + public function test_ilo_get_lcp_elements_by_minimum_viewport_widths( array $grouped_url_metrics, array $expected_lcp_element_xpaths ) { + $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $grouped_url_metrics ); + + $lcp_element_xpaths_by_minimum_viewport_widths = array(); + foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { + $this->assertTrue( is_array( $lcp_element ) || false === $lcp_element ); + if ( is_array( $lcp_element ) ) { + $this->assertTrue( $lcp_element['isLCP'] ); + $this->assertTrue( $lcp_element['isLCPCandidate'] ); + $this->assertIsString( $lcp_element['xpath'] ); + $this->assertIsNumeric( $lcp_element['intersectionRatio'] ); + $lcp_element_xpaths_by_minimum_viewport_widths[ $minimum_viewport_width ] = $lcp_element['xpath']; + } else { + $lcp_element_xpaths_by_minimum_viewport_widths[ $minimum_viewport_width ] = false; + } + } + + $this->assertSame( $expected_lcp_element_xpaths, $lcp_element_xpaths_by_minimum_viewport_widths ); } /** @@ -435,20 +519,36 @@ public function test_ilo_needs_url_metric_for_breakpoint() { $this->markTestIncomplete(); } - private function get_validated_url_metric(): array { + private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): array { return array( 'viewport' => array( - 'width' => 480, + 'width' => $viewport_width, 'height' => 640, ), 'elements' => array( array( - 'isLCP' => true, - 'isLCPCandidate' => true, - 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]/*[1][self::MAIN]/*[0][self::DIV]/*[0][self::FIGURE]/*[0][self::IMG]', + 'isLCP' => $is_lcp, + 'isLCPCandidate' => $is_lcp, + 'xpath' => $this->get_xpath( ...$breadcrumbs ), 'intersectionRatio' => 1, ), ), ); } + + /** + * @param string ...$breadcrumbs List of tags. + * @return string XPath. + */ + private function get_xpath( ...$breadcrumbs ): string { + return implode( + '', + array_map( + static function ( $tag ) { + return sprintf( '/*[0][self::%s]', strtoupper( $tag ) ); + }, + $breadcrumbs + ) + ); + } } From 1d9275681ce45b92220dd8a21978c4455e5cd9a9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 7 Dec 2023 17:03:00 -0800 Subject: [PATCH 157/371] Add remainint test stubs --- .../storage/data.php | 4 +- .../class-ilo-html-tag-processor-tests.php | 71 +++++++++++++++++++ .../optimization-tests.php | 40 +++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php create mode 100644 tests/modules/images/image-loading-optimization/optimization-tests.php diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 652bcbb75a..e9e5a5b1a1 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -388,8 +388,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. diff --git a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php new file mode 100644 index 0000000000..b9e15ff5fe --- /dev/null +++ b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php @@ -0,0 +1,71 @@ +markTestIncomplete(); + } + + /** + * Test get_xpath(). + * + * @test + * @covers ::get_xpath + */ + public function test_get_xpath() { + $this->markTestIncomplete(); + } + + /** + * Test get_attribute(). + * + * @test + * @covers ::get_attribute + */ + public function test_get_attribute() { + $this->markTestIncomplete(); + } + + /** + * Test set_attribute(). + * + * @test + * @covers ::set_attribute + */ + public function test_set_attribute() { + $this->markTestIncomplete(); + } + + /** + * Test remove_attribute(). + * + * @test + * @covers ::remove_attribute + */ + public function test_remove_attribute() { + $this->markTestIncomplete(); + } + + /** + * Test get_updated_html(). + * + * @test + * @covers ::get_updated_html + */ + public function test_get_updated_html() { + $this->markTestIncomplete(); + } +} diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php new file mode 100644 index 0000000000..d1207dc854 --- /dev/null +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -0,0 +1,40 @@ +markTestIncomplete(); + } + + /** + * Test ilo_construct_preload_links(). + * + * @test + * @covers ::ilo_construct_preload_links + */ + public function test_ilo_construct_preload_links() { + $this->markTestIncomplete(); + } + + /** + * Test ilo_optimize_template_output_buffer(). + * + * @test + * @covers ::ilo_optimize_template_output_buffer + */ + public function test_ilo_optimize_template_output_buffer() { + $this->markTestIncomplete(); + } +} From 63136ab036330ea08850a239b5a1b265d1129971 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 8 Dec 2023 14:08:07 -0800 Subject: [PATCH 158/371] Add tests for 2 optimization functions and improve comments --- .../optimization.php | 11 ++-- .../storage/data.php | 9 +-- .../optimization-tests.php | 59 ++++++++++++++++++- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 6e5a2c13d7..2a079999aa 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -30,21 +30,22 @@ 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 $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 ] ]; + $lcp_element = $lcp_elements_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. + // No supported LCP element at this breakpoint, so nothing to preload. continue; } + // TODO: Add support for background images. $img_attributes = $lcp_element['attributes']; // Prevent preloading src for browsers that don't support imagesrcset on the link element. diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index e9e5a5b1a1..6384ff5be9 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -305,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 + * an 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 { diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index d1207dc854..94a8260be0 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -15,7 +15,18 @@ class Image_Loading_Optimization_Optimization_Tests extends WP_UnitTestCase { * @covers ::ilo_maybe_add_template_output_buffer_filter */ public function test_ilo_maybe_add_template_output_buffer_filter() { - $this->markTestIncomplete(); + $this->assertFalse( has_filter( 'ilo_template_output_buffer', 'ilo_optimize_template_output_buffer' ) ); + + add_filter( 'ilo_can_optimize_response', '__return_false', 1 ); + ilo_maybe_add_template_output_buffer_filter(); + $this->assertFalse( ilo_can_optimize_response() ); + $this->assertFalse( has_filter( 'ilo_template_output_buffer', 'ilo_optimize_template_output_buffer' ) ); + + add_filter( 'ilo_can_optimize_response', '__return_true', 2 ); + $this->go_to( home_url( '/' ) ); + $this->assertTrue( ilo_can_optimize_response() ); + ilo_maybe_add_template_output_buffer_filter(); + $this->assertSame( 10, has_filter( 'ilo_template_output_buffer', 'ilo_optimize_template_output_buffer' ) ); } /** @@ -28,13 +39,55 @@ public function test_ilo_construct_preload_links() { $this->markTestIncomplete(); } + public function data_provider_test_ilo_optimize_template_output_buffer(): array { + return array( + 'one-needed' => array( + array( + array( 480, false ), + ), + false, + ), + 'one-unneeded' => array( + array( + array( 480, true ), + ), + true, + ), + 'one-of-3-needed' => array( + array( + array( 480, false ), + array( 600, true ), + array( 782, false ), + ), + true, + ), + 'none-of-3-needed' => array( + array( + array( 480, false ), + array( 600, false ), + array( 782, false ), + ), + false, + ), + 'all-of-3-needed' => array( + array( + array( 480, true ), + array( 600, true ), + array( 782, true ), + ), + true, + ), + ); + } + /** * Test ilo_optimize_template_output_buffer(). * * @test * @covers ::ilo_optimize_template_output_buffer + * @dataProvider data_provider_test_ilo_optimize_template_output_buffer */ - public function test_ilo_optimize_template_output_buffer() { - $this->markTestIncomplete(); + public function test_ilo_optimize_template_output_buffer( array $needed_minimum_viewport_widths, bool $expected_needed ) { + $this->assertSame( $expected_needed, ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths ) ); } } From 746f463a29fbc83ad5823a2cbf6e166e5838759b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 8 Dec 2023 15:01:16 -0800 Subject: [PATCH 159/371] Fix XPath for foreign elements and add tests for ILO_HTML_Tag_Processor --- .../class-ilo-html-tag-processor.php | 57 ++-- phpcs.xml.dist | 3 + .../class-ilo-html-tag-processor-tests.php | 305 +++++++++++++++++- 3 files changed, 329 insertions(+), 36 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 65a4516f23..e41a06f787 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -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 ); } } 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). + * + * @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. * diff --git a/phpcs.xml.dist b/phpcs.xml.dist index af746663c2..4661c65e87 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -87,6 +87,9 @@ tests/* + + tests/* + diff --git a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php index b9e15ff5fe..4d005133f4 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php @@ -9,24 +9,296 @@ */ class Image_Loading_Optimization_ILO_HTML_Tag_Processor_Tests extends WP_UnitTestCase { - /** - * Test open_tags(). - * - * @test - * @covers ::open_tags - */ - public function test_open_tags() { - $this->markTestIncomplete(); + public function data_provider_sample_documents(): array { + return array( + 'well-formed-html' => array( + 'document' => ' + + + + + Foo + + +

+ Foo! +
+ Foo +

+
The end!
+ + + ', + 'open_tags' => array( 'HTML', 'HEAD', 'META', 'TITLE', 'BODY', 'P', 'BR', 'IMG', 'FOOTER' ), + 'xpaths' => array( + '/*[0][self::HTML]', + '/*[0][self::HTML]/*[0][self::HEAD]', + '/*[0][self::HTML]/*[0][self::HEAD]/*[0][self::META]', + '/*[0][self::HTML]/*[0][self::HEAD]/*[1][self::TITLE]', + '/*[0][self::HTML]/*[1][self::BODY]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]/*[0][self::BR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]/*[1][self::IMG]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::FOOTER]', + ), + ), + 'foreign-elements' => array( + 'document' => ' + + + + + + + + + + + + + 1 + + 2 + + + + ', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'SVG', 'G', 'PATH', 'CIRCLE', 'G', 'RECT', 'MATH', 'MN', 'MSPACE', 'MN' ), + 'xpaths' => array( + '/*[0][self::HTML]', + '/*[0][self::HTML]/*[0][self::HEAD]', + '/*[0][self::HTML]/*[1][self::BODY]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SVG]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SVG]/*[0][self::G]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SVG]/*[0][self::G]/*[0][self::PATH]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SVG]/*[0][self::G]/*[1][self::CIRCLE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SVG]/*[0][self::G]/*[2][self::G]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SVG]/*[0][self::G]/*[3][self::RECT]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::MATH]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::MATH]/*[0][self::MN]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::MATH]/*[1][self::MSPACE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::MATH]/*[2][self::MN]', + ), + ), + 'closing-void-tag' => array( + 'document' => ' + + + + 1 +

+ 2 + + + ', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'SPAN', 'BR', 'SPAN' ), + 'xpaths' => array( + '/*[0][self::HTML]', + '/*[0][self::HTML]/*[0][self::HEAD]', + '/*[0][self::HTML]/*[1][self::BODY]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SPAN]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::BR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[2][self::SPAN]', + ), + ), + 'void-tags' => array( + 'document' => ' + + + + + + + +
+ + + +
+ + + + + + + + + + + +
+ + + + + ', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'AREA', 'BASE', 'BASEFONT', 'BGSOUND', 'BR', 'COL', 'EMBED', 'FRAME', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR', 'DIV', 'SPAN', 'EM' ), + 'xpaths' => array( + '/*[0][self::HTML]', + '/*[0][self::HTML]/*[0][self::HEAD]', + '/*[0][self::HTML]/*[1][self::BODY]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::AREA]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::BASE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[2][self::BASEFONT]', + '/*[0][self::HTML]/*[1][self::BODY]/*[3][self::BGSOUND]', + '/*[0][self::HTML]/*[1][self::BODY]/*[4][self::BR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[5][self::COL]', + '/*[0][self::HTML]/*[1][self::BODY]/*[6][self::EMBED]', + '/*[0][self::HTML]/*[1][self::BODY]/*[7][self::FRAME]', + '/*[0][self::HTML]/*[1][self::BODY]/*[8][self::HR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[9][self::IMG]', + '/*[0][self::HTML]/*[1][self::BODY]/*[10][self::INPUT]', + '/*[0][self::HTML]/*[1][self::BODY]/*[11][self::KEYGEN]', + '/*[0][self::HTML]/*[1][self::BODY]/*[12][self::LINK]', + '/*[0][self::HTML]/*[1][self::BODY]/*[13][self::META]', + '/*[0][self::HTML]/*[1][self::BODY]/*[14][self::PARAM]', + '/*[0][self::HTML]/*[1][self::BODY]/*[15][self::SOURCE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[16][self::TRACK]', + '/*[0][self::HTML]/*[1][self::BODY]/*[17][self::WBR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[18][self::DIV]', + '/*[0][self::HTML]/*[1][self::BODY]/*[18][self::DIV]/*[0][self::SPAN]', + '/*[0][self::HTML]/*[1][self::BODY]/*[18][self::DIV]/*[0][self::SPAN]/*[0][self::EM]', + ), + ), + 'optional-closing-p' => array( + 'document' => ' + + + + +

First +

Second +

Third + + +

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+


+

+

+

+

    +

    
    +							

    +

    +

    +

      + + + ', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'P', 'P', 'EM', 'P', 'P', 'ADDRESS', 'P', 'ARTICLE', 'P', 'ASIDE', 'P', 'BLOCKQUOTE', 'P', 'DETAILS', 'P', 'DIV', 'P', 'DL', 'P', 'FIELDSET', 'P', 'FIGCAPTION', 'P', 'FIGURE', 'P', 'FOOTER', 'P', 'FORM', 'P', 'H1', 'P', 'H2', 'P', 'H3', 'P', 'H4', 'P', 'H5', 'P', 'H6', 'P', 'HEADER', 'P', 'HGROUP', 'P', 'HR', 'P', 'MAIN', 'P', 'MENU', 'P', 'NAV', 'P', 'OL', 'P', 'PRE', 'P', 'SEARCH', 'P', 'SECTION', 'P', 'TABLE', 'P', 'UL' ), + 'xpaths' => array( + '/*[0][self::HTML]', + '/*[0][self::HTML]/*[0][self::HEAD]', + '/*[0][self::HTML]/*[1][self::BODY]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]/*[0][self::EM]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::ADDRESS]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::ARTICLE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::ASIDE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::BLOCKQUOTE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DETAILS]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DL]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::FIELDSET]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::FIGCAPTION]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::FIGURE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::FOOTER]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::FORM]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H1]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H2]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H3]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H4]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H5]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H6]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::HEADER]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::HGROUP]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::HR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::MAIN]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::MENU]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::NAV]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::OL]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::PRE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SEARCH]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::SECTION]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::TABLE]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::UL]', + ), + ), + ); } /** - * Test get_xpath(). + * Test open_tags() and get_xpath(). * * @test + * @covers ::open_tags * @covers ::get_xpath + * + * @dataProvider data_provider_sample_documents */ - public function test_get_xpath() { - $this->markTestIncomplete(); + public function test_open_tags_and_get_xpath( string $document, array $open_tags, array $xpaths ) { + $p = new ILO_HTML_Tag_Processor( $document ); + $this->assertSame( '', $p->get_xpath(), 'Expected empty XPath since iteration has not started.' ); + $actual_open_tags = array(); + $actual_xpaths = array(); + foreach ( $p->open_tags() as $open_tag ) { + $actual_open_tags[] = $open_tag; + $actual_xpaths[] = $p->get_xpath(); + } + + $this->assertSame( $actual_open_tags, $open_tags, "Expected list of open tags to match.\nSnapshot: " . $this->export_array_snapshot( $actual_open_tags, true ) ); + $this->assertSame( $actual_xpaths, $xpaths, "Expected list of XPaths to match.\nSnapshot:" . $this->export_array_snapshot( $actual_xpaths ) ); } /** @@ -68,4 +340,15 @@ public function test_remove_attribute() { public function test_get_updated_html() { $this->markTestIncomplete(); } + + /** + * Export an array as a PHP literal to use as a snapshot. + */ + private function export_array_snapshot( array $data, bool $one_line = false ): string { + $php = preg_replace( '/^\s*\d+\s*=>\s*/m', '', var_export( $data, true ) ); + if ( $one_line ) { + $php = str_replace( "\n", ' ', $php ); + } + return $php; + } } From 59fb175b739e54c2ad245cfbd7925a5501b2ea28 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 12 Dec 2023 09:37:12 -0800 Subject: [PATCH 160/371] Add remaining tests for ILO_HTML_Tag_Processor --- .../class-ilo-html-tag-processor-tests.php | 41 +++++-------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php index 4d005133f4..158d6e29c1 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php @@ -302,43 +302,24 @@ public function test_open_tags_and_get_xpath( string $document, array $open_tags } /** - * Test get_attribute(). + * Test get_attribute(), set_attribute(), remove_attribute(), and get_updated_html(). * * @test * @covers ::get_attribute - */ - public function test_get_attribute() { - $this->markTestIncomplete(); - } - - /** - * Test set_attribute(). - * - * @test * @covers ::set_attribute - */ - public function test_set_attribute() { - $this->markTestIncomplete(); - } - - /** - * Test remove_attribute(). - * - * @test * @covers ::remove_attribute - */ - public function test_remove_attribute() { - $this->markTestIncomplete(); - } - - /** - * Test get_updated_html(). - * - * @test * @covers ::get_updated_html */ - public function test_get_updated_html() { - $this->markTestIncomplete(); + public function test_html_tag_processor_wrapper_methods() { + $processor = new ILO_HTML_Tag_Processor( '' ); + foreach ( $processor->open_tags() as $open_tag ) { + if ( 'HTML' === $open_tag ) { + $this->assertSame( 'en', $processor->get_attribute( 'lang' ) ); + $processor->set_attribute( 'lang', 'es' ); + $processor->remove_attribute( 'xml:lang' ); + } + } + $this->assertSame( '', $processor->get_updated_html() ); } /** From 3e7798832af13e0f73b5a874049c029bb560c94f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 12 Dec 2023 11:33:59 -0800 Subject: [PATCH 161/371] Add test for ilo_needs_url_metric_for_breakpoint and fix stub --- .../storage/data.php | 2 +- .../optimization-tests.php | 46 +----- .../storage/data-tests.php | 137 +++++++++++++++++- 3 files changed, 136 insertions(+), 49 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 6384ff5be9..891f2b4b88 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -306,7 +306,7 @@ static function ( $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 supported LCP element, then the array value is `false`. (Currently only IMG is - * an supported LCP element.) If there is a supported LCP element at the breakpoint, then the array value is an array + * 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. * diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 94a8260be0..effad8d5c3 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -39,55 +39,13 @@ public function test_ilo_construct_preload_links() { $this->markTestIncomplete(); } - public function data_provider_test_ilo_optimize_template_output_buffer(): array { - return array( - 'one-needed' => array( - array( - array( 480, false ), - ), - false, - ), - 'one-unneeded' => array( - array( - array( 480, true ), - ), - true, - ), - 'one-of-3-needed' => array( - array( - array( 480, false ), - array( 600, true ), - array( 782, false ), - ), - true, - ), - 'none-of-3-needed' => array( - array( - array( 480, false ), - array( 600, false ), - array( 782, false ), - ), - false, - ), - 'all-of-3-needed' => array( - array( - array( 480, true ), - array( 600, true ), - array( 782, true ), - ), - true, - ), - ); - } - /** * Test ilo_optimize_template_output_buffer(). * * @test * @covers ::ilo_optimize_template_output_buffer - * @dataProvider data_provider_test_ilo_optimize_template_output_buffer */ - public function test_ilo_optimize_template_output_buffer( array $needed_minimum_viewport_widths, bool $expected_needed ) { - $this->assertSame( $expected_needed, ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths ) ); + public function test_ilo_optimize_template_output_buffer() { + $this->markTestIncomplete(); } } diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index c12ee04499..16d2d6c93f 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -499,14 +499,132 @@ public function test_ilo_get_lcp_elements_by_minimum_viewport_widths( array $gro $this->assertSame( $expected_lcp_element_xpaths, $lcp_element_xpaths_by_minimum_viewport_widths ); } + /** + * Data provider. + * + * @return array[] + */ + public function data_provider_test_ilo_get_needed_minimum_viewport_widths(): array { + $current_time = microtime( true ); + + $none_needed_data = array( + 'url_metrics' => ( function () use ( $current_time ): array { + return array_merge( + array_fill( + 0, + 3, + array_merge( $this->get_validated_url_metric( 400 ), array( 'timestamp' => $current_time ) ) + ), + array_fill( + 0, + 3, + array_merge( $this->get_validated_url_metric( 600 ), array( 'timestamp' => $current_time ) ) + ) + ); + } )(), + 'current_time' => $current_time, + 'breakpoint_max_widths' => array( 480 ), + 'sample_size' => 3, + 'freshness_ttl' => HOUR_IN_SECONDS, + ); + + return array( + 'none-needed' => array_merge( + $none_needed_data, + array( + 'expected' => array( + array( 0, false ), + array( 481, false ), + ), + ) + ), + + 'not-enough-url-metrics' => array_merge( + $none_needed_data, + array( + 'sample_size' => $none_needed_data['sample_size'] + 1, + ), + array( + 'expected' => array( + array( 0, true ), + array( 481, true ), + ), + ) + ), + + 'url-metric-too-old' => array_merge( + ( static function ( $data ): array { + $data['url_metrics'][0]['timestamp'] -= $data['freshness_ttl'] + 1; + return $data; + } )( $none_needed_data ), + array( + 'expected' => array( + array( 0, true ), + array( 481, false ), + ), + ) + ), + ); + } + /** * Test ilo_get_needed_minimum_viewport_widths(). * * @test * @covers ::ilo_get_needed_minimum_viewport_widths + * @dataProvider data_provider_test_ilo_get_needed_minimum_viewport_widths + */ + public function test_ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $current_time, array $breakpoint_max_widths, int $sample_size, int $freshness_ttl, array $expected ) { + $this->assertSame( + $expected, + ilo_get_needed_minimum_viewport_widths( $url_metrics, $current_time, $breakpoint_max_widths, $sample_size, $freshness_ttl ) + ); + } + + /** + * Data provider. + * + * @return array[] */ - public function test_ilo_get_needed_minimum_viewport_widths() { - $this->markTestIncomplete(); + public function data_provider_test_ilo_needs_url_metric_for_breakpoint(): array { + return array( + 'one-needed' => array( + array( + array( 480, false ), + ), + false, + ), + 'one-unneeded' => array( + array( + array( 480, true ), + ), + true, + ), + 'one-of-3-needed' => array( + array( + array( 480, false ), + array( 600, true ), + array( 782, false ), + ), + true, + ), + 'none-of-3-needed' => array( + array( + array( 480, false ), + array( 600, false ), + array( 782, false ), + ), + false, + ), + 'all-of-3-needed' => array( + array( + array( 480, true ), + array( 600, true ), + array( 782, true ), + ), + true, + ), + ); } /** @@ -514,11 +632,20 @@ public function test_ilo_get_needed_minimum_viewport_widths() { * * @test * @covers ::ilo_needs_url_metric_for_breakpoint + * @dataProvider data_provider_test_ilo_needs_url_metric_for_breakpoint */ - public function test_ilo_needs_url_metric_for_breakpoint() { - $this->markTestIncomplete(); + public function test_ilo_needs_url_metric_for_breakpoint( array $needed_minimum_viewport_widths, bool $expected_needed ) { + $this->assertSame( $expected_needed, ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths ) ); } + /** + * Gets a validated URL metric for testing. + * + * @param int $viewport_width Viewport width. + * @param string[] $breadcrumbs Breadcrumb tags. + * @param bool $is_lcp Whether LCP. + * @return array Validated URL metric. + */ private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): array { return array( 'viewport' => array( @@ -537,6 +664,8 @@ private function get_validated_url_metric( int $viewport_width = 480, array $bre } /** + * Gets sample XPath. + * * @param string ...$breadcrumbs List of tags. * @return string XPath. */ From 46080ef9a85ec584b18e322a7572bfce3fcadf5d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 12 Dec 2023 15:00:31 -0800 Subject: [PATCH 162/371] Add two test cases for ilo_construct_preload_links and remove integrity attr lifting --- .../optimization.php | 4 +- .../optimization-tests.php | 48 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 2a079999aa..225b86919b 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -40,7 +40,7 @@ function ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_wi $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_elements_by_minimum_viewport_widths[ $minimum_viewport_widths[ $i ] ]; - if ( false === $lcp_element || empty( $lcp_element['attributes'] ) ) { + if ( false === $lcp_element ) { // No supported LCP element at this breakpoint, so nothing to preload. continue; } @@ -173,7 +173,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // 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 ) { diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index effad8d5c3..2a354806a9 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -29,14 +29,58 @@ public function test_ilo_maybe_add_template_output_buffer_filter() { $this->assertSame( 10, has_filter( 'ilo_template_output_buffer', 'ilo_optimize_template_output_buffer' ) ); } + /** + * Data provider. + * + * @return array[] + */ + public function data_provider_test_ilo_construct_preload_links(): array { + return array( + 'no-lcp-image' => array( + 'lcp_elements_by_minimum_viewport_widths' => array( + 0 => false, + ), + 'expected' => '', + ), + 'one-non-responsive-lcp-image' => array( + 'lcp_elements_by_minimum_viewport_widths' => array( + 0 => array( + 'attributes' => array( + 'src' => 'https://example.com/image.jpg', + ), + ), + ), + 'expected' => ' + + ', + ), + 'one-responsive-lcp-image' => array( + 'lcp_elements_by_minimum_viewport_widths' => array( + 0 => array( + 'attributes' => array( + 'src' => 'elva-fairy-800w.jpg', + 'srcset' => 'elva-fairy-480w.jpg 480w, elva-fairy-800w.jpg 800w', + 'sizes' => '(max-width: 600px) 480px, 800px', + 'crossorigin' => 'anonymous', + ), + ), + ), + 'expected' => ' + + ', + ), + ); + } + /** * Test ilo_construct_preload_links(). * * @test * @covers ::ilo_construct_preload_links + * @dataProvider data_provider_test_ilo_construct_preload_links */ - public function test_ilo_construct_preload_links() { - $this->markTestIncomplete(); + public function test_ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_widths, string $expected ) { + $this->assertSame( trim( $expected ), trim( ilo_construct_preload_links( $lcp_elements_by_minimum_viewport_widths ) ) ); } /** From e12b79839baa47d9914c411996e09ccfd17ed23b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 13 Dec 2023 13:40:20 -0800 Subject: [PATCH 163/371] Add array shape typing for ilo_construct_preload_links() arg --- .../image-loading-optimization/optimization.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 225b86919b..d4d1e2a5c9 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -30,7 +30,7 @@ function ilo_maybe_add_template_output_buffer_filter() { * @since n.e.x.t * @access private * - * @param array $lcp_elements_by_minimum_viewport_widths LCP images keyed by minimum viewport width, amended with attributes key for the IMG attributes. + * @param array $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_elements_by_minimum_viewport_widths ): string { @@ -46,11 +46,11 @@ function ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_wi } // TODO: Add support for background images. - $img_attributes = $lcp_element['attributes']; + $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`. @@ -61,12 +61,12 @@ function ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_wi 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 = ' $value ) { + foreach ( array_filter( $attributes ) as $name => $value ) { // Map img attribute name to link attribute name. if ( 'srcset' === $name || 'sizes' === $name ) { $name = 'image' . $name; From 0410678069dcf75d11f3f091777389421eb90142 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 13 Dec 2023 14:04:55 -0800 Subject: [PATCH 164/371] Add remaining tests for ilo_construct_preload_links() --- .../optimization-tests.php | 70 +++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 2a354806a9..e23fb722e6 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -36,13 +36,13 @@ public function test_ilo_maybe_add_template_output_buffer_filter() { */ public function data_provider_test_ilo_construct_preload_links(): array { return array( - 'no-lcp-image' => array( + 'no-lcp-image' => array( 'lcp_elements_by_minimum_viewport_widths' => array( 0 => false, ), 'expected' => '', ), - 'one-non-responsive-lcp-image' => array( + 'one-non-responsive-lcp-image' => array( 'lcp_elements_by_minimum_viewport_widths' => array( 0 => array( 'attributes' => array( @@ -54,7 +54,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { ', ), - 'one-responsive-lcp-image' => array( + 'one-responsive-lcp-image' => array( 'lcp_elements_by_minimum_viewport_widths' => array( 0 => array( 'attributes' => array( @@ -69,6 +69,55 @@ public function data_provider_test_ilo_construct_preload_links(): array { ', ), + 'two-breakpoint-responsive-lcp-images' => array( + 'lcp_elements_by_minimum_viewport_widths' => array( + 0 => array( + 'attributes' => array( + 'src' => 'elva-fairy-800w.jpg', + 'srcset' => 'elva-fairy-480w.jpg 480w, elva-fairy-800w.jpg 800w', + 'sizes' => '(max-width: 600px) 480px, 800px', + 'crossorigin' => 'anonymous', + ), + ), + 601 => array( + 'attributes' => array( + 'src' => 'alt-elva-fairy-800w.jpg', + 'srcset' => 'alt-elva-fairy-480w.jpg 480w, alt-elva-fairy-800w.jpg 800w', + 'sizes' => '(max-width: 600px) 480px, 800px', + 'crossorigin' => 'anonymous', + ), + ), + ), + 'expected' => ' + + + ', + ), + 'two-non-consecutive-responsive-lcp-images' => array( + 'lcp_elements_by_minimum_viewport_widths' => array( + 0 => array( + 'attributes' => array( + 'src' => 'elva-fairy-800w.jpg', + 'srcset' => 'elva-fairy-480w.jpg 480w, elva-fairy-800w.jpg 800w', + 'sizes' => '(max-width: 600px) 480px, 800px', + 'crossorigin' => 'anonymous', + ), + ), + 481 => false, + 601 => array( + 'attributes' => array( + 'src' => 'alt-elva-fairy-800w.jpg', + 'srcset' => 'alt-elva-fairy-480w.jpg 480w, alt-elva-fairy-800w.jpg 800w', + 'sizes' => '(max-width: 600px) 480px, 800px', + 'crossorigin' => 'anonymous', + ), + ), + ), + 'expected' => ' + + + ', + ), ); } @@ -80,7 +129,10 @@ public function data_provider_test_ilo_construct_preload_links(): array { * @dataProvider data_provider_test_ilo_construct_preload_links */ public function test_ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_widths, string $expected ) { - $this->assertSame( trim( $expected ), trim( ilo_construct_preload_links( $lcp_elements_by_minimum_viewport_widths ) ) ); + $this->assertSame( + $this->normalize_whitespace( $expected ), + $this->normalize_whitespace( ilo_construct_preload_links( $lcp_elements_by_minimum_viewport_widths ) ) + ); } /** @@ -92,4 +144,14 @@ public function test_ilo_construct_preload_links( array $lcp_elements_by_minimum public function test_ilo_optimize_template_output_buffer() { $this->markTestIncomplete(); } + + /** + * Normalizes whitespace. + * + * @param string $str String to normalize. + * @return string Normalized string. + */ + private function normalize_whitespace( string $str ): string { + return preg_replace( '/\s+/', ' ', trim( $str ) ); + } } From 1b16857d6ef9e04a3939e18a7fdcc9e77fa0960b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 13 Dec 2023 15:50:29 -0800 Subject: [PATCH 165/371] Add tests for ilo_optimize_template_output_buffer() --- composer.json | 1 + .../optimization.php | 3 + phpcs.xml.dist | 12 + .../optimization-tests.php | 314 +++++++++++++++++- 4 files changed, 328 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 27cd7d9afc..2d277b84f2 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "require": { "composer/installers": "~1.0", "php": ">=7|^8", + "ext-dom": "*", "ext-json": "*" }, "scripts": { diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index d4d1e2a5c9..b226bd2717 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -170,6 +170,9 @@ 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(); diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 4661c65e87..c6fa03962a 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -91,5 +91,17 @@ tests/* + + + + + + + + + + + + diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index e23fb722e6..41c3d99376 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -135,14 +135,264 @@ public function test_ilo_construct_preload_links( array $lcp_elements_by_minimum ); } + /** + * Data provider. + * + * @return array[] + */ + public function data_provider_test_ilo_optimize_template_output_buffer(): array { + return array( + 'no-url-metrics' => array( + 'set_up' => static function () {}, + 'buffer' => ' + + + + ... + + + Foo + + + ', + 'expected' => ' + + + + ... + + + + Foo + + + ', + ), + + 'common-lcp-image-with-fully-populated-sample-data' => array( + 'set_up' => function () { + $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + foreach ( array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + ilo_store_url_metric( + home_url( '/' ), + $slug, + $this->get_validated_url_metric( + $viewport_width, + array( + array( + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + 'isLCP' => true, + ), + array( + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + 'isLCP' => false, + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + + Foo + Bar + + + ', + 'expected' => ' + + + + ... + + + + Foo + Bar + + + ', + ), + + 'fetch-priority-high-already-on-common-lcp-image-with-fully-populated-sample-data' => array( + 'set_up' => function () { + $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + foreach ( array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + ilo_store_url_metric( + home_url( '/' ), + $slug, + $this->get_validated_url_metric( + $viewport_width, + array( + array( + 'isLCP' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + + Foo + + + ', + 'expected' => ' + + + + ... + + + + Foo + + + ', + ), + + 'url-metric-only-captured-for-one-breakpoint' => array( + 'set_up' => function () { + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 400, + array( + array( + 'isLCP' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + + Foo + + + ', + 'expected' => ' + + + + ... + + + + + Foo + + + ', + ), + + 'different-lcp-elements-for-different-breakpoints' => array( + 'set_up' => function () { + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 400, + array( + array( + 'isLCP' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + ), + ) + ) + ); + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 800, + array( + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + array( + 'isLCP' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + + Mobile Logo + Desktop Logo + + + ', + 'expected' => ' + + + + ... + + + + + + Mobile Logo + Desktop Logo + + + ', + ), + + ); + } + /** * Test ilo_optimize_template_output_buffer(). * * @test * @covers ::ilo_optimize_template_output_buffer + * @dataProvider data_provider_test_ilo_optimize_template_output_buffer */ - public function test_ilo_optimize_template_output_buffer() { - $this->markTestIncomplete(); + public function test_ilo_optimize_template_output_buffer( Closure $set_up, string $buffer, string $expected ) { + $set_up(); + $this->assertEquals( + $this->parse_html_document( $expected ), + $this->parse_html_document( ilo_optimize_template_output_buffer( $buffer ) ) + ); } /** @@ -154,4 +404,64 @@ public function test_ilo_optimize_template_output_buffer() { private function normalize_whitespace( string $str ): string { return preg_replace( '/\s+/', ' ', trim( $str ) ); } + + /** + * Gets a validated URL metric. + * + * @param int $viewport_width Viewport width for the URL metric. + * @return array URL metric. + */ + private function get_validated_url_metric( int $viewport_width, array $elements = array() ): array { + return array( + 'viewport' => array( + 'width' => $viewport_width, + 'height' => 800, + ), + 'elements' => array_map( + static function ( array $element ): array { + return array_merge( + array( + 'isLCPCandidate' => true, + 'intersectionRatio' => 1, + ), + $element + ); + }, + $elements + ), + ); + } + + /** + * Parse an HTML markup fragment and normalize for comparison. + * + * @param string $markup Markup. + * @return DOMDocument Document containing the normalized markup fragment. + */ + protected function parse_html_document( string $markup ): DOMDocument { + $dom = new DOMDocument(); + $dom->loadHTML( trim( $markup ) ); + + // Remove all whitespace nodes. + $xpath = new DOMXPath( $dom ); + foreach ( $xpath->query( '//text()' ) as $node ) { + /** @var DOMText $node */ + if ( preg_match( '/^\s+$/', $node->nodeValue ) ) { + $node->nodeValue = ''; + } + } + + // Insert a newline before each node to make the diff easier to read. + foreach ( $xpath->query( '/html//*' ) as $node ) { + /** @var DOMElement $node */ + $node->parentNode->insertBefore( $dom->createTextNode( "\n" ), $node ); + } + + // Normalize contents of module script output by ilo_get_detection_script(). + foreach ( $xpath->query( '//script[ contains( text(), "import detect" ) ]' ) as $script ) { + $script->textContent = '/* import detect ... */'; + } + + return $dom; + } } From 9265b9d9dd26e7fa91bf4b8d2dfac41c2b294587 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 13 Dec 2023 16:34:47 -0800 Subject: [PATCH 166/371] Fix REST API endpoint to reject metrics for specific unneeded viewport width --- .../storage/rest-api.php | 16 ++++++-- .../storage/rest-api-tests.php | 39 ++++++++++++++++--- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 195af304ad..378826148b 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -173,11 +173,21 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { ilo_get_url_metric_freshness_ttl() ); - // TODO: This is not right. It is asking if it is needed for any breakpoint, not if it is needed for the supplied breakpoint. This logic here is specific for the frontend. - if ( ! ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths ) ) { + // Block the request if URL metrics aren't needed for the provided viewport width. + // This logic is the same as the isViewportNeeded() function in detect.js. + $viewport_width = $request->get_param( 'viewport' )['width']; + $last_was_needed = false; + foreach ( $needed_minimum_viewport_widths as list( $minimum_viewport_width, $is_needed ) ) { + if ( $viewport_width >= $minimum_viewport_width ) { + $last_was_needed = $is_needed; + } else { + break; + } + } + if ( ! $last_was_needed ) { return new WP_Error( 'no_url_metric_needed', - __( 'No URL metric needed for any of the breakpoints.', 'performance-lab' ), + __( 'No URL metric needed for the provided viewport width.', 'performance-lab' ), array( 'status' => 403 ) ); } diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index e686495fa4..42fae8beb2 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -162,24 +162,23 @@ public function test_rest_request_locked() { $this->assertSame( 'url_metric_storage_locked', $response->get_data()['code'] ); } - /** - * Test sending viewport data that isn't needed. + * Test sending viewport data that isn't needed for a specific breakpoint. * * @test * @covers ::ilo_register_endpoint * @covers ::ilo_handle_rest_request */ - public function test_rest_request_breakpoint_not_needed() { + public function test_rest_request_breakpoint_not_needed_for_any_breakpoint() { add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); - // First fully populate the sample for a given breakpoint. + // First fully populate the sample for all breakpoints. $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); $viewport_widths = array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ); - foreach ( $viewport_widths as $breakpoint_width ) { + foreach ( $viewport_widths as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { $valid_params = $this->get_valid_params(); - $valid_params['viewport']['width'] = $breakpoint_width; + $valid_params['viewport']['width'] = $viewport_width; $request = new WP_REST_Request( 'POST', self::ROUTE ); $request->set_body_params( $valid_params ); $response = rest_get_server()->dispatch( $request ); @@ -194,6 +193,34 @@ public function test_rest_request_breakpoint_not_needed() { $this->assertSame( 403, $response->get_status() ); } + /** + * Test sending viewport data that isn't needed for any breakpoint. + * + * @test + * @covers ::ilo_register_endpoint + * @covers ::ilo_handle_rest_request + */ + public function test_rest_request_breakpoint_not_needed_for_specific_breakpoint() { + add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); + + // First fully populate the sample for a given breakpoint. + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + for ( $i = 0; $i < $sample_size; $i++ ) { + $valid_params = $this->get_valid_params(); + $valid_params['viewport']['width'] = 480; + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $valid_params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + } + + // The next request with the same sample size will be rejected. + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $this->get_valid_params() ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status() ); + } + /** * Gets valid params. * From 1b219f5413f37a1b246c4ae0c359769b8ee4a2ba Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 13 Dec 2023 16:43:08 -0800 Subject: [PATCH 167/371] Remove debug code --- .../images/image-loading-optimization/storage/rest-api.php | 2 -- .../image-loading-optimization/storage/rest-api-tests.php | 5 +++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 378826148b..6ddc928038 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -208,8 +208,6 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { return new WP_REST_Response( array( 'success' => true, - 'post_id' => $result, - 'data' => ilo_parse_stored_url_metrics( ilo_get_url_metrics_post( $request->get_param( 'slug' ) ) ), // TODO: Remove this debug data. ) ); } diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index 42fae8beb2..9016460ace 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -33,16 +33,17 @@ public function test_ilo_register_endpoint_hooked() { public function test_rest_request_good_params() { $request = new WP_REST_Request( 'POST', self::ROUTE ); $valid_params = $this->get_valid_params(); + $this->assertCount( 0, get_posts( array( 'post_type' => ILO_URL_METRICS_POST_TYPE ) ) ); $request->set_body_params( $valid_params ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); $this->assertTrue( $data['success'] ); - $this->assertIsInt( $data['post_id'] ); + $this->assertCount( 1, get_posts( array( 'post_type' => ILO_URL_METRICS_POST_TYPE ) ) ); $post = ilo_get_url_metrics_post( $valid_params['slug'] ); - $this->assertSame( $post->ID, $data['post_id'] ); + $this->assertInstanceOf( WP_Post::class, $post ); $url_metrics = ilo_parse_stored_url_metrics( $post ); $this->assertCount( 1, $url_metrics ); From e1ac28eb6cd9deefb3a235b77bdcffe51cb4f5d5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 13 Dec 2023 16:49:59 -0800 Subject: [PATCH 168/371] Remove todo --- modules/images/image-loading-optimization/storage/post-type.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index c68fd8484f..00d397af04 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -46,8 +46,6 @@ 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. */ From 7324ce267178c7e3c986c3957224949988b4cd54 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 14 Dec 2023 10:31:27 -0800 Subject: [PATCH 169/371] Use ILO prefix for test classes --- .../images/image-loading-optimization/storage/data-tests.php | 2 +- .../images/image-loading-optimization/storage/lock-tests.php | 2 +- .../image-loading-optimization/storage/post-type-tests.php | 2 +- .../image-loading-optimization/storage/rest-api-tests.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 16d2d6c93f..91e93163d8 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -6,7 +6,7 @@ * @group image-loading-optimization */ -class Image_Loading_Optimization_Storage_Data_Tests extends WP_UnitTestCase { +class ILO_Storage_Data_Tests extends WP_UnitTestCase { public function tear_down() { unset( $GLOBALS['wp_customize'] ); diff --git a/tests/modules/images/image-loading-optimization/storage/lock-tests.php b/tests/modules/images/image-loading-optimization/storage/lock-tests.php index 983d0af3b3..d5c12c5500 100644 --- a/tests/modules/images/image-loading-optimization/storage/lock-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/lock-tests.php @@ -6,7 +6,7 @@ * @group image-loading-optimization */ -class Image_Loading_Optimization_Storage_Lock_Tests extends WP_UnitTestCase { +class ILO_Storage_Lock_Tests extends WP_UnitTestCase { /** * Tear down. diff --git a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php index c9869f9e69..03b8cbb670 100644 --- a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php @@ -6,7 +6,7 @@ * @group image-loading-optimization */ -class Image_Loading_Optimization_Storage_Post_Type_Tests extends WP_UnitTestCase { +class ILO_Storage_Post_Type_Tests extends WP_UnitTestCase { /** * Test ilo_register_url_metrics_post_type(). diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index 9016460ace..d69a404748 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -6,7 +6,7 @@ * @group image-loading-optimization */ -class Image_Loading_Optimization_Storage_REST_API_Tests extends WP_UnitTestCase { +class ILO_Storage_REST_API_Tests extends WP_UnitTestCase { /** * @var string From d95c5859238b71d3498700c995d75bdf5fa430d2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 15 Dec 2023 12:12:48 -0800 Subject: [PATCH 170/371] Detect background-image inline style on elements when iterating --- .../detection/detect.js | 17 +++-------------- .../image-loading-optimization/optimization.php | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 142a765e90..48b002fd69 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -202,23 +202,12 @@ export default async function detect( { /** @type {?HTMLDivElement} */ doc.getElementById( adminBarId ); // TODO: This query no longer needs to be done as early as possible since the server is adding the breadcrumbs. - const breadcrumbedImages = doc.body.querySelectorAll( - 'img[data-ilo-xpath]' - ); - - // We do the same for elements with background images which are not data: URLs. - // TODO: Re-enable background image support when server-side is implemented. - // const breadcrumbedElementsWithBackgrounds = Array.from( - // doc.body.querySelectorAll( '[data-ilo-xpath][style*="background"]' ) - // ).filter( ( /** @type {Element} */ el ) => - // /url\(\s*['"](?!=data:)/.test( el.style.backgroundImage ) - // ); + const breadcrumbedElements = + doc.body.querySelectorAll( '[data-ilo-xpath]' ); /** @type {Map} */ const breadcrumbedElementsMap = new Map( - [ - ...breadcrumbedImages /*, ...breadcrumbedElementsWithBackgrounds*/, - ].map( + [ ...breadcrumbedElements ].map( /** * @param {HTMLElement} element * @return {[HTMLElement, string]} Tuple of element and its XPath. diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index b226bd2717..057d3595fa 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -143,10 +143,23 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // Walk over all IMG tags in the document and ensure fetchpriority is set/removed, and gather IMG attributes for preloading. $processor = new ILO_HTML_Tag_Processor( $buffer ); foreach ( $processor->open_tags() as $tag_name ) { - if ( 'IMG' !== $tag_name ) { + $style = $processor->get_attribute( 'style' ); + $background_images = array(); + // TODO: The background image could be supplied via `background` shorthand as well. + // TODO: Multiple background images may be layered. + if ( $style && preg_match( '/background-image\s*:\s*url\(\s*[\'"]?(?!data:)(?.+?)[\'"]?\s*\)/', $style, $matches ) ) { + $background_images[] = $matches['background_image']; + } + + if ( ! ( 'IMG' === $tag_name || $background_images ) ) { continue; } + // DEBUG. + if ( $background_images ) { + $processor->set_attribute( 'data-ilo-has-bg-image', implode( ' ', $background_images ) ); + } + $xpath = $processor->get_xpath(); // Ensure the fetchpriority attribute is set on the element properly. From 0942fa5f51e20502b41d224d3d8cd3847226d2c5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 19 Dec 2023 23:04:08 -0800 Subject: [PATCH 171/371] Implement initial support for preloading LCP background images --- .../optimization.php | 110 +++++++++++------- .../optimization-tests.php | 55 ++++++++- 2 files changed, 114 insertions(+), 51 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 057d3595fa..70dca0b969 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -30,7 +30,7 @@ function ilo_maybe_add_template_output_buffer_filter() { * @since n.e.x.t * @access private * - * @param array $lcp_elements_by_minimum_viewport_widths LCP images keyed by minimum viewport width, amended with attributes key for the IMG attributes. + * @param array $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_elements_by_minimum_viewport_widths ): string { @@ -45,35 +45,43 @@ function ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_wi continue; } - // TODO: Add support for background images. - $attributes = $lcp_element['attributes']; + $link_attributes = array(); - // Prevent preloading src for browsers that don't support imagesrcset on the link element. - if ( isset( $attributes['src'], $attributes['srcset'] ) ) { - unset( $attributes['src'] ); + if ( ! empty( $lcp_element['background_image'] ) ) { + $link_attributes['href'] = $lcp_element['background_image']; + } elseif ( ! empty( $lcp_element['img_attributes'] ) ) { + $img_attributes = $lcp_element['img_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'] ); + } + + foreach ( $img_attributes as $name => $value ) { + // Map img attribute name to link attribute name. + if ( 'srcset' === $name || 'sizes' === $name ) { + $name = 'image' . $name; + } elseif ( 'src' === $name ) { + $name = 'href'; + } + $link_attributes[ $name ] = $value; + } } // Add media query if it's going to be something other than just `min-width: 0px`. $minimum_viewport_width = $minimum_viewport_widths[ $i ]; $maximum_viewport_width = isset( $minimum_viewport_widths[ $i + 1 ] ) ? $minimum_viewport_widths[ $i + 1 ] - 1 : null; if ( $minimum_viewport_width > 0 || null !== $maximum_viewport_width ) { - $media_query = sprintf( '( min-width: %dpx )', $minimum_viewport_width ); + $media_query = sprintf( '( min-width: %dpx )', $minimum_viewport_width ); // TODO: No need to add min-width:0px. if ( null !== $maximum_viewport_width ) { $media_query .= sprintf( ' and ( max-width: %dpx )', $maximum_viewport_width ); } - $attributes['media'] = $media_query; + $link_attributes['media'] = $media_query; } // Construct preload link. $link_tag = ' $value ) { - // Map img attribute name to link attribute name. - if ( 'srcset' === $name || 'sizes' === $name ) { - $name = 'image' . $name; - } elseif ( 'src' === $name ) { - $name = 'href'; - } - + foreach ( $link_attributes as $name => $value ) { $link_tag .= sprintf( ' %s="%s"', $name, esc_attr( $value ) ); } $link_tag .= ">\n"; @@ -143,44 +151,47 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // Walk over all IMG tags in the document and ensure fetchpriority is set/removed, and gather IMG attributes for preloading. $processor = new ILO_HTML_Tag_Processor( $buffer ); foreach ( $processor->open_tags() as $tag_name ) { - $style = $processor->get_attribute( 'style' ); - $background_images = array(); + $is_img_tag = ( 'IMG' === $tag_name ); + $style = $processor->get_attribute( 'style' ); + $background_image = null; // TODO: Could be an array. // TODO: The background image could be supplied via `background` shorthand as well. // TODO: Multiple background images may be layered. if ( $style && preg_match( '/background-image\s*:\s*url\(\s*[\'"]?(?!data:)(?.+?)[\'"]?\s*\)/', $style, $matches ) ) { - $background_images[] = $matches['background_image']; + $background_image = $matches['background_image']; } - if ( ! ( 'IMG' === $tag_name || $background_images ) ) { + if ( ! ( $is_img_tag || $background_image ) ) { continue; } // DEBUG. - if ( $background_images ) { - $processor->set_attribute( 'data-ilo-has-bg-image', implode( ' ', $background_images ) ); + if ( $background_image ) { + $processor->set_attribute( 'data-ilo-has-bg-image', $background_image ); } $xpath = $processor->get_xpath(); // Ensure the fetchpriority attribute is set on the element properly. - if ( $common_lcp_element && $xpath === $common_lcp_element['xpath'] ) { - if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { - $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); - } else { - $processor->set_attribute( 'fetchpriority', 'high' ); - $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); - } - - // Never include loading=lazy on the LCP image common across all breakpoints. - if ( 'lazy' === $processor->get_attribute( 'loading' ) ) { - $processor->set_attribute( 'data-ilo-removed-loading', $processor->get_attribute( 'loading' ) ); - $processor->remove_attribute( 'loading' ); + if ( $is_img_tag ) { + if ( $common_lcp_element && $xpath === $common_lcp_element['xpath'] ) { + if ( 'high' === $processor->get_attribute( 'fetchpriority' ) ) { + $processor->set_attribute( 'data-ilo-fetchpriority-already-added', true ); + } else { + $processor->set_attribute( 'fetchpriority', 'high' ); + $processor->set_attribute( 'data-ilo-added-fetchpriority', true ); + } + + // Never include loading=lazy on the LCP image common across all breakpoints. + if ( 'lazy' === $processor->get_attribute( 'loading' ) ) { + $processor->set_attribute( 'data-ilo-removed-loading', $processor->get_attribute( 'loading' ) ); + $processor->remove_attribute( 'loading' ); + } + } elseif ( $all_breakpoints_have_url_metrics && $processor->get_attribute( 'fetchpriority' ) ) { + // Note: The $all_breakpoints_have_url_metrics condition here allows for server-side heuristics to + // continue to apply while waiting for all breakpoints to have metrics collected for them. + $processor->set_attribute( 'data-ilo-removed-fetchpriority', $processor->get_attribute( 'fetchpriority' ) ); + $processor->remove_attribute( 'fetchpriority' ); } - } elseif ( $all_breakpoints_have_url_metrics && $processor->get_attribute( 'fetchpriority' ) ) { - // Note: The $all_breakpoints_have_url_metrics condition here allows for server-side heuristics to - // continue to apply while waiting for all breakpoints to have metrics collected for them. - $processor->set_attribute( 'data-ilo-removed-fetchpriority', $processor->get_attribute( 'fetchpriority' ) ); - $processor->remove_attribute( 'fetchpriority' ); } // TODO: If the image is visible (intersectionRatio!=0) in any of the URL metrics, remove loading=lazy. @@ -188,12 +199,21 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // 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' ) as $attr_name ) { - $attributes[ $attr_name ] = $processor->get_attribute( $attr_name ); - } - foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] as $minimum_viewport_width ) { - $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['attributes'] = $attributes; + if ( $is_img_tag ) { + $img_attributes = array(); + foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin' ) as $attr_name ) { + $value = $processor->get_attribute( $attr_name ); + if ( null !== $value ) { + $img_attributes[ $attr_name ] = $value; + } + } + foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] as $minimum_viewport_width ) { + $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['img_attributes'] = $img_attributes; + } + } elseif ( $background_image ) { + foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] as $minimum_viewport_width ) { + $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['background_image'] = $background_image; + } } } diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 41c3d99376..4009300ada 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -45,7 +45,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { 'one-non-responsive-lcp-image' => array( 'lcp_elements_by_minimum_viewport_widths' => array( 0 => array( - 'attributes' => array( + 'img_attributes' => array( 'src' => 'https://example.com/image.jpg', ), ), @@ -57,7 +57,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { 'one-responsive-lcp-image' => array( 'lcp_elements_by_minimum_viewport_widths' => array( 0 => array( - 'attributes' => array( + 'img_attributes' => array( 'src' => 'elva-fairy-800w.jpg', 'srcset' => 'elva-fairy-480w.jpg 480w, elva-fairy-800w.jpg 800w', 'sizes' => '(max-width: 600px) 480px, 800px', @@ -72,7 +72,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { 'two-breakpoint-responsive-lcp-images' => array( 'lcp_elements_by_minimum_viewport_widths' => array( 0 => array( - 'attributes' => array( + 'img_attributes' => array( 'src' => 'elva-fairy-800w.jpg', 'srcset' => 'elva-fairy-480w.jpg 480w, elva-fairy-800w.jpg 800w', 'sizes' => '(max-width: 600px) 480px, 800px', @@ -80,7 +80,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), ), 601 => array( - 'attributes' => array( + 'img_attributes' => array( 'src' => 'alt-elva-fairy-800w.jpg', 'srcset' => 'alt-elva-fairy-480w.jpg 480w, alt-elva-fairy-800w.jpg 800w', 'sizes' => '(max-width: 600px) 480px, 800px', @@ -96,7 +96,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { 'two-non-consecutive-responsive-lcp-images' => array( 'lcp_elements_by_minimum_viewport_widths' => array( 0 => array( - 'attributes' => array( + 'img_attributes' => array( 'src' => 'elva-fairy-800w.jpg', 'srcset' => 'elva-fairy-480w.jpg 480w, elva-fairy-800w.jpg 800w', 'sizes' => '(max-width: 600px) 480px, 800px', @@ -105,7 +105,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), 481 => false, 601 => array( - 'attributes' => array( + 'img_attributes' => array( 'src' => 'alt-elva-fairy-800w.jpg', 'srcset' => 'alt-elva-fairy-480w.jpg 480w, alt-elva-fairy-800w.jpg 800w', 'sizes' => '(max-width: 600px) 480px, 800px', @@ -118,6 +118,49 @@ public function data_provider_test_ilo_construct_preload_links(): array { ', ), + 'one-background-lcp-image' => array( + 'lcp_elements_by_minimum_viewport_widths' => array( + 0 => array( + 'background_image' => 'https://example.com/image.jpg', + ), + ), + 'expected' => ' + + ', + ), + 'two-background-lcp-images' => array( + 'lcp_elements_by_minimum_viewport_widths' => array( + 0 => array( + 'background_image' => 'https://example.com/mobile.jpg', + ), + 481 => array( + 'background_image' => 'https://example.com/desktop.jpg', + ), + ), + 'expected' => ' + + + ', + ), + 'one-bg-image-one-img-element' => array( + 'lcp_elements_by_minimum_viewport_widths' => array( + 0 => array( + 'img_attributes' => array( + 'src' => 'mobile-800w.jpg', + 'srcset' => 'mobile-480w.jpg 480w, mobile-800w.jpg 800w', + 'sizes' => '(max-width: 600px) 480px, 800px', + 'crossorigin' => 'anonymous', + ), + ), + 481 => array( + 'background_image' => 'https://example.com/desktop.jpg', + ), + ), + 'expected' => ' + + + ', + ), ); } From 19459d8b1c7b6c344a6f24105321c058d893bae5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 19 Dec 2023 23:24:24 -0800 Subject: [PATCH 172/371] Add E2E test for background-image optimization --- .../optimization-tests.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 4009300ada..0714ead3f5 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -265,6 +265,53 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array ', ), + 'common-lcp-background-image-with-fully-populated-sample-data' => array( + 'set_up' => function () { + $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + foreach ( array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + ilo_store_url_metric( + home_url( '/' ), + $slug, + $this->get_validated_url_metric( + $viewport_width, + array( + array( + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]', + 'isLCP' => true, + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + +
      This is so background!
      + + + ', + 'expected' => ' + + + + ... + + + +
      This is so background!
      + + + ', + ), + 'fetch-priority-high-already-on-common-lcp-image-with-fully-populated-sample-data' => array( 'set_up' => function () { $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); From 2df915d88af41e2dc2c894278fd94d2cb828aa90 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 21 Dec 2023 09:19:31 -0800 Subject: [PATCH 173/371] Move XPath pattern definition to ILO_HTML_Tag_Processor --- .../class-ilo-html-tag-processor.php | 8 ++++++++ .../image-loading-optimization/storage/rest-api.php | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 65a4516f23..f42bdce21f 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -88,6 +88,14 @@ final class ILO_HTML_Tag_Processor { 'UL', ); + /** + * Pattern for valid XPath subset for breadcrumb. + * + * @see self::get_xpath() + * @var string + */ + const XPATH_PATTERN = '^(/\*\[\d+\]\[self::.+?\])+$'; + /** * Open stack tags. * diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 607e094717..f12f3c36cb 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -133,7 +133,7 @@ function ilo_register_endpoint() { 'xpath' => array( 'type' => 'string', 'required' => true, - 'pattern' => '^(/\*\[\d+\]\[self::.+?\])+$', // See ILO_HTML_Tag_Processor::get_xpath() for format. + 'pattern' => ILO_HTML_Tag_Processor::XPATH_PATTERN, ), 'intersectionRatio' => array( 'type' => 'number', From 966c5b305bd427972dd24e5b79e1202329d7c3b3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 21 Dec 2023 09:38:43 -0800 Subject: [PATCH 174/371] Use ILO prefix for test classes --- .../class-ilo-html-tag-processor-tests.php | 2 +- .../images/image-loading-optimization/detection-tests.php | 2 +- tests/modules/images/image-loading-optimization/hooks-tests.php | 2 +- .../images/image-loading-optimization/optimization-tests.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php index 158d6e29c1..62249b7849 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php @@ -7,7 +7,7 @@ * * @coversDefaultClass ILO_HTML_Tag_Processor */ -class Image_Loading_Optimization_ILO_HTML_Tag_Processor_Tests extends WP_UnitTestCase { +class ILO_HTML_Tag_Processor_Tests extends WP_UnitTestCase { public function data_provider_sample_documents(): array { return array( diff --git a/tests/modules/images/image-loading-optimization/detection-tests.php b/tests/modules/images/image-loading-optimization/detection-tests.php index 1fe1db6235..278e76fc26 100644 --- a/tests/modules/images/image-loading-optimization/detection-tests.php +++ b/tests/modules/images/image-loading-optimization/detection-tests.php @@ -6,7 +6,7 @@ * @group image-loading-optimization */ -class Image_Loading_Optimization_Detection_Tests extends WP_UnitTestCase { +class ILO_Detection_Tests extends WP_UnitTestCase { /** * Data provider. diff --git a/tests/modules/images/image-loading-optimization/hooks-tests.php b/tests/modules/images/image-loading-optimization/hooks-tests.php index 6d7307aa57..7842757b91 100644 --- a/tests/modules/images/image-loading-optimization/hooks-tests.php +++ b/tests/modules/images/image-loading-optimization/hooks-tests.php @@ -6,7 +6,7 @@ * @group image-loading-optimization */ -class Image_Loading_Optimization_Hooks_Tests extends WP_UnitTestCase { +class ILO_Hooks_Tests extends WP_UnitTestCase { /** * Make sure the hook is added. diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 41c3d99376..a193f59fc2 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -6,7 +6,7 @@ * @group image-loading-optimization */ -class Image_Loading_Optimization_Optimization_Tests extends WP_UnitTestCase { +class ILO_Optimization_Tests extends WP_UnitTestCase { /** * Test ilo_maybe_add_template_output_buffer_filter(). From 9d648280e8f16efedcf9e36d4e110f4353ac2384 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 21 Dec 2023 10:01:59 -0800 Subject: [PATCH 175/371] Add server timing for image-loading-optimization --- modules/images/image-loading-optimization/optimization.php | 6 +++++- .../image-loading-optimization/optimization-tests.php | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 70dca0b969..ee4b4963aa 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -20,7 +20,11 @@ function ilo_maybe_add_template_output_buffer_filter() { if ( ! ilo_can_optimize_response() ) { return; } - add_filter( 'ilo_template_output_buffer', 'ilo_optimize_template_output_buffer' ); + $callback = 'ilo_optimize_template_output_buffer'; + if ( function_exists( 'perflab_wrap_server_timing' ) ) { + $callback = perflab_wrap_server_timing( $callback, 'image-loading-optimization', 'exist' ); + } + add_filter( 'ilo_template_output_buffer', $callback ); } add_action( 'wp', 'ilo_maybe_add_template_output_buffer_filter' ); diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 0714ead3f5..50a7659cbd 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -15,18 +15,18 @@ class Image_Loading_Optimization_Optimization_Tests extends WP_UnitTestCase { * @covers ::ilo_maybe_add_template_output_buffer_filter */ public function test_ilo_maybe_add_template_output_buffer_filter() { - $this->assertFalse( has_filter( 'ilo_template_output_buffer', 'ilo_optimize_template_output_buffer' ) ); + $this->assertFalse( has_filter( 'ilo_template_output_buffer' ) ); add_filter( 'ilo_can_optimize_response', '__return_false', 1 ); ilo_maybe_add_template_output_buffer_filter(); $this->assertFalse( ilo_can_optimize_response() ); - $this->assertFalse( has_filter( 'ilo_template_output_buffer', 'ilo_optimize_template_output_buffer' ) ); + $this->assertFalse( has_filter( 'ilo_template_output_buffer' ) ); add_filter( 'ilo_can_optimize_response', '__return_true', 2 ); $this->go_to( home_url( '/' ) ); $this->assertTrue( ilo_can_optimize_response() ); ilo_maybe_add_template_output_buffer_filter(); - $this->assertSame( 10, has_filter( 'ilo_template_output_buffer', 'ilo_optimize_template_output_buffer' ) ); + $this->assertTrue( has_filter( 'ilo_template_output_buffer' ) ); } /** From c6ff3c035d0515578b280c14d852929aa71e3fb6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 21 Dec 2023 10:38:58 -0800 Subject: [PATCH 176/371] Remove obsolete link preload workaround for responsive images --- .../image-loading-optimization/optimization.php | 9 +-------- .../optimization-tests.php | 12 ++++++------ 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index ee4b4963aa..c5d984d085 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -54,14 +54,7 @@ function ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_wi if ( ! empty( $lcp_element['background_image'] ) ) { $link_attributes['href'] = $lcp_element['background_image']; } elseif ( ! empty( $lcp_element['img_attributes'] ) ) { - $img_attributes = $lcp_element['img_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'] ); - } - - foreach ( $img_attributes as $name => $value ) { + foreach ( $lcp_element['img_attributes'] as $name => $value ) { // Map img attribute name to link attribute name. if ( 'srcset' === $name || 'sizes' === $name ) { $name = 'image' . $name; diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 50a7659cbd..31647e0904 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -66,7 +66,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), ), 'expected' => ' - + ', ), 'two-breakpoint-responsive-lcp-images' => array( @@ -89,8 +89,8 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), ), 'expected' => ' - - + + ', ), 'two-non-consecutive-responsive-lcp-images' => array( @@ -114,8 +114,8 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), ), 'expected' => ' - - + + ', ), 'one-background-lcp-image' => array( @@ -157,7 +157,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), ), 'expected' => ' - + ', ), From 28db82777afcad97aba69792b92f39e022718e06 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 21 Dec 2023 10:44:29 -0800 Subject: [PATCH 177/371] Run composer update --- composer.lock | 186 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 121 insertions(+), 65 deletions(-) diff --git a/composer.lock b/composer.lock index 6a9ae7127c..fbc2a7cd1f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2dcd132f2c017c64da30a4a4b6f78f29", + "content-hash": "343d54db29b4354eed69ae570fa85247", "packages": [ { "name": "composer/installers", @@ -365,16 +365,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.17.1", + "version": "v4.18.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" + "reference": "1bcbb2179f97633e98bbbc87044ee2611c7d7999" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/1bcbb2179f97633e98bbbc87044ee2611c7d7999", + "reference": "1bcbb2179f97633e98bbbc87044ee2611c7d7999", "shasum": "" }, "require": { @@ -415,9 +415,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.17.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.18.0" }, - "time": "2023-08-13T19:53:39+00:00" + "time": "2023-12-10T21:03:43+00:00" }, { "name": "phar-io/manifest", @@ -532,25 +532,27 @@ }, { "name": "php-stubs/wordpress-stubs", - "version": "v6.4.0", + "version": "v6.4.1", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "286d42eeb44c6808633cc59b8dbb9aa75fe41264" + "reference": "6d6063cf9464a306ca2a0529705d41312b08500b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/286d42eeb44c6808633cc59b8dbb9aa75fe41264", - "reference": "286d42eeb44c6808633cc59b8dbb9aa75fe41264", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/6d6063cf9464a306ca2a0529705d41312b08500b", + "reference": "6d6063cf9464a306ca2a0529705d41312b08500b", "shasum": "" }, "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "nikic/php-parser": "^4.13", "php": "^7.4 || ~8.0.0", "php-stubs/generator": "^0.8.3", "phpdocumentor/reflection-docblock": "^5.3", "phpstan/phpstan": "^1.10.12", - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^9.5", + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^0.8" }, "suggest": { "paragonie/sodium_compat": "Pure PHP implementation of libsodium", @@ -571,9 +573,9 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.4.0" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.4.1" }, - "time": "2023-11-08T07:02:08+00:00" + "time": "2023-11-10T00:33:47+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -639,29 +641,29 @@ }, { "name": "phpcsstandards/phpcsextra", - "version": "1.1.2", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "746c3190ba8eb2f212087c947ba75f4f5b9a58d5" + "reference": "11d387c6642b6e4acaf0bd9bf5203b8cca1ec489" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/746c3190ba8eb2f212087c947ba75f4f5b9a58d5", - "reference": "746c3190ba8eb2f212087c947ba75f4f5b9a58d5", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/11d387c6642b6e4acaf0bd9bf5203b8cca1ec489", + "reference": "11d387c6642b6e4acaf0bd9bf5203b8cca1ec489", "shasum": "" }, "require": { "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.0.8", - "squizlabs/php_codesniffer": "^3.7.1" + "phpcsstandards/phpcsutils": "^1.0.9", + "squizlabs/php_codesniffer": "^3.8.0" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0", "php-parallel-lint/php-parallel-lint": "^1.3.2", "phpcsstandards/phpcsdevcs": "^1.1.6", "phpcsstandards/phpcsdevtools": "^1.2.1", - "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0" + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0" }, "type": "phpcodesniffer-standard", "extra": { @@ -696,35 +698,50 @@ ], "support": { "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", "source": "https://github.com/PHPCSStandards/PHPCSExtra" }, - "time": "2023-09-20T22:06:18+00:00" + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + } + ], + "time": "2023-12-08T16:49:07+00:00" }, { "name": "phpcsstandards/phpcsutils", - "version": "1.0.8", + "version": "1.0.9", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", - "reference": "69465cab9d12454e5e7767b9041af0cd8cd13be7" + "reference": "908247bc65010c7b7541a9551e002db12e9dae70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/69465cab9d12454e5e7767b9041af0cd8cd13be7", - "reference": "69465cab9d12454e5e7767b9041af0cd8cd13be7", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/908247bc65010c7b7541a9551e002db12e9dae70", + "reference": "908247bc65010c7b7541a9551e002db12e9dae70", "shasum": "" }, "require": { "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", "php": ">=5.4", - "squizlabs/php_codesniffer": "^3.7.1 || 4.0.x-dev@dev" + "squizlabs/php_codesniffer": "^3.8.0 || 4.0.x-dev@dev" }, "require-dev": { "ext-filter": "*", "php-parallel-lint/php-console-highlighter": "^1.0", "php-parallel-lint/php-parallel-lint": "^1.3.2", "phpcsstandards/phpcsdevcs": "^1.1.6", - "yoast/phpunit-polyfills": "^1.0.5 || ^2.0.0" + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0" }, "type": "phpcodesniffer-standard", "extra": { @@ -769,9 +786,24 @@ "support": { "docs": "https://phpcsutils.com/", "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", "source": "https://github.com/PHPCSStandards/PHPCSUtils" }, - "time": "2023-07-16T21:39:41+00:00" + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + } + ], + "time": "2023-12-08T14:50:00+00:00" }, { "name": "phpstan/extension-installer", @@ -819,16 +851,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.24.2", + "version": "1.24.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "bcad8d995980440892759db0c32acae7c8e79442" + "reference": "fedf211ff14ec8381c9bf5714e33a7a552dd1acc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/bcad8d995980440892759db0c32acae7c8e79442", - "reference": "bcad8d995980440892759db0c32acae7c8e79442", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fedf211ff14ec8381c9bf5714e33a7a552dd1acc", + "reference": "fedf211ff14ec8381c9bf5714e33a7a552dd1acc", "shasum": "" }, "require": { @@ -860,22 +892,22 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.24.2" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.24.5" }, - "time": "2023-09-26T12:28:12+00:00" + "time": "2023-12-16T09:33:33+00:00" }, { "name": "phpstan/phpstan", - "version": "1.10.41", + "version": "1.10.50", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "c6174523c2a69231df55bdc65b61655e72876d76" + "reference": "06a98513ac72c03e8366b5a0cb00750b487032e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6174523c2a69231df55bdc65b61655e72876d76", - "reference": "c6174523c2a69231df55bdc65b61655e72876d76", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/06a98513ac72c03e8366b5a0cb00750b487032e4", + "reference": "06a98513ac72c03e8366b5a0cb00750b487032e4", "shasum": "" }, "require": { @@ -924,7 +956,7 @@ "type": "tidelift" } ], - "time": "2023-11-05T12:57:57+00:00" + "time": "2023-12-13T10:59:42+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -1347,16 +1379,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.13", + "version": "9.6.15", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f3d767f7f9e191eab4189abe41ab37797e30b1be" + "reference": "05017b80304e0eb3f31d90194a563fd53a6021f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f3d767f7f9e191eab4189abe41ab37797e30b1be", - "reference": "f3d767f7f9e191eab4189abe41ab37797e30b1be", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/05017b80304e0eb3f31d90194a563fd53a6021f1", + "reference": "05017b80304e0eb3f31d90194a563fd53a6021f1", "shasum": "" }, "require": { @@ -1430,7 +1462,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.13" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.15" }, "funding": [ { @@ -1446,7 +1478,7 @@ "type": "tidelift" } ], - "time": "2023-09-19T05:39:22+00:00" + "time": "2023-12-01T16:55:19+00:00" }, { "name": "sebastian/cli-parser", @@ -2479,16 +2511,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.7.2", + "version": "3.8.0", "source": { "type": "git", - "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879" + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "5805f7a4e4958dbb5e944ef1e6edae0a303765e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879", - "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5805f7a4e4958dbb5e944ef1e6edae0a303765e7", + "reference": "5805f7a4e4958dbb5e944ef1e6edae0a303765e7", "shasum": "" }, "require": { @@ -2498,7 +2530,7 @@ "php": ">=5.4.0" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0" }, "bin": [ "bin/phpcs", @@ -2517,22 +2549,45 @@ "authors": [ { "name": "Greg Sherwood", - "role": "lead" + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ "phpcs", "standards", "static analysis" ], "support": { - "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", - "source": "https://github.com/squizlabs/PHP_CodeSniffer", - "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" }, - "time": "2023-02-22T23:07:41+00:00" + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + } + ], + "time": "2023-12-08T12:32:31+00:00" }, { "name": "symfony/polyfill-php73", @@ -2677,16 +2732,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.1", + "version": "1.2.2", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e" + "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b2ad5003ca10d4ee50a12da31de12a5774ba6b96", + "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96", "shasum": "" }, "require": { @@ -2715,7 +2770,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.1" + "source": "https://github.com/theseer/tokenizer/tree/1.2.2" }, "funding": [ { @@ -2723,7 +2778,7 @@ "type": "github" } ], - "time": "2021-07-28T10:34:58+00:00" + "time": "2023-11-20T00:12:19+00:00" }, { "name": "wp-coding-standards/wpcs", @@ -2793,7 +2848,7 @@ }, { "name": "wp-phpunit/wp-phpunit", - "version": "5.9.7", + "version": "5.9.8", "source": { "type": "git", "url": "https://github.com/wp-phpunit/wp-phpunit.git", @@ -2907,6 +2962,7 @@ "prefer-lowest": false, "platform": { "php": ">=7|^8", + "ext-dom": "*", "ext-json": "*" }, "platform-dev": [], From 37096888ec8e5cf54cf6cff09abf42b7c366499c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 21 Dec 2023 10:46:50 -0800 Subject: [PATCH 178/371] Fix Generic.CodeAnalysis.UnusedFunctionParameter --- .../audit-enqueued-assets/audit-enqueued-assets-helper-test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-helper-test.php b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-helper-test.php index d81f38d671..7d9f45635e 100644 --- a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-helper-test.php +++ b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-helper-test.php @@ -101,7 +101,7 @@ public function test_perflab_aea_get_path_from_resource_url_outside_wp_setup() { $expected_path = WP_CONTENT_DIR . '/themes/test-theme/style.css'; add_filter( 'content_url', - static function ( $url ) { + static function () { return site_url() . '/content'; } ); From 1b3b859698613968442763ceb156b875c9c02bce Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 21 Dec 2023 10:51:03 -0800 Subject: [PATCH 179/371] Fix filter return type for PHPStan static analysis --- tests/admin/load-tests.php | 2 +- tests/modules/images/webp-uploads/helper-tests.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/admin/load-tests.php b/tests/admin/load-tests.php index 9e39ad44e3..5f139c939d 100644 --- a/tests/admin/load-tests.php +++ b/tests/admin/load-tests.php @@ -84,7 +84,7 @@ public function test_perflab_add_modules_page() { remove_all_filters( 'plugin_action_links_' . plugin_basename( PERFLAB_MAIN_FILE ) ); // Does not register the page if the perflab_active_modules filter is used. - add_filter( 'perflab_active_modules', '__return_null' ); + add_filter( 'perflab_active_modules', '__return_empty_array' ); $hook_suffix = perflab_add_modules_page(); $this->assertFalse( $hook_suffix ); $this->assertFalse( isset( $_wp_submenu_nopriv['options-general.php'][ PERFLAB_MODULES_SCREEN ] ) ); diff --git a/tests/modules/images/webp-uploads/helper-tests.php b/tests/modules/images/webp-uploads/helper-tests.php index 9fa6fbbd67..5ff31c75aa 100644 --- a/tests/modules/images/webp-uploads/helper-tests.php +++ b/tests/modules/images/webp-uploads/helper-tests.php @@ -364,6 +364,7 @@ public function it_should_return_empty_array_when_filter_returns_empty_array() { * @test */ public function it_should_return_default_transforms_when_filter_returns_non_array_type() { + /** @phpstan-ignore-next-line */ add_filter( 'webp_uploads_upload_image_mime_transforms', '__return_null' ); $default_transforms = array( From fd43aaa8507bf695a68d846c94bef23908650c81 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 21 Dec 2023 11:03:21 -0800 Subject: [PATCH 180/371] Fix sending header() in test_perflab_aea_clean_aea_audit_action --- .../audit-enqueued-assets-test.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php index c4a640b923..cd71fd1ed0 100644 --- a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php +++ b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php @@ -241,7 +241,23 @@ public function test_perflab_aea_clean_aea_audit_action() { $_REQUEST['_wpnonce'] = wp_create_nonce( 'clean_aea_audit' ); $_GET['action'] = 'clean_aea_audit'; $this->current_user_can_view_site_health_checks_cap(); + $redirected_url = null; + add_filter( + 'wp_redirect', + static function ( $url ) use ( &$redirected_url ) { + $redirected_url = $url; + return false; + } + ); + $_REQUEST['_wp_http_referer'] = add_query_arg( + array( + '_wpnonce' => 'foo', + 'action' => 'bar', + ), + home_url( '/' ) + ); perflab_aea_clean_aea_audit_action(); + $this->assertSame( home_url( '/' ), $redirected_url ); $this->assertFalse( get_transient( 'aea_enqueued_front_page_scripts' ) ); $this->assertFalse( get_transient( 'aea_enqueued_front_page_styles' ) ); } From df0d09ea9872f28cf135e10277f4db14cba93501 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jan 2024 09:15:18 -0800 Subject: [PATCH 181/371] Add since tags to ILO_HTML_Tag_Processor methods --- .../class-ilo-html-tag-processor.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index de6baafdf7..308c3f0c6e 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -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 Tag name of current open tag. */ public function open_tags(): Generator { @@ -229,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 Breadcrumb. */ private function get_breadcrumbs(): Generator { @@ -257,6 +261,8 @@ private function is_foreign_element(): bool { * 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 { @@ -273,6 +279,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. @@ -288,6 +295,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. @@ -304,6 +312,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. @@ -319,6 +328,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. From 1fa877c79394f17804623ddf843c4ba9fe358b69 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jan 2024 09:27:59 -0800 Subject: [PATCH 182/371] Use test method prefix consistenty instead of redundant tag --- .../class-ilo-html-tag-processor-tests.php | 2 -- .../image-loading-optimization/detection-tests.php | 1 - .../image-loading-optimization/hooks-tests.php | 7 ++----- .../optimization-tests.php | 3 --- .../storage/data-tests.php | 12 ------------ .../storage/lock-tests.php | 3 --- .../storage/post-type-tests.php | 5 ----- .../storage/rest-api-tests.php | 6 ------ 8 files changed, 2 insertions(+), 37 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php index 62249b7849..6cd0c7bbbe 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php @@ -281,7 +281,6 @@ public function data_provider_sample_documents(): array { /** * Test open_tags() and get_xpath(). * - * @test * @covers ::open_tags * @covers ::get_xpath * @@ -304,7 +303,6 @@ public function test_open_tags_and_get_xpath( string $document, array $open_tags /** * Test get_attribute(), set_attribute(), remove_attribute(), and get_updated_html(). * - * @test * @covers ::get_attribute * @covers ::set_attribute * @covers ::remove_attribute diff --git a/tests/modules/images/image-loading-optimization/detection-tests.php b/tests/modules/images/image-loading-optimization/detection-tests.php index 278e76fc26..410383c73f 100644 --- a/tests/modules/images/image-loading-optimization/detection-tests.php +++ b/tests/modules/images/image-loading-optimization/detection-tests.php @@ -48,7 +48,6 @@ static function (): int { /** * Make sure the expected script is printed. * - * @test * @dataProvider data_provider_ilo_get_detection_script * @covers ::ilo_get_detection_script * diff --git a/tests/modules/images/image-loading-optimization/hooks-tests.php b/tests/modules/images/image-loading-optimization/hooks-tests.php index 7842757b91..8ca9cddddf 100644 --- a/tests/modules/images/image-loading-optimization/hooks-tests.php +++ b/tests/modules/images/image-loading-optimization/hooks-tests.php @@ -10,20 +10,17 @@ class ILO_Hooks_Tests extends WP_UnitTestCase { /** * Make sure the hook is added. - * - * @test */ - public function it_is_hooking_output_buffering_at_template_include() { + public function test_hooking_output_buffering_at_template_include() { $this->assertEquals( PHP_INT_MAX, has_filter( 'template_include', 'ilo_buffer_output' ) ); } /** * Make output is buffered and that it is also filtered. * - * @test * @covers ::ilo_buffer_output */ - public function it_buffers_and_filters_output() { + public function test_buffering_and_filtering_output() { $original = 'Hello World!'; $expected = '¡Hola Mundo!'; diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index a193f59fc2..1e63fc76df 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -11,7 +11,6 @@ class ILO_Optimization_Tests extends WP_UnitTestCase { /** * Test ilo_maybe_add_template_output_buffer_filter(). * - * @test * @covers ::ilo_maybe_add_template_output_buffer_filter */ public function test_ilo_maybe_add_template_output_buffer_filter() { @@ -124,7 +123,6 @@ public function data_provider_test_ilo_construct_preload_links(): array { /** * Test ilo_construct_preload_links(). * - * @test * @covers ::ilo_construct_preload_links * @dataProvider data_provider_test_ilo_construct_preload_links */ @@ -383,7 +381,6 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array /** * Test ilo_optimize_template_output_buffer(). * - * @test * @covers ::ilo_optimize_template_output_buffer * @dataProvider data_provider_test_ilo_optimize_template_output_buffer */ diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 91e93163d8..b9f18893be 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -16,7 +16,6 @@ public function tear_down() { /** * Test ilo_get_url_metric_freshness_ttl(). * - * @test * @covers ::ilo_get_url_metric_freshness_ttl */ public function test_ilo_get_url_metric_freshness_ttl() { @@ -83,7 +82,6 @@ public function data_provider_test_ilo_can_optimize_response(): array { /** * Test ilo_can_optimize_response(). * - * @test * @covers ::ilo_can_optimize_response * @dataProvider data_provider_test_ilo_can_optimize_response */ @@ -147,7 +145,6 @@ public function data_provider_test_ilo_get_normalized_query_vars(): array { /** * Test ilo_get_normalized_query_vars(). * - * @test * @covers ::ilo_get_normalized_query_vars * @dataProvider data_provider_test_ilo_get_normalized_query_vars */ @@ -159,7 +156,6 @@ public function test_ilo_get_normalized_query_vars( Closure $set_up ) { /** * Test ilo_get_url_metrics_slug(). * - * @test * @covers ::ilo_get_url_metrics_slug */ public function test_ilo_get_url_metrics_slug() { @@ -174,7 +170,6 @@ public function test_ilo_get_url_metrics_slug() { /** * Test ilo_get_url_metrics_storage_nonce(). * - * @test * @covers ::ilo_get_url_metrics_storage_nonce * @covers ::ilo_verify_url_metrics_storage_nonce */ @@ -235,7 +230,6 @@ public function data_provider_sample_size_and_breakpoints(): array { /** * Test ilo_unshift_url_metrics(). * - * @test * @covers ::ilo_unshift_url_metrics * @dataProvider data_provider_sample_size_and_breakpoints */ @@ -298,7 +292,6 @@ function ( $url_metric ) use ( $old_timestamp ) { /** * Test ilo_get_breakpoint_max_widths(). * - * @test * @covers ::ilo_get_breakpoint_max_widths */ public function test_ilo_get_breakpoint_max_widths() { @@ -324,7 +317,6 @@ static function () use ( $filtered_breakpoints ) { /** * Test ilo_get_url_metrics_breakpoint_sample_size(). * - * @test * @covers ::ilo_get_url_metrics_breakpoint_sample_size */ public function test_ilo_get_url_metrics_breakpoint_sample_size() { @@ -356,7 +348,6 @@ public function data_provider_test_ilo_group_url_metrics_by_breakpoint(): array /** * Test ilo_group_url_metrics_by_breakpoint(). * - * @test * @covers ::ilo_group_url_metrics_by_breakpoint * @dataProvider data_provider_test_ilo_group_url_metrics_by_breakpoint */ @@ -475,7 +466,6 @@ public function data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widt /** * Test ilo_get_lcp_elements_by_minimum_viewport_widths(). * - * @test * @covers ::ilo_get_lcp_elements_by_minimum_viewport_widths * @dataProvider data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widths */ @@ -570,7 +560,6 @@ public function data_provider_test_ilo_get_needed_minimum_viewport_widths(): arr /** * Test ilo_get_needed_minimum_viewport_widths(). * - * @test * @covers ::ilo_get_needed_minimum_viewport_widths * @dataProvider data_provider_test_ilo_get_needed_minimum_viewport_widths */ @@ -630,7 +619,6 @@ public function data_provider_test_ilo_needs_url_metric_for_breakpoint(): array /** * Test ilo_needs_url_metric_for_breakpoint(). * - * @test * @covers ::ilo_needs_url_metric_for_breakpoint * @dataProvider data_provider_test_ilo_needs_url_metric_for_breakpoint */ diff --git a/tests/modules/images/image-loading-optimization/storage/lock-tests.php b/tests/modules/images/image-loading-optimization/storage/lock-tests.php index d5c12c5500..cb31e2c41a 100644 --- a/tests/modules/images/image-loading-optimization/storage/lock-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/lock-tests.php @@ -55,7 +55,6 @@ static function (): int { /** * Test ilo_get_url_metric_storage_lock_ttl(). * - * @test * @covers ::ilo_get_url_metric_storage_lock_ttl * @dataProvider data_provider_ilo_get_url_metric_storage_lock_ttl * @@ -70,7 +69,6 @@ public function test_ilo_get_url_metric_storage_lock_ttl( Closure $set_up, int $ /** * Test ilo_get_url_metric_storage_lock_transient_key(). * - * @test * @covers ::ilo_get_url_metric_storage_lock_transient_key */ public function test_ilo_get_url_metric_storage_lock_transient_key() { @@ -90,7 +88,6 @@ public function test_ilo_get_url_metric_storage_lock_transient_key() { /** * Test ilo_set_url_metric_storage_lock() and ilo_is_url_metric_storage_locked(). * - * @test * @covers ::ilo_set_url_metric_storage_lock * @covers ::ilo_is_url_metric_storage_locked */ diff --git a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php index 03b8cbb670..068d240416 100644 --- a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php @@ -11,7 +11,6 @@ class ILO_Storage_Post_Type_Tests extends WP_UnitTestCase { /** * Test ilo_register_url_metrics_post_type(). * - * @test * @covers ::ilo_register_url_metrics_post_type */ public function test_ilo_register_url_metrics_post_type() { @@ -24,7 +23,6 @@ public function test_ilo_register_url_metrics_post_type() { /** * Test ilo_get_url_metrics_post() when there is no post. * - * @test * @covers ::ilo_get_url_metrics_post */ public function test_ilo_get_url_metrics_post_when_absent() { @@ -35,7 +33,6 @@ public function test_ilo_get_url_metrics_post_when_absent() { /** * Test ilo_get_url_metrics_post() when there is a post. * - * @test * @covers ::ilo_get_url_metrics_post */ public function test_ilo_get_url_metrics_post_when_present() { @@ -92,7 +89,6 @@ public function data_provider_test_ilo_parse_stored_url_metrics(): array { /** * Test ilo_parse_stored_url_metrics(). * - * @test * @covers ::ilo_parse_stored_url_metrics * @dataProvider data_provider_test_ilo_parse_stored_url_metrics */ @@ -111,7 +107,6 @@ public function test_ilo_parse_stored_url_metrics( string $post_content, array $ /** * Test ilo_store_url_metric(). * - * @test * @covers ::ilo_store_url_metric */ public function test_ilo_store_url_metric() { diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index d69a404748..8e4ddc0c4a 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -16,7 +16,6 @@ class ILO_Storage_REST_API_Tests extends WP_UnitTestCase { /** * Test ilo_register_endpoint(). * - * @test * @covers ::ilo_register_endpoint */ public function test_ilo_register_endpoint_hooked() { @@ -26,7 +25,6 @@ public function test_ilo_register_endpoint_hooked() { /** * Test good params. * - * @test * @covers ::ilo_register_endpoint * @covers ::ilo_handle_rest_request */ @@ -132,7 +130,6 @@ function ( $params ) { /** * Test bad params. * - * @test * @covers ::ilo_register_endpoint * @covers ::ilo_handle_rest_request * @dataProvider data_provider_invalid_params @@ -148,7 +145,6 @@ public function test_rest_request_bad_params( array $params ) { /** * Test REST API request when metric storage is locked. * - * @test * @covers ::ilo_register_endpoint * @covers ::ilo_handle_rest_request */ @@ -166,7 +162,6 @@ public function test_rest_request_locked() { /** * Test sending viewport data that isn't needed for a specific breakpoint. * - * @test * @covers ::ilo_register_endpoint * @covers ::ilo_handle_rest_request */ @@ -197,7 +192,6 @@ public function test_rest_request_breakpoint_not_needed_for_any_breakpoint() { /** * Test sending viewport data that isn't needed for any breakpoint. * - * @test * @covers ::ilo_register_endpoint * @covers ::ilo_handle_rest_request */ From 3da1363c6248d2dfdb212dc1d3dad6e90716305b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jan 2024 13:57:32 -0800 Subject: [PATCH 183/371] Remove unnecessary ilo_needs_url_metric_for_breakpoint() --- .../optimization.php | 7 ++- .../storage/data.php | 18 ------ .../storage/data-tests.php | 56 ------------------- 3 files changed, 6 insertions(+), 75 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index b226bd2717..3e5bdba5d0 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -108,7 +108,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 ); diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 891f2b4b88..410c60af2b 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -419,21 +419,3 @@ function ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $curr return $needed_minimum_viewport_widths; } - -/** - * Checks whether there is a URL metric needed for one of the breakpoints. - * - * @since n.e.x.t - * @access private - * - * @param array $needed_minimum_viewport_widths Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. - * @return bool Whether a URL metric is needed. - */ -function ilo_needs_url_metric_for_breakpoint( array $needed_minimum_viewport_widths ): bool { - foreach ( $needed_minimum_viewport_widths as list( $minimum_viewport_width, $is_needed ) ) { - if ( $is_needed ) { - return true; - } - } - return false; -} diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index b9f18893be..4db3e3a0cf 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -570,62 +570,6 @@ public function test_ilo_get_needed_minimum_viewport_widths( array $url_metrics, ); } - /** - * Data provider. - * - * @return array[] - */ - public function data_provider_test_ilo_needs_url_metric_for_breakpoint(): array { - return array( - 'one-needed' => array( - array( - array( 480, false ), - ), - false, - ), - 'one-unneeded' => array( - array( - array( 480, true ), - ), - true, - ), - 'one-of-3-needed' => array( - array( - array( 480, false ), - array( 600, true ), - array( 782, false ), - ), - true, - ), - 'none-of-3-needed' => array( - array( - array( 480, false ), - array( 600, false ), - array( 782, false ), - ), - false, - ), - 'all-of-3-needed' => array( - array( - array( 480, true ), - array( 600, true ), - array( 782, true ), - ), - true, - ), - ); - } - - /** - * Test ilo_needs_url_metric_for_breakpoint(). - * - * @covers ::ilo_needs_url_metric_for_breakpoint - * @dataProvider data_provider_test_ilo_needs_url_metric_for_breakpoint - */ - public function test_ilo_needs_url_metric_for_breakpoint( array $needed_minimum_viewport_widths, bool $expected_needed ) { - $this->assertSame( $expected_needed, ilo_needs_url_metric_for_breakpoint( $needed_minimum_viewport_widths ) ); - } - /** * Gets a validated URL metric for testing. * From 2181d884305abce6a95182fc8b037beed747bd9b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jan 2024 14:32:21 -0800 Subject: [PATCH 184/371] Revert test changes moved to #924 --- phpcs.xml.dist | 36 ++------------ tests/admin/load-tests.php | 2 +- tests/admin/server-timing-tests.php | 2 +- tests/bootstrap.php | 2 +- tests/load-tests.php | 14 +++--- .../audit-autoloaded-options-test.php | 1 + .../dominant-color-test.php | 4 +- .../images/webp-uploads/helper-tests.php | 3 +- .../images/webp-uploads/load-tests.php | 6 +-- .../images/webp-uploads/rest-api-tests.php | 2 +- .../audit-enqueued-assets-helper-test.php | 4 +- .../audit-enqueued-assets-test.php | 25 ++-------- tests/server-timing/load-tests.php | 48 +++++-------------- .../perflab-server-timing-tests.php | 22 ++++----- .../something/demo-module-2/activate.php | 2 +- .../something/demo-module-2/deactivate.php | 2 +- .../class-audit-assets-transients-set.php | 1 + .../class-site-health-mock-responses.php | 1 + tests/utils/Constraint/ImageHasSizeSource.php | 1 + tests/utils/Constraint/ImageHasSource.php | 1 + tests/utils/TestCase/ImagesTestCase.php | 2 +- 21 files changed, 57 insertions(+), 124 deletions(-) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index c6fa03962a..9b34e57217 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -6,7 +6,9 @@ - + + tests/* + @@ -71,37 +73,5 @@ tests/utils/* - - - tests/* - - - tests/* - - - tests/* - - - tests/* - - - tests/* - - - tests/* - - - - - - - - - - - - - - diff --git a/tests/admin/load-tests.php b/tests/admin/load-tests.php index 5f139c939d..9e39ad44e3 100644 --- a/tests/admin/load-tests.php +++ b/tests/admin/load-tests.php @@ -84,7 +84,7 @@ public function test_perflab_add_modules_page() { remove_all_filters( 'plugin_action_links_' . plugin_basename( PERFLAB_MAIN_FILE ) ); // Does not register the page if the perflab_active_modules filter is used. - add_filter( 'perflab_active_modules', '__return_empty_array' ); + add_filter( 'perflab_active_modules', '__return_null' ); $hook_suffix = perflab_add_modules_page(); $this->assertFalse( $hook_suffix ); $this->assertFalse( isset( $_wp_submenu_nopriv['options-general.php'][ PERFLAB_MODULES_SCREEN ] ) ); diff --git a/tests/admin/server-timing-tests.php b/tests/admin/server-timing-tests.php index 38cb55a89c..915da3cdc7 100644 --- a/tests/admin/server-timing-tests.php +++ b/tests/admin/server-timing-tests.php @@ -49,7 +49,7 @@ public function test_perflab_load_server_timing_page() { perflab_load_server_timing_page(); $this->assertArrayHasKey( PERFLAB_SERVER_TIMING_SCREEN, $wp_settings_sections ); $expected_sections = array( 'benchmarking' ); - if ( ! has_filter( 'template_include', 'ilo_buffer_output' ) ) { + if ( ! has_filter( 'template_include', 'image_loading_optimization_buffer_output' ) ) { $expected_sections[] = 'output-buffering'; } $this->assertEqualSets( diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3018bda910..9a7fc87a18 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -41,7 +41,7 @@ require_once $_test_root . '/includes/functions.php'; tests_add_filter( 'plugins_loaded', - static function () { + static function() { require_once TESTS_PLUGIN_DIR . '/admin/load.php'; require_once TESTS_PLUGIN_DIR . '/admin/server-timing.php'; $module_files = glob( TESTS_PLUGIN_DIR . '/modules/*/*/load.php' ); diff --git a/tests/load-tests.php b/tests/load-tests.php index 6c7dd2fc2d..5f2ceedc5c 100644 --- a/tests/load-tests.php +++ b/tests/load-tests.php @@ -78,10 +78,10 @@ public function test_perflab_get_module_settings() { $has_passed_default = false; add_filter( 'default_option_' . PERFLAB_MODULES_SETTING, - static function ( $current_default, $option, $passed_default ) use ( &$has_passed_default ) { + static function( $default, $option, $passed_default ) use ( &$has_passed_default ) { // This callback just records whether there is a default value being passed. $has_passed_default = $passed_default; - return $current_default; + return $default; }, 10, 3 @@ -134,7 +134,7 @@ public function test_perflab_get_active_modules() { $expected_active_modules = array_keys( array_filter( perflab_get_modules_setting_default(), - static function ( $module_settings ) { + static function( $module_settings ) { return $module_settings['enabled']; } ) @@ -158,7 +158,7 @@ public function test_perflab_get_generator_content() { array_pop( $active_modules ); add_filter( 'perflab_active_modules', - static function () use ( $active_modules ) { + static function() use ( $active_modules ) { return $active_modules; } ); @@ -229,7 +229,7 @@ private function get_expected_default_option() { $default_enabled_modules = require PERFLAB_PLUGIN_DIR_PATH . 'default-enabled-modules.php'; return array_reduce( $default_enabled_modules, - static function ( $module_settings, $module_dir ) { + static function( $module_settings, $module_dir ) { $module_settings[ $module_dir ] = array( 'enabled' => true ); return $module_settings; }, @@ -312,13 +312,13 @@ private function set_up_mock_filesystem() { add_filter( 'filesystem_method_file', - static function () { + static function() { return __DIR__ . '/utils/Filesystem/WP_Filesystem_MockFilesystem.php'; } ); add_filter( 'filesystem_method', - static function () { + static function() { return 'MockFilesystem'; } ); diff --git a/tests/modules/database/audit-autoloaded-options/audit-autoloaded-options-test.php b/tests/modules/database/audit-autoloaded-options/audit-autoloaded-options-test.php index 6a2586731e..3a4a238d00 100644 --- a/tests/modules/database/audit-autoloaded-options/audit-autoloaded-options-test.php +++ b/tests/modules/database/audit-autoloaded-options/audit-autoloaded-options-test.php @@ -86,3 +86,4 @@ public static function delete_autoloaded_option() { delete_option( self::AUTOLOADED_OPTION_KEY ); } } + diff --git a/tests/modules/images/dominant-color-images/dominant-color-test.php b/tests/modules/images/dominant-color-images/dominant-color-test.php index 02ebf80dc1..1bf6c77cb4 100644 --- a/tests/modules/images/dominant-color-images/dominant-color-test.php +++ b/tests/modules/images/dominant-color-images/dominant-color-test.php @@ -92,7 +92,7 @@ public function test_tag_add_adjust_to_image_attributes( $image_path, $expected_ $filtered_image_tags_added = dominant_color_img_tag_add_dominant_color( $filtered_image_mock_lazy_load, 'the_content', $attachment_id ); - $this->assertStringContainsString( 'data-has-transparency="' . wp_json_encode( $expected_transparency ) . '"', $filtered_image_tags_added ); + $this->assertStringContainsString( 'data-has-transparency="' . json_encode( $expected_transparency ) . '"', $filtered_image_tags_added ); foreach ( $expected_color as $color ) { if ( false !== strpos( $color, $filtered_image_tags_added ) ) { @@ -210,7 +210,7 @@ public function data_provider_dominant_color_check_inline_style() { public function test_dominant_color_update_attachment_image_attributes( $style_attr, $expected ) { $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/dominant-color-images/red.jpg' ); - $attachment_image = wp_get_attachment_image( $attachment_id, 'full', '', array( 'style' => $style_attr ) ); + $attachment_image = wp_get_attachment_image( $attachment_id, 'full', '', array( "style" => $style_attr ) ); $this->assertStringContainsString( $expected, $attachment_image ); } diff --git a/tests/modules/images/webp-uploads/helper-tests.php b/tests/modules/images/webp-uploads/helper-tests.php index 5ff31c75aa..a4ebe0dd58 100644 --- a/tests/modules/images/webp-uploads/helper-tests.php +++ b/tests/modules/images/webp-uploads/helper-tests.php @@ -364,7 +364,6 @@ public function it_should_return_empty_array_when_filter_returns_empty_array() { * @test */ public function it_should_return_default_transforms_when_filter_returns_non_array_type() { - /** @phpstan-ignore-next-line */ add_filter( 'webp_uploads_upload_image_mime_transforms', '__return_null' ); $default_transforms = array( @@ -521,7 +520,7 @@ public function test_webp_uploads_in_frontend_body_within_wp_head() { $result = null; add_action( 'wp_head', - static function () use ( &$result ) { + static function() use ( &$result ) { $result = webp_uploads_in_frontend_body(); } ); diff --git a/tests/modules/images/webp-uploads/load-tests.php b/tests/modules/images/webp-uploads/load-tests.php index cf8ce7e566..7251b4aced 100644 --- a/tests/modules/images/webp-uploads/load-tests.php +++ b/tests/modules/images/webp-uploads/load-tests.php @@ -544,7 +544,7 @@ public function it_should_not_replace_the_references_to_a_jpg_image_when_disable add_filter( 'webp_uploads_content_image_mimes', - static function ( $mime_types ) { + static function( $mime_types ) { unset( $mime_types[ array_search( 'image/webp', $mime_types, true ) ] ); return $mime_types; } @@ -733,7 +733,7 @@ public function it_should_prevent_replacing_an_image_uploaded_via_external_sourc add_filter( 'webp_uploads_pre_replace_additional_image_source', - static function () { + static function() { return ''; } ); @@ -899,7 +899,7 @@ public function it_should_not_add_fallback_script_if_content_has_no_updated_imag public function it_should_create_mime_types_for_allowed_sizes_only_via_filter() { add_filter( 'webp_uploads_image_sizes_with_additional_mime_type_support', - static function ( $sizes ) { + static function( $sizes ) { $sizes['allowed_size_400x300'] = true; return $sizes; } diff --git a/tests/modules/images/webp-uploads/rest-api-tests.php b/tests/modules/images/webp-uploads/rest-api-tests.php index 2dc5145d7c..b1fe715cc6 100644 --- a/tests/modules/images/webp-uploads/rest-api-tests.php +++ b/tests/modules/images/webp-uploads/rest-api-tests.php @@ -24,7 +24,7 @@ public function it_should_add_sources_to_rest_response() { add_filter( 'webp_uploads_upload_image_mime_transforms', - static function ( $transforms ) { + static function( $transforms ) { $transforms['image/jpeg'] = array( 'image/jpeg', 'image/webp' ); return $transforms; } diff --git a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-helper-test.php b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-helper-test.php index 7d9f45635e..fcf69cae69 100644 --- a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-helper-test.php +++ b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-helper-test.php @@ -101,10 +101,12 @@ public function test_perflab_aea_get_path_from_resource_url_outside_wp_setup() { $expected_path = WP_CONTENT_DIR . '/themes/test-theme/style.css'; add_filter( 'content_url', - static function () { + static function( $url ) { return site_url() . '/content'; } ); $this->assertSame( $expected_path, perflab_aea_get_path_from_resource_url( $test_url ) ); } + } + diff --git a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php index cd71fd1ed0..ffe39e7fd0 100644 --- a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php +++ b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php @@ -78,6 +78,7 @@ public function test_perflab_aea_audit_enqueued_scripts() { ), $transient ); + } /** @@ -92,10 +93,8 @@ public function test_perflab_aea_audit_enqueued_styles_transient_already_set() { Audit_Assets_Transients_Set::set_style_transient_with_data( 3 ); - // Avoid deprecation warning due to related change in WordPress 6.4. - remove_action( 'wp_print_styles', 'print_emoji_styles' ); + // Avoids echoing styles. get_echo( 'wp_print_styles' ); - perflab_aea_audit_enqueued_styles(); $transient = get_transient( 'aea_enqueued_front_page_styles' ); $this->assertIsArray( $transient ); @@ -140,9 +139,6 @@ public function test_perflab_aea_audit_enqueued_styles() { $style .= "\tbackground: red;\n"; $style .= '}'; wp_add_inline_style( 'style1', $style ); - - // Avoid deprecation warning due to related change in WordPress 6.4. - remove_action( 'wp_print_styles', 'print_emoji_styles' ); get_echo( 'wp_print_styles' ); perflab_aea_audit_enqueued_styles(); @@ -241,23 +237,7 @@ public function test_perflab_aea_clean_aea_audit_action() { $_REQUEST['_wpnonce'] = wp_create_nonce( 'clean_aea_audit' ); $_GET['action'] = 'clean_aea_audit'; $this->current_user_can_view_site_health_checks_cap(); - $redirected_url = null; - add_filter( - 'wp_redirect', - static function ( $url ) use ( &$redirected_url ) { - $redirected_url = $url; - return false; - } - ); - $_REQUEST['_wp_http_referer'] = add_query_arg( - array( - '_wpnonce' => 'foo', - 'action' => 'bar', - ), - home_url( '/' ) - ); perflab_aea_clean_aea_audit_action(); - $this->assertSame( home_url( '/' ), $redirected_url ); $this->assertFalse( get_transient( 'aea_enqueued_front_page_scripts' ) ); $this->assertFalse( get_transient( 'aea_enqueued_front_page_styles' ) ); } @@ -302,3 +282,4 @@ public function mock_data_perflab_aea_enqueued_css_assets_test_callback( $number return Site_Health_Mock_Responses::return_aea_enqueued_css_assets_test_callback_more_than_threshold( $number_of_assets ); } } + diff --git a/tests/server-timing/load-tests.php b/tests/server-timing/load-tests.php index dcffb6ccdc..7d6fd3e2c7 100644 --- a/tests/server-timing/load-tests.php +++ b/tests/server-timing/load-tests.php @@ -25,7 +25,7 @@ public function test_perflab_server_timing_register_metric() { perflab_server_timing_register_metric( 'test-metric', array( - 'measure_callback' => static function ( $metric ) { + 'measure_callback' => static function( $metric ) { $metric->set_value( 100 ); }, 'access_cap' => 'exist', @@ -42,7 +42,7 @@ public function test_perflab_server_timing_use_output_buffer() { } public function test_perflab_wrap_server_timing() { - $cb = static function () { + $cb = static function() { return 123; }; @@ -64,7 +64,7 @@ public function test_perflab_register_server_timing_setting() { // Reset relevant globals. $wp_registered_settings = array(); - $new_allowed_options = array(); + $new_allowed_options = array(); perflab_register_server_timing_setting(); @@ -103,61 +103,37 @@ public function data_perflab_sanitize_server_timing_setting() { ), 'empty list, array' => array( array( 'benchmarking_actions' => array() ), - array( - 'benchmarking_actions' => array(), - 'output_buffering' => false, - ), + array( 'benchmarking_actions' => array(), 'output_buffering' => false ), ), 'empty list, string' => array( array( 'benchmarking_actions' => '' ), - array( - 'benchmarking_actions' => array(), - 'output_buffering' => false, - ), + array( 'benchmarking_actions' => array(), 'output_buffering' => false ), ), 'empty list, string with whitespace' => array( array( 'benchmarking_actions' => ' ' ), - array( - 'benchmarking_actions' => array(), - 'output_buffering' => false, - ), + array( 'benchmarking_actions' => array(), 'output_buffering' => false ), ), 'regular list, array' => array( array( 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ) ), - array( - 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), - 'output_buffering' => false, - ), + array( 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), 'output_buffering' => false ), ), 'regular list, string' => array( array( 'benchmarking_actions' => "after_setup_theme\ninit\nwp_loaded" ), - array( - 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), - 'output_buffering' => false, - ), + array( 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), 'output_buffering' => false ), ), 'regular list, string with whitespace' => array( array( 'benchmarking_actions' => "after_setup_ theme \ninit \n\nwp_loaded\n" ), - array( - 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), - 'output_buffering' => false, - ), + array( 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), 'output_buffering' => false ), ), 'regular list, array with duplicates' => array( array( 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded', 'init' ) ), - array( - 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), - 'output_buffering' => false, - ), + array( 'benchmarking_actions' => array( 'after_setup_theme', 'init', 'wp_loaded' ), 'output_buffering' => false ), ), 'regular list, array with special hook chars' => array( array( 'benchmarking_actions' => array( 'namespace/hookname', 'namespace.hookname' ) ), - array( - 'benchmarking_actions' => array( 'namespace/hookname', 'namespace.hookname' ), - 'output_buffering' => false, - ), + array( 'benchmarking_actions' => array( 'namespace/hookname', 'namespace.hookname' ), 'output_buffering' => false ), ), - 'output buffering enabled' => array( + 'output buffering enabled' => array( array( 'output_buffering' => 'on' ), array( 'output_buffering' => true ), ), diff --git a/tests/server-timing/perflab-server-timing-tests.php b/tests/server-timing/perflab-server-timing-tests.php index 33d7ca28be..360d4547b1 100644 --- a/tests/server-timing/perflab-server-timing-tests.php +++ b/tests/server-timing/perflab-server-timing-tests.php @@ -38,7 +38,7 @@ public function test_register_metric_stores_metrics_and_runs_measure_callback() $this->server_timing->register_metric( 'test-metric', array( - 'measure_callback' => static function () use ( &$called ) { + 'measure_callback' => static function() use ( &$called ) { $called = true; }, 'access_cap' => 'exist', @@ -52,7 +52,7 @@ public function test_register_metric_stores_metrics_and_runs_measure_callback() public function test_register_metric_runs_measure_callback_based_on_access_cap() { $called = false; $args = array( - 'measure_callback' => static function () use ( &$called ) { + 'measure_callback' => static function() use ( &$called ) { $called = true; }, 'access_cap' => 'manage_options', // Admin capability. @@ -130,13 +130,13 @@ public function test_get_header( $expected, $metrics ) { } public function data_get_header() { - $measure_42 = static function ( $metric ) { + $measure_42 = static function( $metric ) { $metric->set_value( 42 ); }; - $measure_300 = static function ( $metric ) { + $measure_300 = static function( $metric ) { $metric->set_value( 300 ); }; - $measure_12point345 = static function ( $metric ) { + $measure_12point345 = static function( $metric ) { $metric->set_value( 12.345 ); }; @@ -188,23 +188,23 @@ public function data_get_header() { } public function get_data_to_test_use_output_buffer() { - $enable_option = static function () { - $option = (array) get_option( PERFLAB_SERVER_TIMING_SETTING ); + $enable_option = static function () { + $option = (array) get_option( PERFLAB_SERVER_TIMING_SETTING ); $option['output_buffering'] = true; update_option( PERFLAB_SERVER_TIMING_SETTING, $option ); }; $disable_option = static function () { - $option = (array) get_option( PERFLAB_SERVER_TIMING_SETTING ); + $option = (array) get_option( PERFLAB_SERVER_TIMING_SETTING ); $option['output_buffering'] = false; update_option( PERFLAB_SERVER_TIMING_SETTING, $option ); }; return array( - 'default' => array( + 'default' => array( 'set_up' => static function () {}, 'expected' => false, ), - 'option-enabled' => array( + 'option-enabled' => array( 'set_up' => $enable_option, 'expected' => true, ), @@ -212,7 +212,7 @@ public function get_data_to_test_use_output_buffer() { 'set_up' => $disable_option, 'expected' => false, ), - 'filter-enabled' => array( + 'filter-enabled' => array( 'set_up' => static function () use ( $disable_option ) { $disable_option(); add_filter( 'perflab_server_timing_use_output_buffer', '__return_true' ); diff --git a/tests/testdata/demo-modules/something/demo-module-2/activate.php b/tests/testdata/demo-modules/something/demo-module-2/activate.php index 8f85b81094..d49a00348b 100644 --- a/tests/testdata/demo-modules/something/demo-module-2/activate.php +++ b/tests/testdata/demo-modules/something/demo-module-2/activate.php @@ -6,6 +6,6 @@ * @package performance-lab */ -return static function () { +return static function() { update_option( 'test_demo_module_activation_status', 'activated' ); }; diff --git a/tests/testdata/demo-modules/something/demo-module-2/deactivate.php b/tests/testdata/demo-modules/something/demo-module-2/deactivate.php index b08dcf3c96..241717d180 100644 --- a/tests/testdata/demo-modules/something/demo-module-2/deactivate.php +++ b/tests/testdata/demo-modules/something/demo-module-2/deactivate.php @@ -6,6 +6,6 @@ * @package performance-lab */ -return static function () { +return static function() { update_option( 'test_demo_module_activation_status', 'deactivated' ); }; diff --git a/tests/testdata/modules/site-health/audit-enqueued-assets/class-audit-assets-transients-set.php b/tests/testdata/modules/site-health/audit-enqueued-assets/class-audit-assets-transients-set.php index 661ae7fe1b..b634dcf992 100644 --- a/tests/testdata/modules/site-health/audit-enqueued-assets/class-audit-assets-transients-set.php +++ b/tests/testdata/modules/site-health/audit-enqueued-assets/class-audit-assets-transients-set.php @@ -62,3 +62,4 @@ public static function set_style_transient_with_no_data() { delete_transient( self::STYLES_TRANSIENT ); } } + diff --git a/tests/testdata/modules/site-health/audit-enqueued-assets/class-site-health-mock-responses.php b/tests/testdata/modules/site-health/audit-enqueued-assets/class-site-health-mock-responses.php index 78e42b6a8b..04854898a5 100644 --- a/tests/testdata/modules/site-health/audit-enqueued-assets/class-site-health-mock-responses.php +++ b/tests/testdata/modules/site-health/audit-enqueued-assets/class-site-health-mock-responses.php @@ -177,3 +177,4 @@ public static function return_aea_enqueued_css_assets_test_callback_more_than_th return $result; } } + diff --git a/tests/utils/Constraint/ImageHasSizeSource.php b/tests/utils/Constraint/ImageHasSizeSource.php index 0688ce6c1e..4c690c7286 100644 --- a/tests/utils/Constraint/ImageHasSizeSource.php +++ b/tests/utils/Constraint/ImageHasSizeSource.php @@ -64,4 +64,5 @@ protected function matches( $attachment_id ): bool { return $this->verify_sources( $metadata['sizes'][ $this->size ]['sources'] ); } + } diff --git a/tests/utils/Constraint/ImageHasSource.php b/tests/utils/Constraint/ImageHasSource.php index 7ac978d108..2c55f01e28 100644 --- a/tests/utils/Constraint/ImageHasSource.php +++ b/tests/utils/Constraint/ImageHasSource.php @@ -136,4 +136,5 @@ protected function verify_sources( $sources ) { protected function failureDescription( $attachment_id ): string { return sprintf( 'an image %s', $this->toString() ); } + } diff --git a/tests/utils/TestCase/ImagesTestCase.php b/tests/utils/TestCase/ImagesTestCase.php index a5a62f0750..b5e4715e32 100644 --- a/tests/utils/TestCase/ImagesTestCase.php +++ b/tests/utils/TestCase/ImagesTestCase.php @@ -116,7 +116,7 @@ public static function assertSizeNameIsHashed( $size_name, $hashed_size_name, $m public function opt_in_to_jpeg_and_webp() { add_filter( 'webp_uploads_upload_image_mime_transforms', - static function ( $transforms ) { + static function( $transforms ) { $transforms['image/jpeg'] = array( 'image/jpeg', 'image/webp' ); $transforms['image/webp'] = array( 'image/webp', 'image/jpeg' ); return $transforms; From 930d4ed90c7344f693e6c0f96c80375bdc3b3260 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jan 2024 14:42:44 -0800 Subject: [PATCH 185/371] Unrevert 2181d88 for server-timing-tests.php --- tests/admin/server-timing-tests.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/admin/server-timing-tests.php b/tests/admin/server-timing-tests.php index 915da3cdc7..38cb55a89c 100644 --- a/tests/admin/server-timing-tests.php +++ b/tests/admin/server-timing-tests.php @@ -49,7 +49,7 @@ public function test_perflab_load_server_timing_page() { perflab_load_server_timing_page(); $this->assertArrayHasKey( PERFLAB_SERVER_TIMING_SCREEN, $wp_settings_sections ); $expected_sections = array( 'benchmarking' ); - if ( ! has_filter( 'template_include', 'image_loading_optimization_buffer_output' ) ) { + if ( ! has_filter( 'template_include', 'ilo_buffer_output' ) ) { $expected_sections[] = 'output-buffering'; } $this->assertEqualSets( From 8dd3283fa229cd6241cd34c4217af957d7cb312a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jan 2024 16:50:54 -0800 Subject: [PATCH 186/371] Unrevert 2181d88 for audit-enqueued-assets-test.php --- .../audit-enqueued-assets/audit-enqueued-assets-test.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php index ffe39e7fd0..975944bb17 100644 --- a/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php +++ b/tests/modules/js-and-css/audit-enqueued-assets/audit-enqueued-assets-test.php @@ -93,8 +93,10 @@ public function test_perflab_aea_audit_enqueued_styles_transient_already_set() { Audit_Assets_Transients_Set::set_style_transient_with_data( 3 ); - // Avoids echoing styles. + // Avoid deprecation warning due to related change in WordPress 6.4. + remove_action( 'wp_print_styles', 'print_emoji_styles' ); get_echo( 'wp_print_styles' ); + perflab_aea_audit_enqueued_styles(); $transient = get_transient( 'aea_enqueued_front_page_styles' ); $this->assertIsArray( $transient ); @@ -139,6 +141,9 @@ public function test_perflab_aea_audit_enqueued_styles() { $style .= "\tbackground: red;\n"; $style .= '}'; wp_add_inline_style( 'style1', $style ); + + // Avoid deprecation warning due to related change in WordPress 6.4. + remove_action( 'wp_print_styles', 'print_emoji_styles' ); get_echo( 'wp_print_styles' ); perflab_aea_audit_enqueued_styles(); From bf1bd1726419b08df127674555efa32f2155671f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 10 Jan 2024 10:04:41 -0800 Subject: [PATCH 187/371] Reset SERVER global after each test --- .../image-loading-optimization/storage/data-tests.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 4db3e3a0cf..2b81df6463 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -8,8 +8,19 @@ class ILO_Storage_Data_Tests extends WP_UnitTestCase { + /** + * @var array + */ + private $original_server_global = array(); + + public function set_up() { + $this->original_server_global = $_SERVER; + parent::set_up(); + } + public function tear_down() { unset( $GLOBALS['wp_customize'] ); + $_SERVER = $this->original_server_global; parent::tear_down(); } From 54396ec77bef00ad26d10629c3b884f236e3958c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 10 Jan 2024 10:30:13 -0800 Subject: [PATCH 188/371] Unset REQUEST_URI in tests that call go_to --- .../optimization-tests.php | 5 +++++ .../storage/data-tests.php | 16 ++++------------ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 1e63fc76df..5cfb14366e 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -8,6 +8,11 @@ class ILO_Optimization_Tests extends WP_UnitTestCase { + public function tear_down() { + parent::tear_down(); + unset( $_SERVER['REQUEST_URI'] ); + } + /** * Test ilo_maybe_add_template_output_buffer_filter(). * diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 2b81df6463..0f797517ee 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -8,19 +8,11 @@ class ILO_Storage_Data_Tests extends WP_UnitTestCase { - /** - * @var array - */ - private $original_server_global = array(); - - public function set_up() { - $this->original_server_global = $_SERVER; - parent::set_up(); - } - public function tear_down() { - unset( $GLOBALS['wp_customize'] ); - $_SERVER = $this->original_server_global; + unset( + $GLOBALS['wp_customize'], + $_SERVER['REQUEST_URI'] + ); parent::tear_down(); } From d865bacbf82a91715b2b5179d0604428c320b94b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 11 Jan 2024 17:21:07 -0800 Subject: [PATCH 189/371] Update ilo_verify_url_metrics_storage_nonce() to return bool --- .../images/image-loading-optimization/storage/data.php | 8 +++----- .../image-loading-optimization/storage/data-tests.php | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 410c60af2b..a6cb6e4573 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -136,12 +136,10 @@ function ilo_get_url_metrics_storage_nonce( string $slug ): string { * * @param string $nonce URL metrics storage nonce. * @param string $slug URL metrics slug. - * @return int 1 if the nonce is valid and generated between 0-12 hours ago, - * 2 if the nonce is valid and generated between 12-24 hours ago. - * 0 if the nonce is invalid. + * @return bool Whether the nonce is valid. */ -function ilo_verify_url_metrics_storage_nonce( string $nonce, string $slug ): int { - return (int) wp_verify_nonce( $nonce, "store_url_metrics:$slug" ); +function ilo_verify_url_metrics_storage_nonce( string $nonce, string $slug ): bool { + return (bool) wp_verify_nonce( $nonce, "store_url_metrics:$slug" ); } /** diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 0f797517ee..b4bc5c7307 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -194,7 +194,7 @@ static function ( int $life, string $action ) use ( &$nonce_life_actions ): int $slug = ilo_get_url_metrics_slug( array() ); $nonce1 = ilo_get_url_metrics_storage_nonce( $slug ); $this->assertMatchesRegularExpression( '/^[0-9a-f]{10}$/', $nonce1 ); - $this->assertSame( 1, ilo_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); + $this->assertTrue( ilo_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); $this->assertCount( 2, $nonce_life_actions ); // Create second nonce for unauthenticated user. @@ -206,8 +206,8 @@ static function ( int $life, string $action ) use ( &$nonce_life_actions ): int wp_set_current_user( $user_id ); $nonce3 = ilo_get_url_metrics_storage_nonce( $slug ); $this->assertNotEquals( $nonce3, $nonce2 ); - $this->assertSame( 0, ilo_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); - $this->assertSame( 1, ilo_verify_url_metrics_storage_nonce( $nonce3, $slug ) ); + $this->assertFalse( ilo_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); + $this->assertTrue( ilo_verify_url_metrics_storage_nonce( $nonce3, $slug ) ); $this->assertCount( 6, $nonce_life_actions ); foreach ( $nonce_life_actions as $nonce_life_action ) { From ed305a9efeba2411f37cd1eaf69f7258d2828fb6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 11 Jan 2024 17:24:28 -0800 Subject: [PATCH 190/371] Move ilo_can_optimize_response() to optimization.php --- .../optimization.php | 32 ++++++++++ .../storage/data.php | 32 ---------- .../optimization-tests.php | 59 +++++++++++++++++++ .../storage/data-tests.php | 59 ------------------- 4 files changed, 91 insertions(+), 91 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 3e5bdba5d0..1408014c02 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -24,6 +24,38 @@ 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. * diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index a6cb6e4573..09840b2053 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -31,38 +31,6 @@ function ilo_get_url_metric_freshness_ttl(): int { return (int) apply_filters( 'ilo_url_metric_freshness_ttl', DAY_IN_SECONDS ); } -/** - * 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 ); -} - /** * Gets the normalized query vars for the current request. * diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 5cfb14366e..dcce9a556d 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -33,6 +33,65 @@ public function test_ilo_maybe_add_template_output_buffer_filter() { $this->assertSame( 10, has_filter( 'ilo_template_output_buffer', 'ilo_optimize_template_output_buffer' ) ); } + /** + * Data provider. + * + * @return array + */ + public function data_provider_test_ilo_can_optimize_response(): array { + return array( + 'homepage' => array( + 'set_up' => function () { + $this->go_to( home_url( '/' ) ); + }, + 'expected' => true, + ), + 'homepage_filtered' => array( + 'set_up' => function () { + $this->go_to( home_url( '/' ) ); + add_filter( 'ilo_can_optimize_response', '__return_false' ); + }, + 'expected' => false, + ), + 'search' => array( + 'set_up' => function () { + self::factory()->post->create( array( 'post_title' => 'Hello' ) ); + $this->go_to( home_url( '?s=Hello' ) ); + }, + 'expected' => false, + ), + 'customizer_preview' => array( + 'set_up' => function () { + $this->go_to( home_url( '/' ) ); + global $wp_customize; + /** @noinspection PhpIncludeInspection */ + require_once ABSPATH . 'wp-includes/class-wp-customize-manager.php'; + $wp_customize = new WP_Customize_Manager(); + $wp_customize->start_previewing_theme(); + }, + 'expected' => false, + ), + 'post_request' => array( + 'set_up' => function () { + $this->go_to( home_url( '/' ) ); + $_SERVER['REQUEST_METHOD'] = 'POST'; + }, + 'expected' => false, + ), + ); + } + + /** + * Test ilo_can_optimize_response(). + * + * @covers ::ilo_can_optimize_response + * @dataProvider data_provider_test_ilo_can_optimize_response + */ + public function test_ilo_can_optimize_response( Closure $set_up, bool $expected ) { + $set_up(); + $this->assertSame( $expected, ilo_can_optimize_response() ); + } + /** * Data provider. * diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index b4bc5c7307..19508751a0 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -34,65 +34,6 @@ static function (): int { $this->assertSame( HOUR_IN_SECONDS, ilo_get_url_metric_freshness_ttl() ); } - /** - * Data provider. - * - * @return array - */ - public function data_provider_test_ilo_can_optimize_response(): array { - return array( - 'homepage' => array( - 'set_up' => function () { - $this->go_to( home_url( '/' ) ); - }, - 'expected' => true, - ), - 'homepage_filtered' => array( - 'set_up' => function () { - $this->go_to( home_url( '/' ) ); - add_filter( 'ilo_can_optimize_response', '__return_false' ); - }, - 'expected' => false, - ), - 'search' => array( - 'set_up' => function () { - self::factory()->post->create( array( 'post_title' => 'Hello' ) ); - $this->go_to( home_url( '?s=Hello' ) ); - }, - 'expected' => false, - ), - 'customizer_preview' => array( - 'set_up' => function () { - $this->go_to( home_url( '/' ) ); - global $wp_customize; - /** @noinspection PhpIncludeInspection */ - require_once ABSPATH . 'wp-includes/class-wp-customize-manager.php'; - $wp_customize = new WP_Customize_Manager(); - $wp_customize->start_previewing_theme(); - }, - 'expected' => false, - ), - 'post_request' => array( - 'set_up' => function () { - $this->go_to( home_url( '/' ) ); - $_SERVER['REQUEST_METHOD'] = 'POST'; - }, - 'expected' => false, - ), - ); - } - - /** - * Test ilo_can_optimize_response(). - * - * @covers ::ilo_can_optimize_response - * @dataProvider data_provider_test_ilo_can_optimize_response - */ - public function test_ilo_can_optimize_response( Closure $set_up, bool $expected ) { - $set_up(); - $this->assertSame( $expected, ilo_can_optimize_response() ); - } - /** * Data provider. * From 06ac5fe4dc5c241054c3791f45878a6a35250ebb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 11 Jan 2024 21:00:47 -0800 Subject: [PATCH 191/371] Add missing since tag Co-authored-by: Mukesh Panchal --- .../image-loading-optimization/class-ilo-html-tag-processor.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 308c3f0c6e..0a69d25c2c 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -244,6 +244,8 @@ 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 { From 4bb2ca845e98ff1a9f363b135ed0cc14f1a1160a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 11 Jan 2024 22:22:01 -0800 Subject: [PATCH 192/371] Add line break before dataProvider tag Co-authored-by: Mukesh Panchal --- .../images/image-loading-optimization/detection-tests.php | 3 ++- .../images/image-loading-optimization/storage/lock-tests.php | 1 + .../image-loading-optimization/storage/post-type-tests.php | 1 + .../image-loading-optimization/storage/rest-api-tests.php | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/modules/images/image-loading-optimization/detection-tests.php b/tests/modules/images/image-loading-optimization/detection-tests.php index 410383c73f..712f31aec0 100644 --- a/tests/modules/images/image-loading-optimization/detection-tests.php +++ b/tests/modules/images/image-loading-optimization/detection-tests.php @@ -48,9 +48,10 @@ static function (): int { /** * Make sure the expected script is printed. * - * @dataProvider data_provider_ilo_get_detection_script * @covers ::ilo_get_detection_script * + * @dataProvider data_provider_ilo_get_detection_script + * * @param Closure $set_up Set up callback. * @param array}> $expected_exports Expected exports. */ diff --git a/tests/modules/images/image-loading-optimization/storage/lock-tests.php b/tests/modules/images/image-loading-optimization/storage/lock-tests.php index cb31e2c41a..dfedb566d0 100644 --- a/tests/modules/images/image-loading-optimization/storage/lock-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/lock-tests.php @@ -56,6 +56,7 @@ static function (): int { * Test ilo_get_url_metric_storage_lock_ttl(). * * @covers ::ilo_get_url_metric_storage_lock_ttl + * * @dataProvider data_provider_ilo_get_url_metric_storage_lock_ttl * * @param Closure $set_up Set up. diff --git a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php index 068d240416..15b298016a 100644 --- a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php @@ -90,6 +90,7 @@ public function data_provider_test_ilo_parse_stored_url_metrics(): array { * Test ilo_parse_stored_url_metrics(). * * @covers ::ilo_parse_stored_url_metrics + * * @dataProvider data_provider_test_ilo_parse_stored_url_metrics */ public function test_ilo_parse_stored_url_metrics( string $post_content, array $expected_value ) { diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index 8e4ddc0c4a..0594a5f2d7 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -132,6 +132,7 @@ function ( $params ) { * * @covers ::ilo_register_endpoint * @covers ::ilo_handle_rest_request + * * @dataProvider data_provider_invalid_params */ public function test_rest_request_bad_params( array $params ) { From d62d710c23ed6d8dad31642c68059e87b16263e2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 11 Jan 2024 22:24:55 -0800 Subject: [PATCH 193/371] Add additional line breaks in phpdoc Co-authored-by: Mukesh Panchal --- .../images/image-loading-optimization/optimization-tests.php | 3 +++ .../images/image-loading-optimization/storage/data-tests.php | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index dcce9a556d..e40f8dd859 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -85,6 +85,7 @@ public function data_provider_test_ilo_can_optimize_response(): array { * Test ilo_can_optimize_response(). * * @covers ::ilo_can_optimize_response + * * @dataProvider data_provider_test_ilo_can_optimize_response */ public function test_ilo_can_optimize_response( Closure $set_up, bool $expected ) { @@ -188,6 +189,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { * Test ilo_construct_preload_links(). * * @covers ::ilo_construct_preload_links + * * @dataProvider data_provider_test_ilo_construct_preload_links */ public function test_ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_widths, string $expected ) { @@ -446,6 +448,7 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array * Test ilo_optimize_template_output_buffer(). * * @covers ::ilo_optimize_template_output_buffer + * * @dataProvider data_provider_test_ilo_optimize_template_output_buffer */ public function test_ilo_optimize_template_output_buffer( Closure $set_up, string $buffer, string $expected ) { diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 19508751a0..d14d477405 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -90,6 +90,7 @@ public function data_provider_test_ilo_get_normalized_query_vars(): array { * Test ilo_get_normalized_query_vars(). * * @covers ::ilo_get_normalized_query_vars + * * @dataProvider data_provider_test_ilo_get_normalized_query_vars */ public function test_ilo_get_normalized_query_vars( Closure $set_up ) { @@ -175,6 +176,7 @@ public function data_provider_sample_size_and_breakpoints(): array { * Test ilo_unshift_url_metrics(). * * @covers ::ilo_unshift_url_metrics + * * @dataProvider data_provider_sample_size_and_breakpoints */ public function test_ilo_unshift_url_metrics( int $sample_size, array $breakpoints, array $viewport_widths ) { @@ -293,6 +295,7 @@ public function data_provider_test_ilo_group_url_metrics_by_breakpoint(): array * Test ilo_group_url_metrics_by_breakpoint(). * * @covers ::ilo_group_url_metrics_by_breakpoint + * * @dataProvider data_provider_test_ilo_group_url_metrics_by_breakpoint */ public function test_ilo_group_url_metrics_by_breakpoint( array $breakpoints, array $viewport_widths ) { @@ -505,6 +508,7 @@ public function data_provider_test_ilo_get_needed_minimum_viewport_widths(): arr * Test ilo_get_needed_minimum_viewport_widths(). * * @covers ::ilo_get_needed_minimum_viewport_widths + * * @dataProvider data_provider_test_ilo_get_needed_minimum_viewport_widths */ public function test_ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $current_time, array $breakpoint_max_widths, int $sample_size, int $freshness_ttl, array $expected ) { From b84a823358516fd9e83a70c4a41bbac1286ef5b3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 12 Jan 2024 12:28:28 -0800 Subject: [PATCH 194/371] Remove debug code and consolidate TODO --- .../images/image-loading-optimization/optimization.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 3d5ce1a238..59427e2691 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -187,9 +187,9 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { foreach ( $processor->open_tags() as $tag_name ) { $is_img_tag = ( 'IMG' === $tag_name ); $style = $processor->get_attribute( 'style' ); - $background_image = null; // TODO: Could be an array. + $background_image = null; // TODO: The background image could be supplied via `background` shorthand as well. - // TODO: Multiple background images may be layered. + // TODO: Multiple background images may be layered, in which case $background_image should be an array. if ( $style && preg_match( '/background-image\s*:\s*url\(\s*[\'"]?(?!data:)(?.+?)[\'"]?\s*\)/', $style, $matches ) ) { $background_image = $matches['background_image']; } @@ -198,11 +198,6 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { continue; } - // DEBUG. - if ( $background_image ) { - $processor->set_attribute( 'data-ilo-has-bg-image', $background_image ); - } - $xpath = $processor->get_xpath(); // Ensure the fetchpriority attribute is set on the element properly. From 14428a70f7a13fbe449a0bfe5ad6ddf2f2a75fde Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 12 Jan 2024 12:46:35 -0800 Subject: [PATCH 195/371] Add test for responsive background images --- .../optimization-tests.php | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index fa79567d7b..21cb03e0f9 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -370,7 +370,78 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array -
      This is so background!
      +
      This is so background!
      + + + ', + ), + + 'responsive-background-images' => array( + 'set_up' => function () { + $mobile_breakpoint = 480; + $tablet_breakpoint = 600; + $desktop_breakpoint = 782; + add_filter( + 'ilo_breakpoint_max_widths', + static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { + return array( $mobile_breakpoint, $tablet_breakpoint ); + } + ); + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + + $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); + $div_index_to_viewport_width_mapping = array( + 0 => $desktop_breakpoint, + 1 => $tablet_breakpoint, + 2 => $mobile_breakpoint, + ); + + foreach ( $div_index_to_viewport_width_mapping as $div_index => $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + ilo_store_url_metric( + home_url( '/' ), + $slug, + $this->get_validated_url_metric( + $viewport_width, + array( + array( + 'xpath' => "/*[0][self::HTML]/*[1][self::BODY]/*[{$div_index}][self::DIV]", + 'isLCP' => true, + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + + +
      This is the desktop background!
      +
      This is the tablet background!
      +
      This is the mobile background!
      + + + ', + 'expected' => ' + + + + ... + + + + + + +
      This is the desktop background!
      +
      This is the tablet background!
      +
      This is the mobile background!
      ', From f592382cfff006f784372cc47b2c1998aa2d5c31 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 12 Jan 2024 13:02:25 -0800 Subject: [PATCH 196/371] Improve construction of media queries * Include screen media type * Remove whitespace padding from media features * Omit needless min-width:0 media feature --- .../optimization.php | 13 +++--- .../optimization-tests.php | 40 +++++++++---------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 59427e2691..11c26c2de8 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -100,13 +100,14 @@ function ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_wi // Add media query if it's going to be something other than just `min-width: 0px`. $minimum_viewport_width = $minimum_viewport_widths[ $i ]; $maximum_viewport_width = isset( $minimum_viewport_widths[ $i + 1 ] ) ? $minimum_viewport_widths[ $i + 1 ] - 1 : null; - if ( $minimum_viewport_width > 0 || null !== $maximum_viewport_width ) { - $media_query = sprintf( '( min-width: %dpx )', $minimum_viewport_width ); // TODO: No need to add min-width:0px. - if ( null !== $maximum_viewport_width ) { - $media_query .= sprintf( ' and ( max-width: %dpx )', $maximum_viewport_width ); - } - $link_attributes['media'] = $media_query; + $media_features = array( 'screen' ); + if ( $minimum_viewport_width > 0 ) { + $media_features[] = sprintf( '(min-width: %dpx)', $minimum_viewport_width ); + } + if ( null !== $maximum_viewport_width ) { + $media_features[] = sprintf( '(max-width: %dpx)', $maximum_viewport_width ); } + $link_attributes['media'] = implode( ' and ', $media_features ); // Construct preload link. $link_tag = ' ' - + ', ), 'one-responsive-lcp-image' => array( @@ -130,7 +130,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), ), 'expected' => ' - + ', ), 'two-breakpoint-responsive-lcp-images' => array( @@ -153,8 +153,8 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), ), 'expected' => ' - - + + ', ), 'two-non-consecutive-responsive-lcp-images' => array( @@ -178,8 +178,8 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), ), 'expected' => ' - - + + ', ), 'one-background-lcp-image' => array( @@ -189,7 +189,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), ), 'expected' => ' - + ', ), 'two-background-lcp-images' => array( @@ -202,8 +202,8 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), ), 'expected' => ' - - + + ', ), 'one-bg-image-one-img-element' => array( @@ -221,8 +221,8 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), ), 'expected' => ' - - + + ', ), ); @@ -319,7 +319,7 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array ... - + Foo @@ -367,7 +367,7 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array ... - +
      This is so background!
      @@ -434,9 +434,9 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ... - - - + + +
      This is the desktop background!
      @@ -485,7 +485,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ... - + Foo @@ -526,7 +526,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ... - + @@ -590,8 +590,8 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ... - - + + From f743656305e8c0b8a327a38f44168db977738866 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 12 Jan 2024 13:10:37 -0800 Subject: [PATCH 197/371] Add test for a data: URL background-image --- .../optimization-tests.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 749e29bd8b..e4ce73004d 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -276,6 +276,35 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array ', ), + 'no-url-metrics-with-data-url-background-image' => array( + 'set_up' => static function () {}, + // Smallest PNG courtesy of . + 'buffer' => ' + + + + ... + + +
      This is so background!
      + + + ', + // There should be no data-ilo-xpath added to the DIV because it is using a data: URL for the background-image. + 'expected' => ' + + + + ... + + + +
      This is so background!
      + + + ', + ), + 'common-lcp-image-with-fully-populated-sample-data' => array( 'set_up' => function () { $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); From dde75c44c6a41de874d637abc4289cfea26d4c1d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 12 Jan 2024 13:40:19 -0800 Subject: [PATCH 198/371] Add support for the background shorthand property --- .../image-loading-optimization/optimization.php | 17 ++++++++++++----- .../optimization-tests.php | 4 ++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 11c26c2de8..57a364099f 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -186,12 +186,19 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // Walk over all IMG tags in the document and ensure fetchpriority is set/removed, and gather IMG attributes for preloading. $processor = new ILO_HTML_Tag_Processor( $buffer ); foreach ( $processor->open_tags() as $tag_name ) { - $is_img_tag = ( 'IMG' === $tag_name ); - $style = $processor->get_attribute( 'style' ); + $is_img_tag = ( 'IMG' === $tag_name ); + $style = $processor->get_attribute( 'style' ); + + /* + * Note that CSS allows for a `background`/`background-image` to have multiple `url()` CSS functions, resulting + * in multiple background images being layered on top of each other. This ability is not employed in core. Here + * is a regex to search WPDirectory for instances of this: /background(-image)?:[^;}]+?url\([^;}]+?[^_]url\(/. + * It is used in Jetpack with the second background image being a gradient. To support multiple background + * images, this logic would need to be modified to make $background_image an array and to have a more robust + * parser of the `url()` functions from the property value. + */ $background_image = null; - // TODO: The background image could be supplied via `background` shorthand as well. - // TODO: Multiple background images may be layered, in which case $background_image should be an array. - if ( $style && preg_match( '/background-image\s*:\s*url\(\s*[\'"]?(?!data:)(?.+?)[\'"]?\s*\)/', $style, $matches ) ) { + if ( $style && preg_match( '/background(-image)?\s*:[^;]*?url\(\s*[\'"]?(?!data:)(?.+?)[\'"]?\s*\)/', $style, $matches ) ) { $background_image = $matches['background_image']; } diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index e4ce73004d..fcf8664fbc 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -451,7 +451,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { -
      This is the desktop background!
      +
      This is the desktop background!
      This is the tablet background!
      This is the mobile background!
      @@ -468,7 +468,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { -
      This is the desktop background!
      +
      This is the desktop background!
      This is the tablet background!
      This is the mobile background!
      From d1250e6141c0e9857d56e21cd6b05719abd33595 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 19 Jan 2024 10:52:09 -0800 Subject: [PATCH 199/371] Remove obsolete TODO Co-authored-by: Pascal Birchler --- modules/images/image-loading-optimization/optimization.php | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 57a364099f..19546fb1f9 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -166,7 +166,6 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { } } - // TODO: Handle case when the LCP element is not an image at all, but rather a background-image. // Prepare to set fetchpriority attribute on the image when all breakpoints have the same LCP element. if ( // All breakpoints share the same LCP element (or all have none at all). From 6ce08c10801b7e2a1e9e9c4f50d0091627c16d85 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 26 Jan 2024 23:45:53 +0530 Subject: [PATCH 200/371] Add copy-webpack-plugin --- package-lock.json | 369 ++++++++++++++++++++++++++++++++++++++-------- package.json | 1 + 2 files changed, 305 insertions(+), 65 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8903ab005d..b1f8c1b1df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@wordpress/scripts": "^26.19.0", "chalk": "^4.1.2", "commander": "^9.4.1", + "copy-webpack-plugin": "^12.0.2", "fast-glob": "^3.3.2", "fs-extra": "^11.2.0", "husky": "^8.0.2", @@ -3337,6 +3338,18 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sindresorhus/merge-streams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-1.0.0.tgz", + "integrity": "sha512-rUV5WyJrJLoloD4NDN1V1+LDMDWOa4OTsT4yYJwQNpTU6FWxkxHpL7eu4w+DmiH8x/EAM1otkPE1+LaspIbplw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", @@ -5150,6 +5163,127 @@ "react-dom": "^18.0.0" } }, + "node_modules/@wordpress/scripts/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@wordpress/scripts/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/@wordpress/scripts/node_modules/array-union": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz", + "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@wordpress/scripts/node_modules/copy-webpack-plugin": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", + "integrity": "sha512-xFVltahqlsRcyyJqQbDY6EYTtyQZF9rf+JPjwHObLdPFMEISqkFkr7mFoVOC6BfYS/dNThyoQKvziugm+OnwBg==", + "dev": true, + "dependencies": { + "fast-glob": "^3.2.7", + "glob-parent": "^6.0.1", + "globby": "^12.0.2", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "engines": { + "node": ">= 12.20.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/@wordpress/scripts/node_modules/globby": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz", + "integrity": "sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==", + "dev": true, + "dependencies": { + "array-union": "^3.0.1", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.7", + "ignore": "^5.1.9", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@wordpress/scripts/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/@wordpress/scripts/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@wordpress/scripts/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@wordpress/stylelint-config": { "version": "21.31.0", "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-21.31.0.tgz", @@ -7042,20 +7176,20 @@ "dev": true }, "node_modules/copy-webpack-plugin": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", - "integrity": "sha512-xFVltahqlsRcyyJqQbDY6EYTtyQZF9rf+JPjwHObLdPFMEISqkFkr7mFoVOC6BfYS/dNThyoQKvziugm+OnwBg==", + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", + "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", "dev": true, "dependencies": { - "fast-glob": "^3.2.7", + "fast-glob": "^3.3.2", "glob-parent": "^6.0.1", - "globby": "^12.0.2", + "globby": "^14.0.0", "normalize-path": "^3.0.0", - "schema-utils": "^4.0.0", - "serialize-javascript": "^6.0.0" + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2" }, "engines": { - "node": ">= 12.20.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", @@ -7093,33 +7227,21 @@ "ajv": "^8.8.2" } }, - "node_modules/copy-webpack-plugin/node_modules/array-union": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz", - "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/copy-webpack-plugin/node_modules/globby": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz", - "integrity": "sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.0.tgz", + "integrity": "sha512-/1WM/LNHRAOH9lZta77uGbq0dAEQM+XjNesWwhlERDVenqothRbnzTrL3/LrIoEPPjeUHC3vrS6TwoyxeHs7MQ==", "dev": true, "dependencies": { - "array-union": "^3.0.1", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.7", - "ignore": "^5.1.9", - "merge2": "^1.4.1", - "slash": "^4.0.0" + "@sindresorhus/merge-streams": "^1.0.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7131,6 +7253,18 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, + "node_modules/copy-webpack-plugin/node_modules/path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/copy-webpack-plugin/node_modules/schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", @@ -7151,12 +7285,12 @@ } }, "node_modules/copy-webpack-plugin/node_modules/slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", "dev": true, "engines": { - "node": ">=12" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -17083,9 +17217,9 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "dependencies": { "randombytes": "^2.1.0" @@ -18814,6 +18948,18 @@ "node": ">=4" } }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -22317,6 +22463,12 @@ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", "dev": true }, + "@sindresorhus/merge-streams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-1.0.0.tgz", + "integrity": "sha512-rUV5WyJrJLoloD4NDN1V1+LDMDWOa4OTsT4yYJwQNpTU6FWxkxHpL7eu4w+DmiH8x/EAM1otkPE1+LaspIbplw==", + "dev": true + }, "@sinonjs/commons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", @@ -23713,6 +23865,87 @@ "webpack-bundle-analyzer": "^4.9.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1" + }, + "dependencies": { + "ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "array-union": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz", + "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==", + "dev": true + }, + "copy-webpack-plugin": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", + "integrity": "sha512-xFVltahqlsRcyyJqQbDY6EYTtyQZF9rf+JPjwHObLdPFMEISqkFkr7mFoVOC6BfYS/dNThyoQKvziugm+OnwBg==", + "dev": true, + "requires": { + "fast-glob": "^3.2.7", + "glob-parent": "^6.0.1", + "globby": "^12.0.2", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + } + }, + "globby": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz", + "integrity": "sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==", + "dev": true, + "requires": { + "array-union": "^3.0.1", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.7", + "ignore": "^5.1.9", + "merge2": "^1.4.1", + "slash": "^4.0.0" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + } + }, + "slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true + } } }, "@wordpress/stylelint-config": { @@ -25117,17 +25350,17 @@ "dev": true }, "copy-webpack-plugin": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", - "integrity": "sha512-xFVltahqlsRcyyJqQbDY6EYTtyQZF9rf+JPjwHObLdPFMEISqkFkr7mFoVOC6BfYS/dNThyoQKvziugm+OnwBg==", + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", + "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", "dev": true, "requires": { - "fast-glob": "^3.2.7", + "fast-glob": "^3.3.2", "glob-parent": "^6.0.1", - "globby": "^12.0.2", + "globby": "^14.0.0", "normalize-path": "^3.0.0", - "schema-utils": "^4.0.0", - "serialize-javascript": "^6.0.0" + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2" }, "dependencies": { "ajv": { @@ -25151,24 +25384,18 @@ "fast-deep-equal": "^3.1.3" } }, - "array-union": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz", - "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==", - "dev": true - }, "globby": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz", - "integrity": "sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.0.tgz", + "integrity": "sha512-/1WM/LNHRAOH9lZta77uGbq0dAEQM+XjNesWwhlERDVenqothRbnzTrL3/LrIoEPPjeUHC3vrS6TwoyxeHs7MQ==", "dev": true, "requires": { - "array-union": "^3.0.1", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.7", - "ignore": "^5.1.9", - "merge2": "^1.4.1", - "slash": "^4.0.0" + "@sindresorhus/merge-streams": "^1.0.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" } }, "json-schema-traverse": { @@ -25177,6 +25404,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, + "path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", + "dev": true + }, "schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", @@ -25190,9 +25423,9 @@ } }, "slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", "dev": true } } @@ -32480,9 +32713,9 @@ } }, "serialize-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "requires": { "randombytes": "^2.1.0" @@ -33844,6 +34077,12 @@ "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", "dev": true }, + "unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true + }, "unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", diff --git a/package.json b/package.json index 077bbf7176..e370479f32 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@wordpress/scripts": "^26.19.0", "chalk": "^4.1.2", "commander": "^9.4.1", + "copy-webpack-plugin": "^12.0.2", "fast-glob": "^3.3.2", "fs-extra": "^11.2.0", "husky": "^8.0.2", From 1e68a88900b7f3f9c4f6fbd5311b30678c0af943 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 26 Jan 2024 23:46:12 +0530 Subject: [PATCH 201/371] Add webpackbar --- package-lock.json | 120 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 3 +- 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index b1f8c1b1df..92e92c7281 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "fs-extra": "^11.2.0", "husky": "^8.0.2", "lint-staged": "^13.1.0", - "lodash": "4.17.21" + "lodash": "4.17.21", + "webpackbar": "^6.0.0" }, "engines": { "node": ">=20.10.0", @@ -7096,6 +7097,15 @@ "node": ">=0.8" } }, + "node_modules/consola": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", + "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "dev": true, + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/constant-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", @@ -13583,6 +13593,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/markdown-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", + "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "dev": true, + "dependencies": { + "repeat-string": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/markdownlint": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.25.1.tgz", @@ -16017,6 +16040,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pretty-time/-/pretty-time-1.1.0.tgz", + "integrity": "sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -16716,6 +16748,15 @@ "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", "dev": true }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "engines": { + "node": ">=0.10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -17825,6 +17866,12 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "dev": true + }, "node_modules/streamx": { "version": "2.15.6", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.6.tgz", @@ -19741,6 +19788,28 @@ "node": ">=10.13.0" } }, + "node_modules/webpackbar": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-6.0.0.tgz", + "integrity": "sha512-RdB0RskzOaix1VFMnBXSkKMbUgvZliRqgoNp0gCnG6iUe9RS9sf018AJ/1h5NAeh+ttwXkXjXKC6NdjE/OOcaA==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "consola": "^3.2.3", + "figures": "^3.2.0", + "markdown-table": "^2.0.0", + "pretty-time": "^1.1.0", + "std-env": "^3.6.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "webpack": "3 || 4 || 5" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -25291,6 +25360,12 @@ "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", "dev": true }, + "consola": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", + "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "dev": true + }, "constant-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", @@ -30087,6 +30162,15 @@ } } }, + "markdown-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", + "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "dev": true, + "requires": { + "repeat-string": "^1.0.0" + } + }, "markdownlint": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.25.1.tgz", @@ -31814,6 +31898,12 @@ } } }, + "pretty-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pretty-time/-/pretty-time-1.1.0.tgz", + "integrity": "sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==", + "dev": true + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -32348,6 +32438,12 @@ "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", "dev": true }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -33216,6 +33312,12 @@ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "dev": true }, + "std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "dev": true + }, "streamx": { "version": "2.15.6", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.6.tgz", @@ -34648,6 +34750,22 @@ "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", "dev": true }, + "webpackbar": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-6.0.0.tgz", + "integrity": "sha512-RdB0RskzOaix1VFMnBXSkKMbUgvZliRqgoNp0gCnG6iUe9RS9sf018AJ/1h5NAeh+ttwXkXjXKC6NdjE/OOcaA==", + "dev": true, + "requires": { + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "consola": "^3.2.3", + "figures": "^3.2.0", + "markdown-table": "^2.0.0", + "pretty-time": "^1.1.0", + "std-env": "^3.6.0", + "wrap-ansi": "^7.0.0" + } + }, "websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", diff --git a/package.json b/package.json index e370479f32..146a4927b0 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "fs-extra": "^11.2.0", "husky": "^8.0.2", "lint-staged": "^13.1.0", - "lodash": "4.17.21" + "lodash": "4.17.21", + "webpackbar": "^6.0.0" }, "scripts": { "changelog": "./bin/plugin/cli.js changelog", From 51346e828ce35aef54f7f85c1ab17748c18edbb2 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 26 Jan 2024 23:47:56 +0530 Subject: [PATCH 202/371] Bump @babel/traverse --- package-lock.json | 80 +++++++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/package-lock.json b/package-lock.json index 92e92c7281..93bb70f7af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -192,12 +192,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz", - "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==", + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", "dev": true, "dependencies": { - "@babel/types": "^7.22.10", + "@babel/types": "^7.23.6", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -611,9 +611,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", + "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -2029,20 +2029,20 @@ } }, "node_modules/@babel/traverse": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.10.tgz", - "integrity": "sha512-Q/urqV4pRByiNNpb/f5OSv28ZlGJiFiiTh+GAHktbIrkPhPbl90+uW6SmpoLyZqutrg9AEaEf3Q/ZBRHBXgxig==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.9.tgz", + "integrity": "sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.10", - "@babel/generator": "^7.22.10", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.10", - "@babel/types": "^7.22.10", - "debug": "^4.1.0", + "@babel/parser": "^7.23.9", + "@babel/types": "^7.23.9", + "debug": "^4.3.1", "globals": "^11.1.0" }, "engines": { @@ -2050,9 +2050,9 @@ } }, "node_modules/@babel/types": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", - "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", + "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.23.4", @@ -20260,12 +20260,12 @@ } }, "@babel/generator": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz", - "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==", + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", "dev": true, "requires": { - "@babel/types": "^7.22.10", + "@babel/types": "^7.23.6", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -20573,9 +20573,9 @@ } }, "@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", + "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", "dev": true }, "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { @@ -21518,27 +21518,27 @@ } }, "@babel/traverse": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.10.tgz", - "integrity": "sha512-Q/urqV4pRByiNNpb/f5OSv28ZlGJiFiiTh+GAHktbIrkPhPbl90+uW6SmpoLyZqutrg9AEaEf3Q/ZBRHBXgxig==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.9.tgz", + "integrity": "sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==", "dev": true, "requires": { - "@babel/code-frame": "^7.22.10", - "@babel/generator": "^7.22.10", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.10", - "@babel/types": "^7.22.10", - "debug": "^4.1.0", + "@babel/parser": "^7.23.9", + "@babel/types": "^7.23.9", + "debug": "^4.3.1", "globals": "^11.1.0" } }, "@babel/types": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", - "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", + "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", "dev": true, "requires": { "@babel/helper-string-parser": "^7.23.4", From ea7f497507a1e04944f8cf3ce2ddea96cfe7dbf5 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 01:11:49 +0530 Subject: [PATCH 203/371] Add webpack base config --- webpack.config.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 webpack.config.js diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000000..7018be55a2 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +const path = require( 'path' ); +const WebpackBar = require( 'webpackbar' ); +const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); + +/** + * WordPress dependencies + */ +const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); + +const sharedConfig = { + ...defaultConfig, + entry: {}, + output: {}, +}; From 3565468dad941bc7a5b4b94d4354341430d8d7e2 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 01:12:55 +0530 Subject: [PATCH 204/371] Add webpack config for copying web vitals libs --- webpack.config.js | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/webpack.config.js b/webpack.config.js index 7018be55a2..9edc600010 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -15,3 +15,59 @@ const sharedConfig = { entry: {}, output: {}, }; + +/** + * Transformer to get version from package.json and return it as a PHP file. + * + * @param {Buffer} content The content as a Buffer of the file being transformed. + * @param {string} absoluteFrom The absolute path to the file being transformed. + * + * @return {string} The transformed content. + */ +const assetDataTransformer = ( content, absoluteFrom ) => { + if ( 'package.json' !== path.basename( absoluteFrom ) ) { + return content; + } + + const contentAsString = content.toString(); + const contentAsJson = JSON.parse( contentAsString ); + const { version } = contentAsJson; + + return ``; +}; + +const webVitals = () => { + const source = path.resolve( __dirname, 'node_modules/web-vitals' ); + const destination = path.resolve( + __dirname, + 'modules/images/image-loading-optimization/detection' + ); + + return { + ...sharedConfig, + plugins: [ + new CopyWebpackPlugin( { + patterns: [ + { + from: `${ source }/dist/web-vitals.js`, + to: `${ destination }/[name].[ext]`, + }, + { + from: `${ source }/package.json`, + to: `${ destination }/web-vitals.asset.php`, + transform: { + transformer: assetDataTransformer, + cache: false, + }, + }, + ], + } ), + new WebpackBar( { + name: 'Web Vitals', + color: '#f5a623', + } ), + ], + }; +}; + +module.exports = [ webVitals ]; From 3a8567d92729e7704cc460264ae8a890d0fa79e1 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 01:21:19 +0530 Subject: [PATCH 205/371] Update config for copying web vitals libs --- webpack.config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webpack.config.js b/webpack.config.js index 9edc600010..6df9c33ec7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -33,7 +33,7 @@ const assetDataTransformer = ( content, absoluteFrom ) => { const contentAsJson = JSON.parse( contentAsString ); const { version } = contentAsJson; - return ``; + return ` { @@ -50,11 +50,11 @@ const webVitals = () => { patterns: [ { from: `${ source }/dist/web-vitals.js`, - to: `${ destination }/[name].[ext]`, + to: `${ destination }/web-vitals/index.js`, }, { from: `${ source }/package.json`, - to: `${ destination }/web-vitals.asset.php`, + to: `${ destination }/web-vitals/index.asset.php`, transform: { transformer: assetDataTransformer, cache: false, From 8618596b821a65a9eb2a7256a26b55f95b669657 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 01:21:35 +0530 Subject: [PATCH 206/371] Add web-vitals script to ignore list --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 1889d02de2..7802dcec4c 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,8 @@ temp/ ._* .Trashes .svn + +############ +## Vendor Libraries +############ +modules/images/image-loading-optimization/detection/web-vitals/* From b0ca402127bade67dea9b18488981ed004b0e96f Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 01:23:56 +0530 Subject: [PATCH 207/371] Add wp-scripts build script --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 146a4927b0..2dabf88182 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "node": ">=20.10.0", "npm": ">=10.2.3" }, + "dependencies": { + "web-vitals": "3.5.0" + }, "devDependencies": { "@octokit/rest": "^19.0.5", "@wordpress/env": "^5.7.0", @@ -25,6 +28,7 @@ "since": "./bin/plugin/cli.js since", "readme": "./bin/plugin/cli.js readme", "translations": "./bin/plugin/cli.js translations", + "build": "wp-scripts build", "build-plugins": "./bin/plugin/cli.js build-plugins", "test-plugins": "./bin/plugin/cli.js test-plugins", "test-plugins-multisite": "./bin/plugin/cli.js test-plugins --sitetype=multi", @@ -50,8 +54,5 @@ "*.js": [ "npm run lint-js" ] - }, - "dependencies": { - "web-vitals": "3.5.0" } } From 55752672f96c347e52f06fa58d43f7dbd0b1bdec Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 01:30:49 +0530 Subject: [PATCH 208/371] Add web-vitals library to include from plugin fs --- modules/images/image-loading-optimization/detection.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 7eb999ec68..d74e469bce 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -35,6 +35,9 @@ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_ */ $detection_time_window = apply_filters( 'ilo_detection_time_window', 5000 ); + $web_vitals_version = require_once __DIR__ . '/detection/web-vitals/index.asset.php'; + $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_version, plugin_dir_url( __FILE__ ) . '/detection/web-vitals/index.js' ); + $detect_args = array( 'serveTime' => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. 'detectionTimeWindow' => $detection_time_window, @@ -45,6 +48,7 @@ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_ 'urlMetricsNonce' => ilo_get_url_metrics_storage_nonce( $slug ), 'neededMinimumViewportWidths' => $needed_minimum_viewport_widths, 'storageLockTTL' => ilo_get_url_metric_storage_lock_ttl(), + 'webVitalsLibrarySrc' => $web_vitals_lib_src, ); return wp_get_inline_script_tag( sprintf( From 2a9a54785d59a476010158a9b8a76f2190f6a8e9 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 01:31:36 +0530 Subject: [PATCH 209/371] Update web-vitals library dynamic import --- .../images/image-loading-optimization/detection/detect.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 48b002fd69..b4ffcdd6a6 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -141,6 +141,7 @@ function getCurrentTime() { * @param {string} args.urlMetricsNonce Nonce for URL metrics storage. * @param {Array[]} args.neededMinimumViewportWidths Needed minimum viewport widths for URL metrics. * @param {number} args.storageLockTTL The TTL (in seconds) for the URL metric storage lock. + * @param {string} args.webVitalsLibrarySrc The URL for the web-vitals library. */ export default async function detect( { serveTime, @@ -152,6 +153,7 @@ export default async function detect( { urlMetricsNonce, neededMinimumViewportWidths, storageLockTTL, + webVitalsLibrarySrc, } ) { const currentTime = getCurrentTime(); @@ -271,11 +273,7 @@ export default async function detect( { } ); } - // TODO: Use a local copy of web-vitals. - const { onLCP } = await import( - // eslint-disable-next-line import/no-unresolved - 'https://unpkg.com/web-vitals@3/dist/web-vitals.js?module' - ); + const { onLCP } = await import( webVitalsLibrarySrc ); /** @type {LCPMetric[]} */ const lcpMetricCandidates = []; From 9139b2a753d541e144d244151a2f01d5ff4a6af1 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 01:59:09 +0530 Subject: [PATCH 210/371] Add build assets steps in unit tests --- .github/workflows/php-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/php-test.yml b/.github/workflows/php-test.yml index 4c249687b0..36d8b390d9 100644 --- a/.github/workflows/php-test.yml +++ b/.github/workflows/php-test.yml @@ -52,6 +52,8 @@ jobs: cache: npm - name: npm install run: npm ci + - name: Assets build + run: npm run build - name: Install WordPress run: npm run wp-env start - name: Running single site unit tests From e59f8b183200751ff68ecf5b436a3f05bbb02611 Mon Sep 17 00:00:00 2001 From: Lovekesh Kumar Date: Sat, 27 Jan 2024 03:01:47 +0530 Subject: [PATCH 211/371] Update web-vitals ignore pattern Co-authored-by: Weston Ruter --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7802dcec4c..3306fa3391 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,4 @@ temp/ ############ ## Vendor Libraries ############ -modules/images/image-loading-optimization/detection/web-vitals/* +modules/images/image-loading-optimization/detection/web-vitals* From 75e39490f96df3b807f670111c744119c9725658 Mon Sep 17 00:00:00 2001 From: Lovekesh Kumar Date: Sat, 27 Jan 2024 03:02:58 +0530 Subject: [PATCH 212/371] Update copied web-vitals library filename Co-authored-by: Weston Ruter --- modules/images/image-loading-optimization/detection.php | 4 ++-- webpack.config.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index d74e469bce..30f2cb1de2 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -35,8 +35,8 @@ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_ */ $detection_time_window = apply_filters( 'ilo_detection_time_window', 5000 ); - $web_vitals_version = require_once __DIR__ . '/detection/web-vitals/index.asset.php'; - $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_version, plugin_dir_url( __FILE__ ) . '/detection/web-vitals/index.js' ); + $web_vitals_version = require_once __DIR__ . '/detection/web-vitals.asset.php'; + $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_version, plugin_dir_url( __FILE__ ) . '/detection/web-vitals.js' ); $detect_args = array( 'serveTime' => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. diff --git a/webpack.config.js b/webpack.config.js index 6df9c33ec7..073ecdf310 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -50,11 +50,11 @@ const webVitals = () => { patterns: [ { from: `${ source }/dist/web-vitals.js`, - to: `${ destination }/web-vitals/index.js`, + to: `${ destination }/web-vitals.js`, }, { from: `${ source }/package.json`, - to: `${ destination }/web-vitals/index.asset.php`, + to: `${ destination }/web-vitals.asset.php`, transform: { transformer: assetDataTransformer, cache: false, From c00f6bc7d3e97b5588b5a413cb06d4d8735fd21e Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 03:13:11 +0530 Subject: [PATCH 213/371] Add .asset.php files to exclude from PHPCS --- phpcs.xml.dist | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index c6fa03962a..0e56612b9e 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -42,8 +42,8 @@ tests/*
      - tests/* - + tests/* +
      tests/* @@ -103,5 +103,10 @@
      + + + + *.asset.php + From 4356a2515b35a29a01ebbcfa2dedeb941707f330 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 03:14:13 +0530 Subject: [PATCH 214/371] Update auto-generated .asset.php file format --- webpack.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpack.config.js b/webpack.config.js index 073ecdf310..7a94961aea 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -33,7 +33,7 @@ const assetDataTransformer = ( content, absoluteFrom ) => { const contentAsJson = JSON.parse( contentAsString ); const { version } = contentAsJson; - return ` array(), 'version' => '${ version }');`; }; const webVitals = () => { From a5ff5040fe81340d26ea3d9ae8343497761f2ae9 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 03:18:41 +0530 Subject: [PATCH 215/371] Update version detection for web-vitals script --- modules/images/image-loading-optimization/detection.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 30f2cb1de2..135d2fecf6 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -35,8 +35,8 @@ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_ */ $detection_time_window = apply_filters( 'ilo_detection_time_window', 5000 ); - $web_vitals_version = require_once __DIR__ . '/detection/web-vitals.asset.php'; - $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_version, plugin_dir_url( __FILE__ ) . '/detection/web-vitals.js' ); + $web_vitals_lib_data = require_once __DIR__ . '/detection/web-vitals.asset.php'; + $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . '/detection/web-vitals.js' ); $detect_args = array( 'serveTime' => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. @@ -50,6 +50,7 @@ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_ 'storageLockTTL' => ilo_get_url_metric_storage_lock_ttl(), 'webVitalsLibrarySrc' => $web_vitals_lib_src, ); + return wp_get_inline_script_tag( sprintf( 'import detect from %s; detect( %s );', From 70851eb33681b858249b0a0f07a4d5181daee7c2 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 03:28:31 +0530 Subject: [PATCH 216/371] Add can-load.php for ILO module --- .../image-loading-optimization/can-load.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 modules/images/image-loading-optimization/can-load.php diff --git a/modules/images/image-loading-optimization/can-load.php b/modules/images/image-loading-optimization/can-load.php new file mode 100644 index 0000000000..62c1b8635e --- /dev/null +++ b/modules/images/image-loading-optimization/can-load.php @@ -0,0 +1,25 @@ + Date: Sat, 27 Jan 2024 04:08:51 +0530 Subject: [PATCH 217/371] Update assets data require statement Co-authored-by: Weston Ruter --- modules/images/image-loading-optimization/detection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 135d2fecf6..cfae3bfff0 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -35,7 +35,7 @@ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_ */ $detection_time_window = apply_filters( 'ilo_detection_time_window', 5000 ); - $web_vitals_lib_data = require_once __DIR__ . '/detection/web-vitals.asset.php'; + $web_vitals_lib_data = require __DIR__ . '/detection/web-vitals.asset.php'; $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . '/detection/web-vitals.js' ); $detect_args = array( From a249d876f135fc8f76580f8a6a7859204524374e Mon Sep 17 00:00:00 2001 From: Lovekesh Kumar Date: Sat, 27 Jan 2024 04:09:28 +0530 Subject: [PATCH 218/371] Update return type of transformed content Co-authored-by: Weston Ruter --- webpack.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpack.config.js b/webpack.config.js index 7a94961aea..95cfe75188 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -22,7 +22,7 @@ const sharedConfig = { * @param {Buffer} content The content as a Buffer of the file being transformed. * @param {string} absoluteFrom The absolute path to the file being transformed. * - * @return {string} The transformed content. + * @return {Buffer|string} The transformed content. */ const assetDataTransformer = ( content, absoluteFrom ) => { if ( 'package.json' !== path.basename( absoluteFrom ) ) { From 1373e61a1e1708bd2f1bab0c792d80fac21c74fc Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 27 Jan 2024 04:17:12 +0530 Subject: [PATCH 219/371] Add build assets steps in workflows --- .github/workflows/deploy-dotorg.yml | 9 +++++++++ .github/workflows/deploy-standalone-plugins.yml | 2 ++ .github/workflows/php-test-standalone-plugins.yml | 2 ++ .github/workflows/php-test.yml | 2 +- 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-dotorg.yml b/.github/workflows/deploy-dotorg.yml index 4d5f24bd0a..acf9a49d6d 100644 --- a/.github/workflows/deploy-dotorg.yml +++ b/.github/workflows/deploy-dotorg.yml @@ -12,6 +12,15 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + - name: Setup Node.js (.nvmrc) + uses: actions/setup-node@v3 + with: + node-version-file: ".nvmrc" + cache: npm + - name: npm install + run: npm ci + - name: Build assets + run: npm run build - name: WordPress plugin deploy uses: 10up/action-wordpress-plugin-deploy@stable env: diff --git a/.github/workflows/deploy-standalone-plugins.yml b/.github/workflows/deploy-standalone-plugins.yml index 0515b03bef..30a84521f4 100644 --- a/.github/workflows/deploy-standalone-plugins.yml +++ b/.github/workflows/deploy-standalone-plugins.yml @@ -28,6 +28,8 @@ jobs: cache: npm - name: Install npm dependencies run: npm ci + - name: Build assets + run: npm run build - name: Get plugin version id: get-version if: ${{ github.event_name == 'workflow_dispatch' }} diff --git a/.github/workflows/php-test-standalone-plugins.yml b/.github/workflows/php-test-standalone-plugins.yml index 3065f9f39a..b3be5bb007 100644 --- a/.github/workflows/php-test-standalone-plugins.yml +++ b/.github/workflows/php-test-standalone-plugins.yml @@ -52,6 +52,8 @@ jobs: cache: npm - name: npm install run: npm ci + - name: Build assets + run: npm run build - name: Building standalone plugins run: npm run build-plugins - name: Running single site standalone plugin integration tests diff --git a/.github/workflows/php-test.yml b/.github/workflows/php-test.yml index 36d8b390d9..8002b6621f 100644 --- a/.github/workflows/php-test.yml +++ b/.github/workflows/php-test.yml @@ -52,7 +52,7 @@ jobs: cache: npm - name: npm install run: npm ci - - name: Assets build + - name: Build assets run: npm run build - name: Install WordPress run: npm run wp-env start From 6325fbdf13c69e110e34ebcbcf54f57d2614e9b6 Mon Sep 17 00:00:00 2001 From: Lovekesh Kumar Date: Mon, 29 Jan 2024 15:17:02 +0530 Subject: [PATCH 220/371] Update web vitals URL to be escaped Co-authored-by: Mukesh Panchal --- modules/images/image-loading-optimization/detection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index cfae3bfff0..0593a7d03c 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -48,7 +48,7 @@ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_ 'urlMetricsNonce' => ilo_get_url_metrics_storage_nonce( $slug ), 'neededMinimumViewportWidths' => $needed_minimum_viewport_widths, 'storageLockTTL' => ilo_get_url_metric_storage_lock_ttl(), - 'webVitalsLibrarySrc' => $web_vitals_lib_src, + 'webVitalsLibrarySrc' => esc_url( $web_vitals_lib_src ), ); return wp_get_inline_script_tag( From d858161c36404aced0ed065b4e5b0e0958e4565f Mon Sep 17 00:00:00 2001 From: Lovekesh Kumar Date: Mon, 29 Jan 2024 15:36:30 +0530 Subject: [PATCH 221/371] Update web vitals library missing WP error message Co-authored-by: Mukesh Panchal --- modules/images/image-loading-optimization/can-load.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/can-load.php b/modules/images/image-loading-optimization/can-load.php index 62c1b8635e..921b78605c 100644 --- a/modules/images/image-loading-optimization/can-load.php +++ b/modules/images/image-loading-optimization/can-load.php @@ -17,7 +17,11 @@ ) { return new WP_Error( 'perflab_missing_web_vitals_library', - __( 'The Web Vitals library is missing. Please do "npm install && npm run build" to finish installing the plugin.', 'performance-lab' ) + sprintf( + /* translators: npm command. */ + esc_html__( 'The Web Vitals library is missing. Please do "%s" to finish installing the plugin.', 'performance-lab' ), + 'npm install && npm run build' + ) ); } From c9bf7f93c70af036b8c12a98809bdd8abfde9037 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Mon, 29 Jan 2024 15:42:39 +0530 Subject: [PATCH 222/371] Update .gitignore --- .gitignore | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 3306fa3391..4b9342a707 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ build node_modules/ vendor/ +modules/images/image-loading-optimization/detection/web-vitals* ############ ## OSes @@ -59,8 +60,3 @@ temp/ ._* .Trashes .svn - -############ -## Vendor Libraries -############ -modules/images/image-loading-optimization/detection/web-vitals* From b0390edbd01fede615c6d13b362248a718e663b5 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Mon, 29 Jan 2024 23:10:40 +0530 Subject: [PATCH 223/371] Add .asset.php --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4b9342a707..4497d89692 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ nbproject/ build .wp-env.override.json +*.asset.php ############ ## Vendor From 68ca3802cba2fed84f99611f13c8723a14ba4061 Mon Sep 17 00:00:00 2001 From: Lovekesh Kumar Date: Mon, 29 Jan 2024 23:23:42 +0530 Subject: [PATCH 224/371] Remove early html and url escaping Co-authored-by: Weston Ruter --- modules/images/image-loading-optimization/can-load.php | 4 ++-- modules/images/image-loading-optimization/detection.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/can-load.php b/modules/images/image-loading-optimization/can-load.php index 921b78605c..859631896a 100644 --- a/modules/images/image-loading-optimization/can-load.php +++ b/modules/images/image-loading-optimization/can-load.php @@ -19,8 +19,8 @@ 'perflab_missing_web_vitals_library', sprintf( /* translators: npm command. */ - esc_html__( 'The Web Vitals library is missing. Please do "%s" to finish installing the plugin.', 'performance-lab' ), - 'npm install && npm run build' + __( 'The Web Vitals library is missing. Please do "%s" to finish installing the plugin.', 'performance-lab' ), + 'npm install && npm run build' ) ); } diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index 0593a7d03c..cfae3bfff0 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -48,7 +48,7 @@ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_ 'urlMetricsNonce' => ilo_get_url_metrics_storage_nonce( $slug ), 'neededMinimumViewportWidths' => $needed_minimum_viewport_widths, 'storageLockTTL' => ilo_get_url_metric_storage_lock_ttl(), - 'webVitalsLibrarySrc' => esc_url( $web_vitals_lib_src ), + 'webVitalsLibrarySrc' => $web_vitals_lib_src, ); return wp_get_inline_script_tag( From 4675b7895502eff7d523df94c478134028cf45bd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 31 Jan 2024 10:09:32 -0800 Subject: [PATCH 225/371] Fix tag processor for compatibility with WP 6.5 --- .../class-ilo-html-tag-processor.php | 44 +++++++++++++++++-- .../class-ilo-html-tag-processor-tests.php | 21 ++++++--- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 0a69d25c2c..8e0307ca26 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -46,6 +46,38 @@ final class ILO_HTML_Tag_Processor { 'WBR', ); + /** + * Raw text tags. + * + * These are treated like void tags for the purposes of walking over the document since we do not process any text + * nodes. To cite the docblock for WP_HTML_Tag_Processor: + * + * > Some HTML elements are handled in a special way; their start and end tags + * > act like a void tag. These are special because their contents can't contain + * > HTML markup. Everything inside these elements is handled in a special way + * > and content that _appears_ like HTML tags inside of them isn't. There can + * > be no nesting in these elements. + * > + * > In the following list, "raw text" means that all of the content in the HTML + * > until the matching closing tag is treated verbatim without any replacements + * > and without any parsing. + * + * @link https://github.com/WordPress/wordpress-develop/blob/6dd00b1ffac54c20c1c1c7721aeebbcd82d0e378/src/wp-includes/html-api/class-wp-html-tag-processor.php#L136-L155 + * @link https://core.trac.wordpress.org/ticket/60392#comment:2 + * + * @var string[] + */ + const RAW_TEXT_TAGS = array( + 'SCRIPT', + 'IFRAME', + 'NOEMBED', // Deprecated. + 'NOFRAMES', // Deprecated. + 'STYLE', + 'TEXTAREA', + 'TITLE', + 'XMP', // Deprecated. + ); + /** * The set of HTML tags whose presence will implicitly close a

      element. * For example '

      foo

      bar

      ' should parse the same as '

      foo

      bar

      '. @@ -192,17 +224,23 @@ public function open_tags(): Generator { // Other mutations may be performed to the open tag's attributes by the callee at this point as well. yield $tag_name; - // Immediately pop off self-closing tags. + // Immediately pop off self-closing and raw text tags. if ( in_array( $tag_name, self::VOID_TAGS, true ) || + in_array( $tag_name, self::RAW_TEXT_TAGS, true ) + || ( $p->has_self_closing_flag() && $this->is_foreign_element() ) ) { array_pop( $this->open_stack_tags ); } } else { - // If the closing tag is for self-closing tag, we ignore it since it was already handled above. - if ( in_array( $tag_name, self::VOID_TAGS, true ) ) { + // If the closing tag is for self-closing or raw text tag, we ignore it since it was already handled above. + if ( + in_array( $tag_name, self::VOID_TAGS, true ) + || + in_array( $tag_name, self::RAW_TEXT_TAGS, true ) + ) { continue; } diff --git a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php index 6cd0c7bbbe..e288211aa5 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php @@ -18,28 +18,37 @@ public function data_provider_sample_documents(): array { Foo + + +

      Foo!
      Foo

      +
      The end!
      ', - 'open_tags' => array( 'HTML', 'HEAD', 'META', 'TITLE', 'BODY', 'P', 'BR', 'IMG', 'FOOTER' ), + 'open_tags' => array( 'HTML', 'HEAD', 'META', 'TITLE', 'SCRIPT', 'STYLE', 'BODY', 'IFRAME', 'P', 'BR', 'IMG', 'FORM', 'TEXTAREA', 'FOOTER' ), 'xpaths' => array( '/*[0][self::HTML]', '/*[0][self::HTML]/*[0][self::HEAD]', '/*[0][self::HTML]/*[0][self::HEAD]/*[0][self::META]', '/*[0][self::HTML]/*[0][self::HEAD]/*[1][self::TITLE]', + '/*[0][self::HTML]/*[0][self::HEAD]/*[2][self::SCRIPT]', + '/*[0][self::HTML]/*[0][self::HEAD]/*[3][self::STYLE]', '/*[0][self::HTML]/*[1][self::BODY]', - '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]', - '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]/*[0][self::BR]', - '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::P]/*[1][self::IMG]', - '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::FOOTER]', + '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IFRAME]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]/*[0][self::BR]', + '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::P]/*[1][self::IMG]', + '/*[0][self::HTML]/*[1][self::BODY]/*[2][self::FORM]', + '/*[0][self::HTML]/*[1][self::BODY]/*[2][self::FORM]/*[0][self::TEXTAREA]', + '/*[0][self::HTML]/*[1][self::BODY]/*[3][self::FOOTER]', ), ), 'foreign-elements' => array( @@ -297,7 +306,7 @@ public function test_open_tags_and_get_xpath( string $document, array $open_tags } $this->assertSame( $actual_open_tags, $open_tags, "Expected list of open tags to match.\nSnapshot: " . $this->export_array_snapshot( $actual_open_tags, true ) ); - $this->assertSame( $actual_xpaths, $xpaths, "Expected list of XPaths to match.\nSnapshot:" . $this->export_array_snapshot( $actual_xpaths ) ); + $this->assertSame( $actual_xpaths, $xpaths, "Expected list of XPaths to match.\nSnapshot: " . $this->export_array_snapshot( $actual_xpaths ) ); } /** From 5c70c2590e8a32a3765ed62d162cb44099f4a3b0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Feb 2024 11:34:38 -0800 Subject: [PATCH 226/371] Fix test cleanup of REQUEST_URI --- .../optimization-tests.php | 9 ++++++++- .../storage/data-tests.php | 13 +++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index fcf8664fbc..0776bc74c9 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -8,9 +8,16 @@ class ILO_Optimization_Tests extends WP_UnitTestCase { + private $original_request_uri; + + public function set_up() { + $this->original_request_uri = $_SERVER['REQUEST_URI']; + parent::set_up(); + } + public function tear_down() { + $_SERVER['REQUEST_URI'] = $this->original_request_uri; parent::tear_down(); - unset( $_SERVER['REQUEST_URI'] ); } /** diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index d14d477405..043486e80d 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -8,11 +8,16 @@ class ILO_Storage_Data_Tests extends WP_UnitTestCase { + private $original_request_uri; + + public function set_up() { + $this->original_request_uri = $_SERVER['REQUEST_URI']; + parent::set_up(); + } + public function tear_down() { - unset( - $GLOBALS['wp_customize'], - $_SERVER['REQUEST_URI'] - ); + $_SERVER['REQUEST_URI'] = $this->original_request_uri; + unset( $GLOBALS['wp_customize'] ); parent::tear_down(); } From dbd29753c705ff74d8382748c2c372567ca22c98 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 6 Feb 2024 17:01:48 -0800 Subject: [PATCH 227/371] Move adminbar skipping wholly to server --- .../class-ilo-html-tag-processor.php | 73 ++++++++++++++----- .../detection/detect.js | 10 +-- .../class-ilo-html-tag-processor-tests.php | 30 +++++++- 3 files changed, 86 insertions(+), 27 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 8e0307ca26..3c48f5c730 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -7,9 +7,12 @@ */ /** - * Processor leveraging WP_HTML_Tag_Processor which gathers breadcrumbs which can be obtained as XPath while iterating the open_tags() generator. + * Processor leveraging WP_HTML_Tag_Processor which gathers breadcrumbs for computing XPaths while iterating the open_tags() generator. * * Eventually this class should be made largely obsolete once `WP_HTML_Processor` is fully implemented to support all HTML tags. + * Note that the admin bar is skipped over since its presence throws off the XPath indices and its presence is irrelevant for + * the purposes of optimizing normal frontend responses: a logged-in user with the admin bar showing should be able to gather + * URL metrics which can be used for logged-out visitors. * * @since n.e.x.t * @access private @@ -171,6 +174,8 @@ public function __construct( string $html ) { public function open_tags(): Generator { $p = $this->processor; + $inside_admin_bar_depth = 0; + /* * The keys for the following two arrays correspond to each other. Given the following document: * @@ -214,15 +219,20 @@ public function open_tags(): Generator { ++$this->open_stack_indices[ $level ]; } - // Only increment the tag index at this level only if it isn't the admin bar, since the presence of the - // admin bar can throw off the indices. - if ( 'DIV' === $tag_name && $p->get_attribute( 'id' ) === 'wpadminbar' ) { + // Decrement the index if this is the admin bar element so that it will be invisible in XPaths. + $is_admin_bar = ( 'DIV' === $tag_name && $p->get_attribute( 'id' ) === 'wpadminbar' ); + if ( $is_admin_bar ) { --$this->open_stack_indices[ $level ]; } - // Now that the breadcrumbs are constructed, yield the tag name so that they can be queried if desired. - // Other mutations may be performed to the open tag's attributes by the callee at this point as well. - yield $tag_name; + // Skip over the admin bar and its descendents. + if ( $is_admin_bar || $inside_admin_bar_depth > 0 ) { + ++$inside_admin_bar_depth; + } else { + // Now that the breadcrumbs are constructed, yield the tag name so that they can be queried if desired. + // Other mutations may be performed to the open tag's attributes by the callee at this point as well. + yield $tag_name; + } // Immediately pop off self-closing and raw text tags. if ( @@ -233,6 +243,9 @@ public function open_tags(): Generator { ( $p->has_self_closing_flag() && $this->is_foreign_element() ) ) { array_pop( $this->open_stack_tags ); + if ( $inside_admin_bar_depth > 0 ) { + --$inside_admin_bar_depth; + } } } else { // If the closing tag is for self-closing or raw text tag, we ignore it since it was already handled above. @@ -245,25 +258,51 @@ public function open_tags(): Generator { } $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 - ) + if ( $popped_tag_name !== $tag_name ) { + $this->warn( + 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 ) ); } + if ( $inside_admin_bar_depth > 0 ) { + --$inside_admin_bar_depth; + if ( 0 === $inside_admin_bar_depth && 'DIV' !== $tag_name ) { + $this->warn( + sprintf( + /* translators: 1: Current tag name, 2: Closing tag name DIV */ + __( 'Expected closing %1$s tag to rather be the closing %2$s tag for the admin bar.', 'performance-lab' ), + $tag_name, + 'DIV' + ) + ); + } + } + array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) + 1 ); } } } + /** + * Warns of bad markup. + * + * @param string $message Warning message. + */ + private function warn( string $message ) { + if ( ! function_exists( 'wp_trigger_error' ) ) { + return; + } + wp_trigger_error( + __CLASS__ . '::open_tags', + esc_html( $message ) + ); + } + /** * Gets breadcrumbs for the current open tag. * diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index b4ffcdd6a6..7d177bd44f 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -7,8 +7,6 @@ const consoleLogPrefix = '[Image Loading Optimization]'; const storageLockTimeSessionKey = 'iloStorageLockTime'; -const adminBarId = 'wpadminbar'; - /** * Checks whether storage is locked. * @@ -199,10 +197,6 @@ export default async function detect( { log( 'Proceeding with detection' ); } - // Obtain the admin bar element because we don't want to detect elements inside of it. - const adminBar = - /** @type {?HTMLDivElement} */ doc.getElementById( adminBarId ); - // TODO: This query no longer needs to be done as early as possible since the server is adding the breadcrumbs. const breadcrumbedElements = doc.body.querySelectorAll( '[data-ilo-xpath]' ); @@ -260,9 +254,7 @@ export default async function detect( { ); for ( const element of breadcrumbedElementsMap.keys() ) { - if ( ! adminBar || ! adminBar.contains( element ) ) { - intersectionObserver.observe( element ); - } + intersectionObserver.observe( element ); } } ); diff --git a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php index e288211aa5..b4786d9423 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php @@ -9,8 +9,19 @@ */ class ILO_HTML_Tag_Processor_Tests extends WP_UnitTestCase { + private function get_admin_bar_markup(): string { + return << + + Log Out +
      +HTML; + } + public function data_provider_sample_documents(): array { - return array( + $datasets = array( 'well-formed-html' => array( 'document' => ' @@ -285,6 +296,23 @@ public function data_provider_sample_documents(): array { ), ), ); + + $all_datasets = array(); + foreach ( $datasets as $key => $dataset ) { + $all_datasets[ $key ] = $dataset; + + // Inject the admin bar at the beginning of the body. + $dataset['document'] = preg_replace( + '#]*>#', + '$0' . $this->get_admin_bar_markup(), + $dataset['document'] + ); + + // Add the admin bar variant as a new dataset. + $all_datasets[ "$key-with-adminbar" ] = $dataset; + } + + return $all_datasets; } /** From 245d41ca58a8a10ad313e2b0f99395366109d02c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 6 Feb 2024 17:18:22 -0800 Subject: [PATCH 228/371] Inject Customizer support script at wp_body_open to fail tests --- .../class-ilo-html-tag-processor-tests.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php index b4786d9423..de2bcd5eed 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php @@ -301,10 +301,10 @@ public function data_provider_sample_documents(): array { foreach ( $datasets as $key => $dataset ) { $all_datasets[ $key ] = $dataset; - // Inject the admin bar at the beginning of the body. + // Inject the admin bar at the beginning of the body and the Customizer support script. $dataset['document'] = preg_replace( '#]*>#', - '$0' . $this->get_admin_bar_markup(), + '$0' . get_echo( 'wp_customize_support_script' ) . $this->get_admin_bar_markup(), $dataset['document'] ); From aae51ad4cedd603c8ce11e3286dae38614aefcfd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 6 Feb 2024 21:23:09 -0800 Subject: [PATCH 229/371] Undo admin bar skipping, vary by user logged-in, and disable for admin users --- .../class-ilo-html-tag-processor.php | 39 ++----------------- .../optimization.php | 16 ++++---- .../storage/data.php | 5 +++ .../class-ilo-html-tag-processor-tests.php | 30 +------------- .../optimization-tests.php | 23 ++++++++++- .../storage/data-tests.php | 7 ++++ 6 files changed, 46 insertions(+), 74 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php index 3c48f5c730..f76278574c 100644 --- a/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php +++ b/modules/images/image-loading-optimization/class-ilo-html-tag-processor.php @@ -10,9 +10,6 @@ * Processor leveraging WP_HTML_Tag_Processor which gathers breadcrumbs for computing XPaths while iterating the open_tags() generator. * * Eventually this class should be made largely obsolete once `WP_HTML_Processor` is fully implemented to support all HTML tags. - * Note that the admin bar is skipped over since its presence throws off the XPath indices and its presence is irrelevant for - * the purposes of optimizing normal frontend responses: a logged-in user with the admin bar showing should be able to gather - * URL metrics which can be used for logged-out visitors. * * @since n.e.x.t * @access private @@ -174,8 +171,6 @@ public function __construct( string $html ) { public function open_tags(): Generator { $p = $this->processor; - $inside_admin_bar_depth = 0; - /* * The keys for the following two arrays correspond to each other. Given the following document: * @@ -219,20 +214,9 @@ public function open_tags(): Generator { ++$this->open_stack_indices[ $level ]; } - // Decrement the index if this is the admin bar element so that it will be invisible in XPaths. - $is_admin_bar = ( 'DIV' === $tag_name && $p->get_attribute( 'id' ) === 'wpadminbar' ); - if ( $is_admin_bar ) { - --$this->open_stack_indices[ $level ]; - } - - // Skip over the admin bar and its descendents. - if ( $is_admin_bar || $inside_admin_bar_depth > 0 ) { - ++$inside_admin_bar_depth; - } else { - // Now that the breadcrumbs are constructed, yield the tag name so that they can be queried if desired. - // Other mutations may be performed to the open tag's attributes by the callee at this point as well. - yield $tag_name; - } + // Now that the breadcrumbs are constructed, yield the tag name so that they can be queried if desired. + // Other mutations may be performed to the open tag's attributes by the callee at this point as well. + yield $tag_name; // Immediately pop off self-closing and raw text tags. if ( @@ -243,9 +227,6 @@ public function open_tags(): Generator { ( $p->has_self_closing_flag() && $this->is_foreign_element() ) ) { array_pop( $this->open_stack_tags ); - if ( $inside_admin_bar_depth > 0 ) { - --$inside_admin_bar_depth; - } } } else { // If the closing tag is for self-closing or raw text tag, we ignore it since it was already handled above. @@ -269,20 +250,6 @@ public function open_tags(): Generator { ); } - if ( $inside_admin_bar_depth > 0 ) { - --$inside_admin_bar_depth; - if ( 0 === $inside_admin_bar_depth && 'DIV' !== $tag_name ) { - $this->warn( - sprintf( - /* translators: 1: Current tag name, 2: Closing tag name DIV */ - __( 'Expected closing %1$s tag to rather be the closing %2$s tag for the admin bar.', 'performance-lab' ), - $tag_name, - 'DIV' - ) - ); - } - } - array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) + 1 ); } } diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 19546fb1f9..e9dbc719cb 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -31,10 +31,6 @@ function 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 * @@ -42,12 +38,18 @@ function ilo_maybe_add_template_output_buffer_filter() { */ function ilo_can_optimize_response(): bool { $able = ! ( - // Since the URL space is infinite. + // Since 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 wouldn't be an issue. 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'] + // Since the images detected in the response body of a POST request cannot, by definition, be cached. + 'GET' !== $_SERVER['REQUEST_METHOD'] || + // The aim is to optimize pages for the majority of site visitors, not those who administer the site. For admin + // users, additional elements will be present like the script from wp_customize_support_script() which will + // interfere with the XPath indices. Note that ilo_get_normalized_query_vars() is varied by is_user_logged_in() + // so membership sites and e-commerce sites will still be able to be optimized for their normal visitors. + current_user_can( 'customize' ) ); /** diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 09840b2053..7ec538099d 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -57,6 +57,11 @@ function ilo_get_normalized_query_vars(): array { ); } + // Vary URL metrics by whether the user is logged-in since additional elements may be present. + if ( is_user_logged_in() ) { + $normalized_query_vars['user_logged_in'] = true; + } + return $normalized_query_vars; } diff --git a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php index de2bcd5eed..e288211aa5 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php @@ -9,19 +9,8 @@ */ class ILO_HTML_Tag_Processor_Tests extends WP_UnitTestCase { - private function get_admin_bar_markup(): string { - return << - - Log Out - -HTML; - } - public function data_provider_sample_documents(): array { - $datasets = array( + return array( 'well-formed-html' => array( 'document' => ' @@ -296,23 +285,6 @@ public function data_provider_sample_documents(): array { ), ), ); - - $all_datasets = array(); - foreach ( $datasets as $key => $dataset ) { - $all_datasets[ $key ] = $dataset; - - // Inject the admin bar at the beginning of the body and the Customizer support script. - $dataset['document'] = preg_replace( - '#]*>#', - '$0' . get_echo( 'wp_customize_support_script' ) . $this->get_admin_bar_markup(), - $dataset['document'] - ); - - // Add the admin bar variant as a new dataset. - $all_datasets[ "$key-with-adminbar" ] = $dataset; - } - - return $all_datasets; } /** diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 0776bc74c9..c9a09ba979 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -10,13 +10,18 @@ class ILO_Optimization_Tests extends WP_UnitTestCase { private $original_request_uri; + private $original_request_method; + public function set_up() { - $this->original_request_uri = $_SERVER['REQUEST_URI']; + $this->original_request_uri = $_SERVER['REQUEST_URI']; + $this->original_request_method = $_SERVER['REQUEST_METHOD']; parent::set_up(); } public function tear_down() { - $_SERVER['REQUEST_URI'] = $this->original_request_uri; + $_SERVER['REQUEST_URI'] = $this->original_request_uri; + $_SERVER['REQUEST_METHOD'] = $this->original_request_method; + unset( $GLOBALS['wp_customize'] ); parent::tear_down(); } @@ -85,6 +90,20 @@ public function data_provider_test_ilo_can_optimize_response(): array { }, 'expected' => false, ), + 'subscriber_user' => array( + 'set_up' => function () { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + $this->go_to( home_url( '/' ) ); + }, + 'expected' => true, + ), + 'admin_user' => array( + 'set_up' => function () { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $this->go_to( home_url( '/' ) ); + }, + 'expected' => false, + ), ); } diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 043486e80d..23676bcb25 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -88,6 +88,13 @@ public function data_provider_test_ilo_get_normalized_query_vars(): array { return array( 'error' => 404 ); }, ), + 'logged-in' => array( + 'set_up' => function () { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + $this->go_to( home_url( '/' ) ); + return array( 'user_logged_in' => true ); + }, + ), ); } From 18feefb4a29f2148a5cee341051e450a61e60879 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Feb 2024 14:19:24 -0800 Subject: [PATCH 230/371] Prevent processing images with data: URLs or no srcs --- .../optimization.php | 28 ++++++--- .../optimization-tests.php | 58 +++++++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 19546fb1f9..1a167a7114 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -185,8 +185,13 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // Walk over all IMG tags in the document and ensure fetchpriority is set/removed, and gather IMG attributes for preloading. $processor = new ILO_HTML_Tag_Processor( $buffer ); foreach ( $processor->open_tags() as $tag_name ) { - $is_img_tag = ( 'IMG' === $tag_name ); - $style = $processor->get_attribute( 'style' ); + $is_img_tag = ( + 'IMG' === $tag_name + && + $processor->get_attribute( 'src' ) + && + ! str_starts_with( $processor->get_attribute( 'src' ), 'data:' ) + ); /* * Note that CSS allows for a `background`/`background-image` to have multiple `url()` CSS functions, resulting @@ -196,12 +201,19 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { * images, this logic would need to be modified to make $background_image an array and to have a more robust * parser of the `url()` functions from the property value. */ - $background_image = null; - if ( $style && preg_match( '/background(-image)?\s*:[^;]*?url\(\s*[\'"]?(?!data:)(?.+?)[\'"]?\s*\)/', $style, $matches ) ) { - $background_image = $matches['background_image']; + $background_image_url = null; + $style = $processor->get_attribute( 'style' ); + if ( + $style + && + preg_match( '/background(-image)?\s*:[^;]*?url\(\s*[\'"]?(?.+?)[\'"]?\s*\)/', $style, $matches ) + && + ! str_starts_with( $matches['background_image'], 'data:' ) + ) { + $background_image_url = $matches['background_image']; } - if ( ! ( $is_img_tag || $background_image ) ) { + if ( ! ( $is_img_tag || $background_image_url ) ) { continue; } @@ -246,9 +258,9 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] as $minimum_viewport_width ) { $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['img_attributes'] = $img_attributes; } - } elseif ( $background_image ) { + } elseif ( $background_image_url ) { foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] as $minimum_viewport_width ) { - $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['background_image'] = $background_image; + $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['background_image'] = $background_image_url; } } } diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 0776bc74c9..f4d0ba0b71 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -312,6 +312,64 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array ', ), + 'no-url-metrics-with-data-url-image' => array( + 'set_up' => static function () {}, + // Smallest PNG courtesy of . + 'buffer' => ' + + + + ... + + + + + + ', + // There should be no data-ilo-xpath added to the IMG because it is using a data: URL. + 'expected' => ' + + + + ... + + + + + + + ', + ), + + 'no-url-metrics-for-image-without-src' => array( + 'set_up' => static function () {}, + 'buffer' => ' + + + + ... + + + + + + + ', + 'expected' => ' + + + + ... + + + + + + + + ', + ), + 'common-lcp-image-with-fully-populated-sample-data' => array( 'set_up' => function () { $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); From 7d4696e64be2655e603a13a06e8ea914fc274310 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Feb 2024 12:36:36 -0800 Subject: [PATCH 231/371] Hyphenate "logged in" Co-authored-by: Adam Silverstein --- modules/images/image-loading-optimization/storage/data.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 7ec538099d..31a5ebacc6 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -57,7 +57,7 @@ function ilo_get_normalized_query_vars(): array { ); } - // Vary URL metrics by whether the user is logged-in since additional elements may be present. + // Vary URL metrics by whether the user is logged in since additional elements may be present. if ( is_user_logged_in() ) { $normalized_query_vars['user_logged_in'] = true; } From 0eae77a74dda9b9fe9c9627acd4cc02fffbe85ba Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Feb 2024 15:01:32 -0800 Subject: [PATCH 232/371] Add failing test case to demonstrate erroneous preload link insertion for stale URL Metrics --- .../optimization-tests.php | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index ee6ee7fe1b..83bd72ea9f 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -442,6 +442,55 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array ', ), + 'common-lcp-image-with-stale-sample-data' => array( + 'set_up' => function () { + $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + foreach ( array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + ilo_store_url_metric( + home_url( '/' ), + $slug, + $this->get_validated_url_metric( + $viewport_width, + array( + array( + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + 'isLCP' => true, + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + + + Foo + + + ', + // The preload link should be absent because the URL Metrics were collected before the script was printed at wp_body_open, causing the XPath to no longer be valid. + 'expected' => ' + + + + ... + + + + Foo + + + ', + ), + 'common-lcp-background-image-with-fully-populated-sample-data' => array( 'set_up' => function () { $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); From 3b3b568cce86b62d597897458ecb4c8981542a6b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Feb 2024 12:45:30 -0800 Subject: [PATCH 233/371] Prevent printing preload links for missing elements --- .../image-loading-optimization/optimization.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 80b83621f8..85ce8d9038 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -68,7 +68,7 @@ function ilo_can_optimize_response(): bool { * @since n.e.x.t * @access private * - * @param array $lcp_elements_by_minimum_viewport_widths LCP images keyed by minimum viewport width, amended with attributes key for the IMG attributes. + * @param array $lcp_elements_by_minimum_viewport_widths LCP elements keyed by minimum viewport width, amended with element details. * @return string Markup for zero or more preload link tags. */ function ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_widths ): string { @@ -99,6 +99,11 @@ function ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_wi } } + // Skip constructing a link if it is missing required attributes. + if ( empty( $link_attributes['href'] ) && empty( $link_attributes['imagesrcset'] ) ) { + continue; + } + // Add media query if it's going to be something other than just `min-width: 0px`. $minimum_viewport_width = $minimum_viewport_widths[ $i ]; $maximum_viewport_width = isset( $minimum_viewport_widths[ $i + 1 ] ) ? $minimum_viewport_widths[ $i + 1 ] - 1 : null; @@ -184,7 +189,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $common_lcp_element = null; } - // Walk over all IMG tags in the document and ensure fetchpriority is set/removed, and gather IMG attributes for preloading. + // Walk over all tags in the document and ensure fetchpriority is set/removed, and gather IMG attributes or background-image for preloading. $processor = new ILO_HTML_Tag_Processor( $buffer ); foreach ( $processor->open_tags() as $tag_name ) { $is_img_tag = ( @@ -248,7 +253,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // 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 ] ) ) { + if ( isset( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] ) && ( $is_img_tag || $background_image_url ) ) { if ( $is_img_tag ) { $img_attributes = array(); foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin' ) as $attr_name ) { From 13453a5063e4fb583ab8477c6dfba3e33eb37567 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Feb 2024 15:26:49 -0800 Subject: [PATCH 234/371] Add test for different LCP elements for two non-consecutive breakpoints --- .../optimization-tests.php | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 83bd72ea9f..a35796b7ca 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -764,6 +764,112 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ', ), + 'different-lcp-elements-for-two-non-consecutive-breakpoints' => array( + 'set_up' => function () { + add_filter( + 'ilo_breakpoint_max_widths', + static function () { + return array( 480, 600, 782 ); + } + ); + + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 400, + array( + array( + 'isLCP' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + ), + ) + ) + ); + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 500, + array( + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + ), + ) + ) + ); + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 700, + array( + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + array( + 'isLCP' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + ), + ) + ) + ); + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 800, + array( + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + + Mobile Logo + Desktop Logo + + + ', + 'expected' => ' + + + + ... + + + + + + Mobile Logo + Desktop Logo + + + ', + ), ); } From 3b52621be5401d9c767f6ef74c0d811bfbc8df79 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Feb 2024 17:29:27 -0800 Subject: [PATCH 235/371] Prevent attempting to preload LCP elements that no longer exist --- .../optimization.php | 32 +++++- .../optimization-tests.php | 108 ++++++++++++++++++ 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 85ce8d9038..8d5f4c941a 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -165,7 +165,11 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics_grouped_by_breakpoint ); $all_breakpoints_have_url_metrics = count( array_filter( $url_metrics_grouped_by_breakpoint ) ) === count( $breakpoint_max_widths ) + 1; - // Optimize looking up the LCP element by XPath. + /** + * Optimized lookup of the LCP element for a viewport width by XPath. + * + * @var array $lcp_element_minimum_viewport_width_by_xpath + */ $lcp_element_minimum_viewport_width_by_xpath = array(); foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { if ( false !== $lcp_element ) { @@ -189,6 +193,16 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $common_lcp_element = null; } + /** + * Mapping of XPath to true to indicate whether the element was found in the document. + * + * After processing through the entire document, only the elements which were actually found in the document can get + * preload links. + * + * @var array $detected_lcp_element_xpaths + */ + $detected_lcp_element_xpaths = array(); + // Walk over all tags in the document and ensure fetchpriority is set/removed, and gather IMG attributes or background-image for preloading. $processor = new ILO_HTML_Tag_Processor( $buffer ); foreach ( $processor->open_tags() as $tag_name ) { @@ -253,7 +267,9 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // 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 ] ) && ( $is_img_tag || $background_image_url ) ) { + if ( isset( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] ) ) { + $detected_lcp_element_xpaths[ $xpath ] = true; + if ( $is_img_tag ) { $img_attributes = array(); foreach ( array( 'src', 'srcset', 'sizes', 'crossorigin' ) as $attr_name ) { @@ -278,12 +294,22 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { } $buffer = $processor->get_updated_html(); + // If there were any LCP elements captured in URL Metrics that no longer exist in the document, we need to behave as + // if they didn't exist in the first place as there is nothing that can be preloaded. + foreach ( array_keys( $lcp_element_minimum_viewport_width_by_xpath ) as $xpath ) { + if ( empty( $detected_lcp_element_xpaths[ $xpath ] ) ) { + foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] as $minimum_viewport_width ) { + $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ] = false; + } + } + } + // Inject any preload links at the end of the HEAD. In the future, WP_HTML_Processor could be used to do this injection. // However, given the simple replacement here this is not essential. $head_injection = ilo_construct_preload_links( $lcp_elements_by_minimum_viewport_widths ); // Inject detection script. - // TODO: When optimizing above, if we find that there is a stored LCP element but it fails to match, it should perhaps set $needs_detection to true and send the request with an override nonce. + // TODO: When optimizing above, if we find that there is a stored LCP element but it fails to match, it should perhaps set $needs_detection to true and send the request with an override nonce. However, this would require backtracking and adding the data-ilo-xpath attributes. if ( $needs_detection ) { $head_injection .= ilo_get_detection_script( $slug, $needed_minimum_viewport_widths ); } diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index a35796b7ca..d3ad89f58b 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -870,6 +870,114 @@ static function () { ', ), + + 'different-lcp-elements-for-two-non-consecutive-breakpoints-and-one-is-stale' => array( + 'set_up' => function () { + add_filter( + 'ilo_breakpoint_max_widths', + static function () { + return array( 480, 600, 782 ); + } + ); + + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 500, + array( + array( + 'isLCP' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + ), + ) + ) + ); + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 650, + array( + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + ), + ) + ) + ); + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 800, + array( + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + array( + 'isLCP' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + ), + ) + ) + ); + ilo_store_url_metric( + home_url( '/' ), + ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + $this->get_validated_url_metric( + 800, + array( + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + ), + array( + 'isLCP' => false, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[1][self::IMG]', + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + + Mobile Logo +

      New paragraph since URL Metrics were captured!

      + Desktop Logo + + + ', + 'expected' => ' + + + + ... + + + + + Mobile Logo +

      New paragraph since URL Metrics were captured!

      + Desktop Logo + + + ', + ), ); } From aed0a6def4685383c6289ed84b4fb0de76831015 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Feb 2024 17:30:40 -0800 Subject: [PATCH 236/371] Emit _doing_it_wrong() if attempting to construct preload link without href or imagesrcset --- modules/images/image-loading-optimization/optimization.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 8d5f4c941a..a0b9fa9e23 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -101,6 +101,13 @@ function ilo_construct_preload_links( array $lcp_elements_by_minimum_viewport_wi // Skip constructing a link if it is missing required attributes. if ( empty( $link_attributes['href'] ) && empty( $link_attributes['imagesrcset'] ) ) { + _doing_it_wrong( + __FUNCTION__, + esc_html( + __( 'Attempted to construct preload link without an available href or imagesrcset. Supplied LCP element: ', 'performance-lab' ) . wp_json_encode( $lcp_element ) + ), + '' + ); continue; } From 3c925aa6ef155523c3611d5a8254de064c23fc1b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Feb 2024 17:36:32 -0800 Subject: [PATCH 237/371] Fix variable name by using plural --- .../optimization.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index a0b9fa9e23..9d935f7e94 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -173,14 +173,14 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $all_breakpoints_have_url_metrics = count( array_filter( $url_metrics_grouped_by_breakpoint ) ) === count( $breakpoint_max_widths ) + 1; /** - * Optimized lookup of the LCP element for a viewport width by XPath. + * Optimized lookup of the LCP element viewport widths by XPath. * - * @var array $lcp_element_minimum_viewport_width_by_xpath + * @var array $lcp_element_minimum_viewport_widths_by_xpath */ - $lcp_element_minimum_viewport_width_by_xpath = array(); + $lcp_element_minimum_viewport_widths_by_xpath = array(); foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { if ( false !== $lcp_element ) { - $lcp_element_minimum_viewport_width_by_xpath[ $lcp_element['xpath'] ][] = $minimum_viewport_width; + $lcp_element_minimum_viewport_widths_by_xpath[ $lcp_element['xpath'] ][] = $minimum_viewport_width; } } @@ -274,7 +274,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // 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 ] ) ) { + if ( isset( $lcp_element_minimum_viewport_widths_by_xpath[ $xpath ] ) ) { $detected_lcp_element_xpaths[ $xpath ] = true; if ( $is_img_tag ) { @@ -285,11 +285,11 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $img_attributes[ $attr_name ] = $value; } } - foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] as $minimum_viewport_width ) { + foreach ( $lcp_element_minimum_viewport_widths_by_xpath[ $xpath ] as $minimum_viewport_width ) { $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['img_attributes'] = $img_attributes; } } elseif ( $background_image_url ) { - foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] as $minimum_viewport_width ) { + foreach ( $lcp_element_minimum_viewport_widths_by_xpath[ $xpath ] as $minimum_viewport_width ) { $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ]['background_image'] = $background_image_url; } } @@ -303,9 +303,9 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // If there were any LCP elements captured in URL Metrics that no longer exist in the document, we need to behave as // if they didn't exist in the first place as there is nothing that can be preloaded. - foreach ( array_keys( $lcp_element_minimum_viewport_width_by_xpath ) as $xpath ) { + foreach ( array_keys( $lcp_element_minimum_viewport_widths_by_xpath ) as $xpath ) { if ( empty( $detected_lcp_element_xpaths[ $xpath ] ) ) { - foreach ( $lcp_element_minimum_viewport_width_by_xpath[ $xpath ] as $minimum_viewport_width ) { + foreach ( $lcp_element_minimum_viewport_widths_by_xpath[ $xpath ] as $minimum_viewport_width ) { $lcp_elements_by_minimum_viewport_widths[ $minimum_viewport_width ] = false; } } From 3a0d862c5d774377428f1e8d2650a1507a07a869 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 9 Feb 2024 10:50:17 -0800 Subject: [PATCH 238/371] Wait until idle before starting detection --- .../detection/detect.js | 66 +++++++++++-------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 7d177bd44f..0cb5ac155b 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -175,20 +175,45 @@ export default async function detect( { return; } - // Prevent detection when page is not scrolled to the initial viewport. - // TODO: Does this cause layout/reflow? https://gist.github.com/paulirish/5d52fb081b3570c81e3a - if ( doc.documentElement.scrollTop > 0 ) { + // Abort if the current viewport is not among those which need URL metrics. + if ( ! isViewportNeeded( win.innerWidth, neededMinimumViewportWidths ) ) { if ( isDebug ) { - warn( - 'Aborted detection since initial scroll position of page is not at the top.' - ); + log( 'No need for URL metrics from the current viewport.' ); } return; } - if ( ! isViewportNeeded( win.innerWidth, neededMinimumViewportWidths ) ) { + // Ensure the DOM is loaded (although it surely already is since we're executing in a module). + await new Promise( ( resolve ) => { + if ( doc.readyState !== 'loading' ) { + resolve(); + } else { + doc.addEventListener( 'DOMContentLoaded', resolve, { once: true } ); + } + } ); + + // Wait until the resources on the page have fully loaded. + await new Promise( ( resolve ) => { + if ( doc.readyState === 'complete' ) { + resolve(); + } else { + win.addEventListener( 'load', resolve, { once: true } ); + } + } ); + + // Wait yet further until idle. + if ( typeof requestIdleCallback === 'function' ) { + await new Promise( ( resolve ) => { + requestIdleCallback( resolve ); + } ); + } + + // Prevent detection when page is not scrolled to the initial viewport. + if ( doc.documentElement.scrollTop > 0 ) { if ( isDebug ) { - log( 'No need for URL metrics from the current viewport.' ); + warn( + 'Aborted detection since initial scroll position of page is not at the top.' + ); } return; } @@ -197,7 +222,6 @@ export default async function detect( { log( 'Proceeding with detection' ); } - // TODO: This query no longer needs to be done as early as possible since the server is adding the breadcrumbs. const breadcrumbedElements = doc.body.querySelectorAll( '[data-ilo-xpath]' ); @@ -212,15 +236,6 @@ export default async function detect( { ) ); - // Ensure the DOM is loaded (although it surely already is since we're executing in a module). - await new Promise( ( resolve ) => { - if ( doc.readyState !== 'loading' ) { - resolve(); - } else { - doc.addEventListener( 'DOMContentLoaded', resolve, { once: true } ); - } - } ); - /** @type {IntersectionObserverEntry[]} */ const elementIntersections = []; @@ -286,15 +301,6 @@ export default async function detect( { ); } ); - // Wait until the resources on the page have fully loaded. - await new Promise( ( resolve ) => { - if ( doc.readyState === 'complete' ) { - resolve(); - } else { - win.addEventListener( 'load', resolve, { once: true } ); - } - } ); - // Stop observing. disconnectIntersectionObserver(); if ( isDebug ) { @@ -348,7 +354,11 @@ export default async function detect( { log( 'URL metrics:', urlMetrics ); } - // TODO: Wait until idle? Yield to main? + // Yield to main before sending data to server to further break up task. + await new Promise( ( resolve ) => { + setTimeout( resolve, 0 ); + } ); + try { const response = await fetch( restApiEndpoint, { method: 'POST', From 7d3a3ea65907322824864aa5140151ac29aea04b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 9 Feb 2024 11:11:21 -0800 Subject: [PATCH 239/371] Move isStorageLocked check after idle since sessionStorage is synchronous --- .../detection/detect.js | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 0cb5ac155b..1b56552e44 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -155,16 +155,6 @@ export default async function detect( { } ) { const currentTime = getCurrentTime(); - // As an alternative to this, the ilo_print_detection_script() function can short-circuit if the - // ilo_is_url_metric_storage_locked() function returns true. However, the downside with that is page caching could - // result in metrics being missed being gathered when a user navigates around a site and primes the page cache. - if ( isStorageLocked( currentTime, storageLockTTL ) ) { - if ( isDebug ) { - warn( 'Aborted detection due to storage being locked.' ); - } - return; - } - // Abort running detection logic if it was served in a cached page. if ( currentTime - serveTime > detectionTimeWindow ) { if ( isDebug ) { @@ -208,6 +198,16 @@ export default async function detect( { } ); } + // As an alternative to this, the ilo_print_detection_script() function can short-circuit if the + // ilo_is_url_metric_storage_locked() function returns true. However, the downside with that is page caching could + // result in metrics being missed being gathered when a user navigates around a site and primes the page cache. + if ( isStorageLocked( currentTime, storageLockTTL ) ) { + if ( isDebug ) { + warn( 'Aborted detection due to storage being locked.' ); + } + return; + } + // Prevent detection when page is not scrolled to the initial viewport. if ( doc.documentElement.scrollTop > 0 ) { if ( isDebug ) { From dcdce432d9819485810ab40efdd728c150fd063f Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Fri, 9 Feb 2024 14:29:18 -0700 Subject: [PATCH 240/371] Update modules/images/image-loading-optimization/detection/detect.js Co-authored-by: Weston Ruter --- modules/images/image-loading-optimization/detection/detect.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 1b56552e44..6284b7d0a5 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -200,7 +200,7 @@ export default async function detect( { // As an alternative to this, the ilo_print_detection_script() function can short-circuit if the // ilo_is_url_metric_storage_locked() function returns true. However, the downside with that is page caching could - // result in metrics being missed being gathered when a user navigates around a site and primes the page cache. + // result in metrics missed from being gathered when a user navigates around a site and primes the page cache. if ( isStorageLocked( currentTime, storageLockTTL ) ) { if ( isDebug ) { warn( 'Aborted detection due to storage being locked.' ); From 0e652fedee5ba9227ce5559d0903a6f6e8e2c813 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 14 Feb 2024 09:55:26 -0800 Subject: [PATCH 241/371] Add ILO_URL_Metric to contain JSON schema --- .../class-ilo-url-metric.php | 94 +++++++++++++ .../image-loading-optimization/load.php | 1 + .../storage/rest-api.php | 127 ++++++------------ 3 files changed, 133 insertions(+), 89 deletions(-) create mode 100644 modules/images/image-loading-optimization/class-ilo-url-metric.php diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php new file mode 100644 index 0000000000..2f7538b116 --- /dev/null +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -0,0 +1,94 @@ + 'object', + 'properties' => array( + 'width' => array( + 'type' => 'number', + 'minimum' => 0, + ), + 'height' => array( + 'type' => 'number', + 'minimum' => 0, + ), + // TODO: There are other properties to define if we need them: x, y, top, right, bottom, left. + ), + ); + + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'ilo-url-metric', + 'type' => 'object', + 'properties' => array( + 'viewport' => array( + 'description' => __( 'Viewport dimensions', 'performance-lab' ), + 'type' => 'object', + 'required' => true, + 'properties' => array( + 'width' => array( + 'type' => 'integer', + 'required' => true, + 'minimum' => 0, + ), + 'height' => array( + 'type' => 'integer', + 'required' => true, + 'minimum' => 0, + ), + ), + ), + 'elements' => array( + 'description' => __( 'Element metrics', 'performance-lab' ), + 'type' => 'array', + 'required' => true, + 'items' => array( + // See the ElementMetrics in detect.js. + 'type' => 'object', + 'properties' => array( + 'isLCP' => array( + 'type' => 'boolean', + 'required' => true, + ), + 'isLCPCandidate' => array( + 'type' => 'boolean', + ), + 'xpath' => array( + 'type' => 'string', + 'required' => true, + 'pattern' => ILO_HTML_Tag_Processor::XPATH_PATTERN, + ), + 'intersectionRatio' => array( + 'type' => 'number', + 'required' => true, + 'minimum' => 0.0, + 'maximum' => 1.0, + ), + 'intersectionRect' => $dom_rect_schema, + 'boundingClientRect' => $dom_rect_schema, + ), + ), + ), + ), + ); + } +} diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index 9a754b037a..73741da3eb 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -18,6 +18,7 @@ require_once __DIR__ . '/hooks.php'; // Storage logic. +require_once __DIR__ . '/class-ilo-url-metric.php'; require_once __DIR__ . '/storage/lock.php'; require_once __DIR__ . '/storage/post-type.php'; require_once __DIR__ . '/storage/data.php'; diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 7ce1b06c97..b2e3aee9de 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -37,21 +37,46 @@ */ function ilo_register_endpoint() { - $dom_rect_schema = array( - 'type' => 'object', - 'properties' => array( - 'width' => array( - 'type' => 'number', - 'minimum' => 0, - ), - 'height' => array( - 'type' => 'number', - 'minimum' => 0, - ), - // TODO: There are other properties to define if we need them: x, y, top, right, bottom, left. + $args = array( + 'url' => array( + 'type' => 'string', + 'description' => __( 'The URL for which the metric was obtained.', 'performance-lab' ), + 'required' => true, + 'format' => 'uri', + 'validate_callback' => static function ( $url ) { + if ( ! wp_validate_redirect( $url ) ) { + return new WP_Error( 'non_origin_url', __( 'URL for another site provided.', 'performance-lab' ) ); + } + // TODO: This is not validated as corresponding to the slug in any way. True it is not used for anything but metadata. + return true; + }, + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'An MD5 hash of the query args.', 'performance-lab' ), + 'required' => true, + 'pattern' => '^[0-9a-f]{32}$', + // This is validated via the nonce validate_callback, as it is provided as input to create the nonce by the server + // which then is verified to match in the REST API request. + ), + 'nonce' => array( + 'type' => 'string', + 'description' => __( 'Nonce originally computed by server required to authorize the request.', 'performance-lab' ), + 'required' => true, + 'pattern' => '^[0-9a-f]+$', + 'validate_callback' => static function ( $nonce, WP_REST_Request $request ) { + if ( ! ilo_verify_url_metrics_storage_nonce( $nonce, $request->get_param( 'slug' ) ) ) { + return new WP_Error( 'invalid_nonce', __( 'URL metrics nonce verification failure.', 'performance-lab' ) ); + } + return true; + }, ), ); + $schema = ILO_URL_Metric::get_json_schema(); + + $args = array_merge( $args, $schema['properties'] ); + register_rest_route( ILO_REST_API_NAMESPACE, ILO_URL_METRICS_ROUTE, @@ -71,83 +96,7 @@ function ilo_register_endpoint() { } return true; }, - 'args' => array( - 'url' => array( - 'type' => 'string', - 'required' => true, - 'format' => 'uri', - 'validate_callback' => static function ( $url ) { - if ( ! wp_validate_redirect( $url ) ) { - return new WP_Error( 'non_origin_url', __( 'URL for another site provided.', 'performance-lab' ) ); - } - return true; - }, - ), - 'slug' => array( - 'type' => 'string', - 'required' => true, - 'pattern' => '^[0-9a-f]{32}$', - ), - 'nonce' => array( - 'type' => 'string', - 'required' => true, - 'pattern' => '^[0-9a-f]+$', - 'validate_callback' => static function ( $nonce, WP_REST_Request $request ) { - if ( ! ilo_verify_url_metrics_storage_nonce( $nonce, $request->get_param( 'slug' ) ) ) { - return new WP_Error( 'invalid_nonce', __( 'URL metrics nonce verification failure.', 'performance-lab' ) ); - } - return true; - }, - ), - 'viewport' => array( - 'description' => __( 'Viewport dimensions', 'performance-lab' ), - 'type' => 'object', - 'required' => true, - 'properties' => array( - 'width' => array( - 'type' => 'integer', - 'required' => true, - 'minimum' => 0, - ), - 'height' => array( - 'type' => 'integer', - 'required' => true, - 'minimum' => 0, - ), - ), - ), - 'elements' => array( - 'description' => __( 'Element metrics', 'performance-lab' ), - 'type' => 'array', - 'required' => true, - 'items' => array( - // See the ElementMetrics in detect.js. - 'type' => 'object', - 'properties' => array( - 'isLCP' => array( - 'type' => 'boolean', - 'required' => true, - ), - 'isLCPCandidate' => array( - 'type' => 'boolean', - ), - 'xpath' => array( - 'type' => 'string', - 'required' => true, - 'pattern' => ILO_HTML_Tag_Processor::XPATH_PATTERN, - ), - 'intersectionRatio' => array( - 'type' => 'number', - 'required' => true, - 'minimum' => 0.0, - 'maximum' => 1.0, - ), - 'intersectionRect' => $dom_rect_schema, - 'boundingClientRect' => $dom_rect_schema, - ), - ), - ), - ), + 'args' => $args, ) ); } From 3c6b15376985bf35807ffe93171981e33b816776 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 14 Feb 2024 11:12:14 -0800 Subject: [PATCH 242/371] Begin to use ILO_URL_Metric as data container and rework setting timestamp --- .../class-ilo-url-metric.php | 90 ++++++++++++++++++- .../storage/data.php | 29 +++--- .../storage/post-type.php | 71 +++++++-------- .../storage/rest-api.php | 19 +++- .../storage/rest-api-tests.php | 6 +- 5 files changed, 150 insertions(+), 65 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index 2f7538b116..116ca480e8 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -9,10 +9,12 @@ /** * Representation of the measurements taken from a single client's visit to a specific URL. * + * @implements ArrayAccess + * * @since n.e.x.t * @access private */ -final class ILO_URL_Metric { +final class ILO_URL_Metric implements ArrayAccess, JsonSerializable { /** * Gets JSON schema for URL Metric. @@ -40,7 +42,7 @@ public static function get_json_schema(): array { 'title' => 'ilo-url-metric', 'type' => 'object', 'properties' => array( - 'viewport' => array( + 'viewport' => array( 'description' => __( 'Viewport dimensions', 'performance-lab' ), 'type' => 'object', 'required' => true, @@ -57,7 +59,14 @@ public static function get_json_schema(): array { ), ), ), - 'elements' => array( + 'timestamp' => array( + 'description' => __( 'Timestamp at which the URL metric was captured.', 'performance-lab' ), + 'type' => 'number', + 'readonly' => true, // Use the server-provided timestamp. + 'default' => microtime( true ), + 'minimum' => 0, + ), + 'elements' => array( 'description' => __( 'Element metrics', 'performance-lab' ), 'type' => 'array', 'required' => true, @@ -91,4 +100,79 @@ public static function get_json_schema(): array { ), ); } + + /** + * Validated data. + * + * @var array + */ + private $data; + + /** + * Constructor. + * + * @param array $data URL metric data. + * @param bool $validated Whether the data was already validated. + */ + public function __construct( array $data, bool $validated = false ) { + if ( ! $validated ) { + // TODO: Validate. + } + $this->data = $data; + } + + /** + * Checks if the offset exists. + * + * @param string $offset Offset. + * @return bool Whether exists. + */ + public function offsetExists( $offset ): bool { + return isset( $this->data[ $offset ] ); + } + + /** + * Gets offset. + * + * @throws Exception If the offset does not exist. + * + * @param string $offset Offset. + * @return mixed Value. + */ + public function offsetGet( $offset ) { + if ( ! $this->offsetExists( $offset ) ) { + throw new Exception( sprintf( __( 'Unknown property %s on ILO_URL_Metric.', 'performance-lab' ), $offset ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + return $this->data[ $offset ]; + } + + /** + * Sets offset (disabled). + * + * @param string $offset Offset. + * @param mixed $value Value. + * @throws Exception + */ + public function offsetSet( $offset, $value ) { + throw new Exception( __( 'Cannot set properties on ILO_URL_Metric.', 'performance-lab' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + /** + * Unsets offset (disabled). + * + * @param string $offset Offset. + * @throws Exception + */ + public function offsetUnset( $offset ) { + throw new Exception( __( 'Cannot unset properties on ILO_URL_Metric.', 'performance-lab' ) ); + } + + /** + * Gets the JSON representation of the object. + * + * @return array + */ + public function jsonSerialize(): array { + return $this->data; + } } diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 31a5ebacc6..8ca6512884 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -121,15 +121,15 @@ function ilo_verify_url_metrics_storage_nonce( string $nonce, string $slug ): bo * @since n.e.x.t * @access private * - * @param array $url_metrics URL metrics. Each URL metric is expected to have a timestamp key. - * @param array $validated_url_metric Validated URL metric. See JSON Schema defined in ilo_register_endpoint(). - * @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. + * @param ILO_URL_Metric[] $url_metrics Existing URL metrics. Each URL metric is expected to have a timestamp key. + * @param ILO_URL_Metric $new_url_metric Validated URL metric. See JSON Schema defined in ilo_register_endpoint(). + * @param int[] $breakpoints Breakpoint max widths. + * @param int $sample_size Sample size for URL metrics at a given breakpoint. + * + * @return ILO_URL_Metric[] Updated URL metrics. */ -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 ); +function ilo_unshift_url_metrics( array $url_metrics, ILO_URL_Metric $new_url_metric, array $breakpoints, int $sample_size ): array { + array_unshift( $url_metrics, $new_url_metric ); $grouped_url_metrics = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoints ); // Make sure there is at most $sample_size number of URL metrics for each breakpoint. @@ -140,10 +140,7 @@ static function ( $breakpoint_url_metrics ) use ( $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; - } + static function ( ILO_URL_Metric $a, ILO_URL_Metric $b ) { return $b['timestamp'] <=> $a['timestamp']; } ); @@ -234,8 +231,8 @@ function ilo_get_url_metrics_breakpoint_sample_size(): int { * @since n.e.x.t * @access private * - * @param array $url_metrics URL metrics. - * @param int[] $breakpoints Viewport breakpoint max widths, sorted in ascending order. + * @param ILO_URL_Metric[] $url_metrics URL metrics. + * @param int[] $breakpoints Viewport breakpoint max widths, sorted in ascending order. * @return array URL metrics grouped by breakpoint. The array keys are the minimum widths for a viewport to lie within * the breakpoint. The returned array is always one larger than the provided array of breakpoints, since * the breakpoints reflect the max inclusive boundaries whereas the return value is the groups of page @@ -254,10 +251,6 @@ static function ( $breakpoint ) { $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; - } - $current_minimum_viewport = 0; foreach ( $minimum_viewport_widths as $viewport_minimum_width ) { if ( $url_metric['viewport']['width'] > $viewport_minimum_width ) { diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index 00d397af04..445c77af2e 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -79,7 +79,7 @@ function ilo_get_url_metrics_post( string $slug ) { * @access private * * @param WP_Post $post URL metrics post. - * @return array URL metrics. + * @return ILO_URL_Metric[] URL metrics. */ function ilo_parse_stored_url_metrics( WP_Post $post ): array { $this_function = __FUNCTION__; @@ -89,7 +89,7 @@ function ilo_parse_stored_url_metrics( WP_Post $post ): array { } }; - $url_metrics = json_decode( $post->post_content, true ); + $url_metrics_data = json_decode( $post->post_content, true ); if ( json_last_error() ) { $trigger_error( sprintf( @@ -99,8 +99,8 @@ function ilo_parse_stored_url_metrics( WP_Post $post ): array { json_last_error_msg() ) ); - $url_metrics = array(); - } elseif ( ! is_array( $url_metrics ) ) { + $url_metrics_data = array(); + } elseif ( ! is_array( $url_metrics_data ) ) { $trigger_error( sprintf( /* translators: %s is post type slug */ @@ -108,40 +108,28 @@ function ilo_parse_stored_url_metrics( WP_Post $post ): array { ILO_URL_METRICS_POST_TYPE ) ); - $url_metrics = array(); + $url_metrics_data = array(); } 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; - } + array_map( + static function ( $url_metric_data ) use ( $trigger_error ) { + try { + return new ILO_URL_Metric( $url_metric_data ); + } catch ( Exception $e ) { + $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 null; + } + }, + $url_metrics_data + ) ) ); } @@ -152,12 +140,12 @@ static function ( $url_metric ) use ( $trigger_error ) { * @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 array $validated_url_metric Validated URL metric. See JSON Schema defined in ilo_register_endpoint(). + * @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 ILO_URL_Metric $new_url_metric New URL metric. * @return int|WP_Error Post ID or WP_Error otherwise. */ -function ilo_store_url_metric( string $url, string $slug, array $validated_url_metric ) { +function ilo_store_url_metric( string $url, string $slug, ILO_URL_Metric $new_url_metric ) { // TODO: What about storing a version identifier? $post_data = array( @@ -177,9 +165,12 @@ function ilo_store_url_metric( string $url, string $slug, array $validated_url_m $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 ); + $url_metrics = ilo_unshift_url_metrics( $url_metrics, $new_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. + $post_data['post_content'] = wp_json_encode( + $url_metrics, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES // TODO: No need for pretty-printing. + ); $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); if ( $has_kses ) { diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index b2e3aee9de..1b378707ae 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -142,7 +142,24 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { } ilo_set_url_metric_storage_lock(); - $new_url_metric = wp_array_slice_assoc( $request->get_params(), array( 'viewport', 'elements' ) ); + + try { + $data = array_merge( + wp_array_slice_assoc( $request->get_params(), array( 'viewport', 'elements' ) ), + array( + 'timestamp' => microtime( true ), + ) + ); + $new_url_metric = new ILO_URL_Metric( + $data, + true // Already validated via REST API. + ); + } catch ( Exception $e ) { + return new WP_Error( + 'url_metric_exception', + __( 'Exception occurred while creating URL metric.', 'performance-lab' ) + ); + } $result = ilo_store_url_metric( $request->get_param( 'url' ), diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index 0594a5f2d7..2cb195b4a0 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -44,7 +44,7 @@ public function test_rest_request_good_params() { $this->assertInstanceOf( WP_Post::class, $post ); $url_metrics = ilo_parse_stored_url_metrics( $post ); - $this->assertCount( 1, $url_metrics ); + $this->assertCount( 1, $url_metrics, 'Expected number of URL metrics stored.' ); foreach ( array( 'viewport', 'elements' ) as $key ) { $this->assertSame( $valid_params[ $key ], $url_metrics[0][ $key ] ); } @@ -139,8 +139,8 @@ public function test_rest_request_bad_params( array $params ) { $request = new WP_REST_Request( 'POST', self::ROUTE ); $request->set_body_params( $params ); $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 400, $response->get_status() ); - $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] ); + $this->assertSame( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + $this->assertSame( 'rest_invalid_param', $response->get_data()['code'], 'Response: ' . wp_json_encode( $response ) ); } /** From 0530a6969d3f9720619162581185329c68f99ff8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 14 Feb 2024 16:06:01 -0800 Subject: [PATCH 243/371] Utilize ILO_URL_Metric and its methods in codebase --- .../class-ilo-url-metric.php | 28 +++++++++-- .../storage/data.php | 28 +++++------ .../storage/post-type.php | 4 ++ .../storage/rest-api.php | 5 +- .../optimization-tests.php | 12 +++-- .../storage/data-tests.php | 49 +++++++++++++------ .../storage/post-type-tests.php | 46 +++++++++-------- 7 files changed, 108 insertions(+), 64 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index 116ca480e8..225dd1a7aa 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -38,10 +38,10 @@ public static function get_json_schema(): array { ); return array( - '$schema' => 'http://json-schema.org/draft-04/schema#', - 'title' => 'ilo-url-metric', - 'type' => 'object', - 'properties' => array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'ilo-url-metric', + 'type' => 'object', + 'properties' => array( 'viewport' => array( 'description' => __( 'Viewport dimensions', 'performance-lab' ), 'type' => 'object', @@ -98,6 +98,7 @@ public static function get_json_schema(): array { ), ), ), + 'additionalProperties' => false, ); } @@ -113,14 +114,31 @@ public static function get_json_schema(): array { * * @param array $data URL metric data. * @param bool $validated Whether the data was already validated. + * + * @throws Exception When the input is invalid. */ public function __construct( array $data, bool $validated = false ) { if ( ! $validated ) { - // TODO: Validate. + $valid = rest_validate_object_value_from_schema( $data, self::get_json_schema(), self::class ); + if ( is_wp_error( $valid ) ) { + throw new Exception( $valid->get_error_message() ); + } } $this->data = $data; } + public function get_viewport_width(): int { + return $this->data['viewport']['width']; + } + + public function get_timestamp(): float { + return $this->data['timestamp']; + } + + public function get_elements(): array { + return $this->data['elements']; + } + /** * Checks if the offset exists. * diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 8ca6512884..c277242b32 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -141,7 +141,7 @@ static function ( $breakpoint_url_metrics ) use ( $sample_size ) { usort( $breakpoint_url_metrics, static function ( ILO_URL_Metric $a, ILO_URL_Metric $b ) { - return $b['timestamp'] <=> $a['timestamp']; + return $b->get_timestamp() <=> $a->get_timestamp(); } ); @@ -233,10 +233,10 @@ function ilo_get_url_metrics_breakpoint_sample_size(): int { * * @param ILO_URL_Metric[] $url_metrics URL metrics. * @param int[] $breakpoints Viewport breakpoint max widths, sorted in ascending order. - * @return array URL metrics grouped by breakpoint. The array keys are the minimum widths for a viewport to lie within - * the breakpoint. The returned array is always one larger than the provided array of breakpoints, since - * the breakpoints reflect the max inclusive boundaries whereas the return value is the groups of page - * metrics with viewports on either side of the breakpoint boundaries. + * @return array URL metrics grouped by breakpoint. The array keys are the minimum widths for a viewport to lie within + * the breakpoint. The returned array is always one larger than the provided array of breakpoints, since + * the breakpoints reflect the max inclusive boundaries whereas the return value is the groups of page + * metrics with viewports on either side of the breakpoint boundaries. */ function ilo_group_url_metrics_by_breakpoint( array $url_metrics, array $breakpoints ): array { @@ -253,7 +253,7 @@ static function ( $breakpoint ) { foreach ( $url_metrics as $url_metric ) { $current_minimum_viewport = 0; foreach ( $minimum_viewport_widths as $viewport_minimum_width ) { - if ( $url_metric['viewport']['width'] > $viewport_minimum_width ) { + if ( $url_metric->get_viewport_width() > $viewport_minimum_width ) { $current_minimum_viewport = $viewport_minimum_width; } else { break; @@ -277,7 +277,7 @@ static function ( $breakpoint ) { * @since n.e.x.t * @access private * - * @param array $grouped_url_metrics URL metrics grouped by breakpoint. See `ilo_group_url_metrics_by_breakpoint()`. + * @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 supported LCP element at a breakpoint, then `false` is used. */ function ilo_get_lcp_elements_by_minimum_viewport_widths( array $grouped_url_metrics ): array { @@ -291,7 +291,7 @@ function ilo_get_lcp_elements_by_minimum_viewport_widths( array $grouped_url_met $breadcrumb_element = array(); foreach ( $breakpoint_url_metrics as $breakpoint_url_metric ) { - foreach ( $breakpoint_url_metric['elements'] as $element ) { + foreach ( $breakpoint_url_metric->get_elements() as $element ) { if ( ! $element['isLCP'] ) { continue; } @@ -353,11 +353,11 @@ 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 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. + * @param ILO_URL_Metric[] $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. * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. */ function ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $current_time, array $breakpoint_max_widths, int $sample_size, int $freshness_ttl ): array { @@ -369,7 +369,7 @@ function ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $curr $needs_url_metrics = true; } else { foreach ( $viewport_url_metrics as $url_metric ) { - if ( isset( $url_metric['timestamp'] ) && $url_metric['timestamp'] + $freshness_ttl < $current_time ) { + if ( $url_metric->get_timestamp() + $freshness_ttl < $current_time ) { $needs_url_metrics = true; break; } diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index 445c77af2e..e7dcc222b7 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -115,6 +115,10 @@ function ilo_parse_stored_url_metrics( WP_Post $post ): array { array_filter( array_map( static function ( $url_metric_data ) use ( $trigger_error ) { + if ( ! is_array( $url_metric_data ) ) { + return null; + } + try { return new ILO_URL_Metric( $url_metric_data ); } catch ( Exception $e ) { diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 1b378707ae..fc88136e0b 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -145,7 +145,10 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { try { $data = array_merge( - wp_array_slice_assoc( $request->get_params(), array( 'viewport', 'elements' ) ), + wp_array_slice_assoc( + $request->get_params(), + array_keys( ILO_URL_Metric::get_json_schema()['properties'] ) + ), array( 'timestamp' => microtime( true ), ) diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index d3ad89f58b..9083ef45cb 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -1010,15 +1010,16 @@ private function normalize_whitespace( string $str ): string { * Gets a validated URL metric. * * @param int $viewport_width Viewport width for the URL metric. - * @return array URL metric. + * @return ILO_URL_Metric URL metric. */ - private function get_validated_url_metric( int $viewport_width, array $elements = array() ): array { - return array( - 'viewport' => array( + private function get_validated_url_metric( int $viewport_width, array $elements = array() ): ILO_URL_Metric { + $data = array( + 'viewport' => array( 'width' => $viewport_width, 'height' => 800, ), - 'elements' => array_map( + 'timestamp' => microtime( true ), + 'elements' => array_map( static function ( array $element ): array { return array_merge( array( @@ -1031,6 +1032,7 @@ static function ( array $element ): array { $elements ), ); + return new ILO_URL_Metric( $data ); } /** diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 23676bcb25..56232af7cd 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -215,10 +215,15 @@ public function test_ilo_unshift_url_metrics( int $sample_size, array $breakpoin // Make sure that ilo_unshift_url_metrics() added a timestamp and then force them to all be old. $all_url_metrics = array_map( - function ( $url_metric ) use ( $old_timestamp ) { - $this->assertArrayHasKey( 'timestamp', $url_metric, 'Expected a timestamp to have been added to a URL metric.' ); - $url_metric['timestamp'] = $old_timestamp; - return $url_metric; + static function ( $url_metric ) use ( $old_timestamp ): ILO_URL_Metric { + return new ILO_URL_Metric( + array_merge( + $url_metric->jsonSerialize(), + array( + 'timestamp' => $old_timestamp, + ) + ) + ); }, $all_url_metrics ); @@ -239,7 +244,7 @@ function ( $url_metric ) use ( $old_timestamp ) { ); $new_count = 0; foreach ( $all_url_metrics as $url_metric ) { - if ( $url_metric['timestamp'] > $old_timestamp ) { + if ( $url_metric->get_timestamp() > $old_timestamp ) { ++$new_count; } } @@ -341,9 +346,9 @@ function ( $viewport_width ) { $this->assertIsArray( $grouped_url_metrics[ $minimum_viewport_width ] ); foreach ( $grouped_url_metrics[ $minimum_viewport_width ] as $url_metric ) { - $this->assertGreaterThanOrEqual( $minimum_viewport_width, $url_metric['viewport']['width'] ); + $this->assertGreaterThanOrEqual( $minimum_viewport_width, $url_metric->get_viewport_width() ); if ( isset( $maximum_viewport_width ) ) { - $this->assertLessThanOrEqual( $maximum_viewport_width, $url_metric['viewport']['width'] ); + $this->assertLessThanOrEqual( $maximum_viewport_width, $url_metric->get_viewport_width() ); } } } @@ -462,12 +467,22 @@ public function data_provider_test_ilo_get_needed_minimum_viewport_widths(): arr array_fill( 0, 3, - array_merge( $this->get_validated_url_metric( 400 ), array( 'timestamp' => $current_time ) ) + new ILO_URL_Metric( + array_merge( + $this->get_validated_url_metric( 400 )->jsonSerialize(), + array( 'timestamp' => $current_time ) + ) + ) ), array_fill( 0, 3, - array_merge( $this->get_validated_url_metric( 600 ), array( 'timestamp' => $current_time ) ) + new ILO_URL_Metric( + array_merge( + $this->get_validated_url_metric( 600 )->jsonSerialize(), + array( 'timestamp' => $current_time ) + ) + ) ) ); } )(), @@ -503,7 +518,9 @@ public function data_provider_test_ilo_get_needed_minimum_viewport_widths(): arr 'url-metric-too-old' => array_merge( ( static function ( $data ): array { - $data['url_metrics'][0]['timestamp'] -= $data['freshness_ttl'] + 1; + $url_metrics_data = $data['url_metrics'][0]->jsonSerialize(); + $url_metrics_data['timestamp'] -= $data['freshness_ttl'] + 1; + $data['url_metrics'][0] = new ILO_URL_Metric( $url_metrics_data ); return $data; } )( $none_needed_data ), array( @@ -536,15 +553,16 @@ public function test_ilo_get_needed_minimum_viewport_widths( array $url_metrics, * @param int $viewport_width Viewport width. * @param string[] $breadcrumbs Breadcrumb tags. * @param bool $is_lcp Whether LCP. - * @return array Validated URL metric. + * @return ILO_URL_Metric Validated URL metric. */ - private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): array { - return array( - 'viewport' => array( + private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): ILO_URL_Metric { + $data = array( + 'viewport' => array( 'width' => $viewport_width, 'height' => 640, ), - 'elements' => array( + 'timestamp' => microtime( true ), + 'elements' => array( array( 'isLCP' => $is_lcp, 'isLCPCandidate' => $is_lcp, @@ -553,6 +571,7 @@ private function get_validated_url_metric( int $viewport_width = 480, array $bre ), ), ); + return new ILO_URL_Metric( $data ); } /** diff --git a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php index 15b298016a..b14f48c48c 100644 --- a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php @@ -101,7 +101,13 @@ public function test_ilo_parse_stored_url_metrics( string $post_content, array $ ) ); - $url_metrics = ilo_parse_stored_url_metrics( $post ); + $url_metrics = array_map( + static function ( ILO_URL_Metric $url_metric ): array { + return $url_metric->jsonSerialize(); + }, + ilo_parse_stored_url_metrics( $post ) + ); + $this->assertSame( $expected_value, $url_metrics ); } @@ -114,19 +120,22 @@ public function test_ilo_store_url_metric() { $url = home_url( '/' ); $slug = ilo_get_url_metrics_slug( array( 'p' => 1 ) ); - $validated_url_metric = array( - 'viewport' => array( - 'width' => 480, - 'height' => 640, - ), - 'elements' => array( - array( - 'isLCP' => true, - 'isLCPCandidate' => true, - 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]/*[1][self::MAIN]/*[0][self::DIV]/*[0][self::FIGURE]/*[0][self::IMG]', - 'intersectionRatio' => 1, + $validated_url_metric = new ILO_URL_Metric( + array( + 'viewport' => array( + 'width' => 480, + 'height' => 640, ), - ), + 'timestamp' => microtime( true ), + 'elements' => array( + array( + 'isLCP' => true, + 'isLCPCandidate' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]/*[1][self::MAIN]/*[0][self::DIV]/*[0][self::FIGURE]/*[0][self::IMG]', + 'intersectionRatio' => 1, + ), + ), + ) ); $post_id = ilo_store_url_metric( $url, $slug, $validated_url_metric ); @@ -138,22 +147,11 @@ public function test_ilo_store_url_metric() { $url_metrics = ilo_parse_stored_url_metrics( $post ); $this->assertCount( 1, $url_metrics ); - $this->assertArrayHasKey( 'timestamp', $url_metrics[0] ); - $this->assertIsFloat( $url_metrics[0]['timestamp'] ); - $this->assertLessThanOrEqual( microtime( true ), $url_metrics[0]['timestamp'] ); $again_post_id = ilo_store_url_metric( $url, $slug, $validated_url_metric ); $post = get_post( $again_post_id ); $this->assertSame( $post_id, $again_post_id ); $url_metrics = ilo_parse_stored_url_metrics( $post ); $this->assertCount( 2, $url_metrics ); - - foreach ( $url_metrics as $url_metric ) { - $this->assertArrayHasKey( 'timestamp', $url_metric ); - $this->assertIsFloat( $url_metric['timestamp'] ); - $this->assertLessThanOrEqual( microtime( true ), $url_metric['timestamp'] ); - unset( $url_metric['timestamp'] ); - $this->assertSame( $validated_url_metric, $url_metric ); - } } } From d8228c2f162e409611698343f7e8eba1b56352ad Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 14 Feb 2024 16:12:09 -0800 Subject: [PATCH 244/371] Eliminate ArrayAccess --- .../class-ilo-url-metric.php | 65 +++++-------------- .../storage/rest-api-tests.php | 6 +- 2 files changed, 18 insertions(+), 53 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index 225dd1a7aa..12f2176aa6 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -9,12 +9,10 @@ /** * Representation of the measurements taken from a single client's visit to a specific URL. * - * @implements ArrayAccess - * * @since n.e.x.t * @access private */ -final class ILO_URL_Metric implements ArrayAccess, JsonSerializable { +final class ILO_URL_Metric implements JsonSerializable { /** * Gets JSON schema for URL Metric. @@ -103,11 +101,11 @@ public static function get_json_schema(): array { } /** - * Validated data. + * Data. * * @var array */ - private $data; + private $data = array(); /** * Constructor. @@ -121,68 +119,37 @@ public function __construct( array $data, bool $validated = false ) { if ( ! $validated ) { $valid = rest_validate_object_value_from_schema( $data, self::get_json_schema(), self::class ); if ( is_wp_error( $valid ) ) { - throw new Exception( $valid->get_error_message() ); + throw new Exception( esc_html( $valid->get_error_message() ) ); } } $this->data = $data; } - public function get_viewport_width(): int { - return $this->data['viewport']['width']; - } - - public function get_timestamp(): float { - return $this->data['timestamp']; - } - - public function get_elements(): array { - return $this->data['elements']; - } - /** - * Checks if the offset exists. + * Gets viewport width. * - * @param string $offset Offset. - * @return bool Whether exists. + * @return int */ - public function offsetExists( $offset ): bool { - return isset( $this->data[ $offset ] ); - } - - /** - * Gets offset. - * - * @throws Exception If the offset does not exist. - * - * @param string $offset Offset. - * @return mixed Value. - */ - public function offsetGet( $offset ) { - if ( ! $this->offsetExists( $offset ) ) { - throw new Exception( sprintf( __( 'Unknown property %s on ILO_URL_Metric.', 'performance-lab' ), $offset ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped - } - return $this->data[ $offset ]; + public function get_viewport_width(): int { + return $this->data['viewport']['width']; } /** - * Sets offset (disabled). + * Gets timestamp. * - * @param string $offset Offset. - * @param mixed $value Value. - * @throws Exception + * @return float */ - public function offsetSet( $offset, $value ) { - throw new Exception( __( 'Cannot set properties on ILO_URL_Metric.', 'performance-lab' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + public function get_timestamp(): float { + return $this->data['timestamp']; } /** - * Unsets offset (disabled). + * Gets elements. * - * @param string $offset Offset. - * @throws Exception + * @return array */ - public function offsetUnset( $offset ) { - throw new Exception( __( 'Cannot unset properties on ILO_URL_Metric.', 'performance-lab' ) ); + public function get_elements(): array { + return $this->data['elements']; } /** diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index 2cb195b4a0..da3ed1b077 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -45,10 +45,8 @@ public function test_rest_request_good_params() { $url_metrics = ilo_parse_stored_url_metrics( $post ); $this->assertCount( 1, $url_metrics, 'Expected number of URL metrics stored.' ); - foreach ( array( 'viewport', 'elements' ) as $key ) { - $this->assertSame( $valid_params[ $key ], $url_metrics[0][ $key ] ); - } - $this->assertArrayHasKey( 'timestamp', $url_metrics[0] ); + $this->assertSame( $valid_params['elements'], $url_metrics[0]->get_elements() ); + $this->assertSame( $valid_params['viewport']['width'], $url_metrics[0]->get_viewport_width() ); } /** From e87e3aca3a80943be683b1ef071cbf1472e3e563 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 14 Feb 2024 16:53:31 -0800 Subject: [PATCH 245/371] Modify timestamp schema for REST API request --- .../class-ilo-url-metric.php | 3 +-- .../image-loading-optimization/storage/rest-api.php | 4 ++++ .../storage/post-type-tests.php | 5 +++-- .../storage/rest-api-tests.php | 11 +++++++---- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index 12f2176aa6..d8f5c41d76 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -60,8 +60,7 @@ public static function get_json_schema(): array { 'timestamp' => array( 'description' => __( 'Timestamp at which the URL metric was captured.', 'performance-lab' ), 'type' => 'number', - 'readonly' => true, // Use the server-provided timestamp. - 'default' => microtime( true ), + 'required' => true, 'minimum' => 0, ), 'elements' => array( diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index fc88136e0b..9e0e611682 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -75,6 +75,10 @@ function ilo_register_endpoint() { $schema = ILO_URL_Metric::get_json_schema(); + // Make timestamp not required since it is forcibly-provided in ilo_handle_rest_request(). + $schema['properties']['timestamp']['required'] = false; + $schema['properties']['timestamp']['readonly'] = true; + $args = array_merge( $args, $schema['properties'] ); register_rest_route( diff --git a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php index b14f48c48c..99986f93d4 100644 --- a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php @@ -58,11 +58,12 @@ public function test_ilo_get_url_metrics_post_when_present() { public function data_provider_test_ilo_parse_stored_url_metrics(): array { $valid_content = array( array( - 'viewport' => array( + 'viewport' => array( 'width' => 640, 'height' => 480, ), - 'elements' => array(), + 'timestamp' => microtime( true ), + 'elements' => array(), ), ); diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index da3ed1b077..8c3c39308d 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -34,7 +34,7 @@ public function test_rest_request_good_params() { $this->assertCount( 0, get_posts( array( 'post_type' => ILO_URL_METRICS_POST_TYPE ) ) ); $request->set_body_params( $valid_params ); $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); $data = $response->get_data(); $this->assertTrue( $data['success'] ); @@ -222,7 +222,7 @@ public function test_rest_request_breakpoint_not_needed_for_specific_breakpoint( */ private function get_valid_params(): array { $slug = ilo_get_url_metrics_slug( array() ); - return array_merge( + $data = array_merge( array( 'url' => home_url( '/' ), 'slug' => $slug, @@ -230,6 +230,8 @@ private function get_valid_params(): array { ), $this->get_sample_validated_url_metric() ); + unset( $data['timestamp'] ); // Since provided by request handler. + return $data; } /** @@ -239,11 +241,12 @@ private function get_valid_params(): array { */ private function get_sample_validated_url_metric(): array { return array( - 'viewport' => array( + 'viewport' => array( 'width' => 480, 'height' => 640, ), - 'elements' => array( + 'timestamp' => microtime( true ), + 'elements' => array( array( 'isLCP' => true, 'isLCPCandidate' => true, From 3f91818bc9bbd5e4e37a6530cb6c74bb38c43f0d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 15 Feb 2024 12:27:49 -0800 Subject: [PATCH 246/371] Add more strict typing --- .../class-ilo-url-metric.php | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index d8f5c41d76..0d1f571753 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -21,8 +21,8 @@ final class ILO_URL_Metric implements JsonSerializable { */ public static function get_json_schema(): array { $dom_rect_schema = array( - 'type' => 'object', - 'properties' => array( + 'type' => 'object', + 'properties' => array( 'width' => array( 'type' => 'number', 'minimum' => 0, @@ -31,8 +31,9 @@ public static function get_json_schema(): array { 'type' => 'number', 'minimum' => 0, ), - // TODO: There are other properties to define if we need them: x, y, top, right, bottom, left. ), + // TODO: There are other properties to define if we need them: x, y, top, right, bottom, left. + 'additionalProperties' => true, ); return array( @@ -69,8 +70,8 @@ public static function get_json_schema(): array { 'required' => true, 'items' => array( // See the ElementMetrics in detect.js. - 'type' => 'object', - 'properties' => array( + 'type' => 'object', + 'properties' => array( 'isLCP' => array( 'type' => 'boolean', 'required' => true, @@ -92,6 +93,7 @@ public static function get_json_schema(): array { 'intersectionRect' => $dom_rect_schema, 'boundingClientRect' => $dom_rect_schema, ), + 'additionalProperties' => false, ), ), ), @@ -102,9 +104,20 @@ public static function get_json_schema(): array { /** * Data. * - * @var array + * @var array{ + * timestamp: int, + * viewport: array{ width: int, height: int }, + * elements: array + * } */ - private $data = array(); + private $data; /** * Constructor. From 09637532597d2dd867d32d6193a75fcc93ecfecb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 21 Feb 2024 14:53:10 -0800 Subject: [PATCH 247/371] Move data member to top of class --- .../class-ilo-url-metric.php | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index 0d1f571753..ef22d56461 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -14,6 +14,24 @@ */ final class ILO_URL_Metric implements JsonSerializable { + /** + * Data. + * + * @var array{ + * timestamp: int, + * viewport: array{ width: int, height: int }, + * elements: array + * } + */ + private $data; + /** * Gets JSON schema for URL Metric. * @@ -101,24 +119,6 @@ public static function get_json_schema(): array { ); } - /** - * Data. - * - * @var array{ - * timestamp: int, - * viewport: array{ width: int, height: int }, - * elements: array - * } - */ - private $data; - /** * Constructor. * From cc9e9562651ade093d5f769784b1667957a66a63 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 21 Feb 2024 14:54:15 -0800 Subject: [PATCH 248/371] Also move up constructor to top of class --- .../class-ilo-url-metric.php | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index ef22d56461..da6b49552c 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -32,6 +32,24 @@ final class ILO_URL_Metric implements JsonSerializable { */ private $data; + /** + * Constructor. + * + * @param array $data URL metric data. + * @param bool $validated Whether the data was already validated. + * + * @throws Exception When the input is invalid. + */ + public function __construct( array $data, bool $validated = false ) { + if ( ! $validated ) { + $valid = rest_validate_object_value_from_schema( $data, self::get_json_schema(), self::class ); + if ( is_wp_error( $valid ) ) { + throw new Exception( esc_html( $valid->get_error_message() ) ); + } + } + $this->data = $data; + } + /** * Gets JSON schema for URL Metric. * @@ -119,24 +137,6 @@ public static function get_json_schema(): array { ); } - /** - * Constructor. - * - * @param array $data URL metric data. - * @param bool $validated Whether the data was already validated. - * - * @throws Exception When the input is invalid. - */ - public function __construct( array $data, bool $validated = false ) { - if ( ! $validated ) { - $valid = rest_validate_object_value_from_schema( $data, self::get_json_schema(), self::class ); - if ( is_wp_error( $valid ) ) { - throw new Exception( esc_html( $valid->get_error_message() ) ); - } - } - $this->data = $data; - } - /** * Gets viewport width. * From eab177c798029be6791c2ef844aa3ca1a31200bb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 21 Feb 2024 15:11:05 -0800 Subject: [PATCH 249/371] Add typing for get_elements return value --- .../image-loading-optimization/class-ilo-url-metric.php | 9 ++++++++- .../image-loading-optimization/storage/post-type.php | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index da6b49552c..08275da6f9 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -158,7 +158,14 @@ public function get_timestamp(): float { /** * Gets elements. * - * @return array + * @return array */ public function get_elements(): array { return $this->data['elements']; diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index e7dcc222b7..5f3e391c5b 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -120,6 +120,7 @@ static function ( $url_metric_data ) use ( $trigger_error ) { } try { + // TODO: This is re-validating the data which has been stored in the post type. This ensures it remains valid, but is it overkill? return new ILO_URL_Metric( $url_metric_data ); } catch ( Exception $e ) { $trigger_error( From 87f518f67003636e81b79c2a7a99cde22f25fe77 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 21 Feb 2024 16:48:11 -0800 Subject: [PATCH 250/371] Improve formatting --- .../storage/rest-api.php | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 9e0e611682..1cb2c856d0 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -86,6 +86,7 @@ function ilo_register_endpoint() { ILO_URL_METRICS_ROUTE, array( 'methods' => 'POST', + 'args' => $args, 'callback' => static function ( WP_REST_Request $request ) { return ilo_handle_rest_request( $request ); }, @@ -100,7 +101,6 @@ function ilo_register_endpoint() { } return true; }, - 'args' => $args, ) ); } @@ -148,17 +148,16 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { ilo_set_url_metric_storage_lock(); try { - $data = array_merge( - wp_array_slice_assoc( - $request->get_params(), - array_keys( ILO_URL_Metric::get_json_schema()['properties'] ) - ), - array( - 'timestamp' => microtime( true ), - ) - ); $new_url_metric = new ILO_URL_Metric( - $data, + array_merge( + wp_array_slice_assoc( + $request->get_params(), + array_keys( ILO_URL_Metric::get_json_schema()['properties'] ) + ), + array( + 'timestamp' => microtime( true ), + ) + ), true // Already validated via REST API. ); } catch ( Exception $e ) { From 56346fd4a2b303a8699a70f33a2752633247eb5a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 21 Feb 2024 16:54:34 -0800 Subject: [PATCH 251/371] Use integer for timestamp in tests for sake of comparison --- .../image-loading-optimization/storage/post-type-tests.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php index 99986f93d4..82a77e2852 100644 --- a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php @@ -62,7 +62,7 @@ public function data_provider_test_ilo_parse_stored_url_metrics(): array { 'width' => 640, 'height' => 480, ), - 'timestamp' => microtime( true ), + 'timestamp' => (int) microtime( true ), // Integer to facilitate equality tests. 'elements' => array(), ), ); From 6cafe3add1575a18c5de7df8b25bde626ef74478 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 21 Feb 2024 16:57:08 -0800 Subject: [PATCH 252/371] Replace get_viewport_width with get_viewport to return array --- .../image-loading-optimization/class-ilo-url-metric.php | 6 +++--- modules/images/image-loading-optimization/storage/data.php | 2 +- .../image-loading-optimization/storage/data-tests.php | 4 ++-- .../image-loading-optimization/storage/rest-api-tests.php | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index 08275da6f9..0d05782c75 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -140,10 +140,10 @@ public static function get_json_schema(): array { /** * Gets viewport width. * - * @return int + * @return array{ width: int, height: int } */ - public function get_viewport_width(): int { - return $this->data['viewport']['width']; + public function get_viewport(): array { + return $this->data['viewport']; } /** diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index c277242b32..2a00b5f2f5 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -253,7 +253,7 @@ static function ( $breakpoint ) { foreach ( $url_metrics as $url_metric ) { $current_minimum_viewport = 0; foreach ( $minimum_viewport_widths as $viewport_minimum_width ) { - if ( $url_metric->get_viewport_width() > $viewport_minimum_width ) { + if ( $url_metric->get_viewport()['width'] > $viewport_minimum_width ) { $current_minimum_viewport = $viewport_minimum_width; } else { break; diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 56232af7cd..9a27438c47 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -346,9 +346,9 @@ function ( $viewport_width ) { $this->assertIsArray( $grouped_url_metrics[ $minimum_viewport_width ] ); foreach ( $grouped_url_metrics[ $minimum_viewport_width ] as $url_metric ) { - $this->assertGreaterThanOrEqual( $minimum_viewport_width, $url_metric->get_viewport_width() ); + $this->assertGreaterThanOrEqual( $minimum_viewport_width, $url_metric->get_viewport()['width'] ); if ( isset( $maximum_viewport_width ) ) { - $this->assertLessThanOrEqual( $maximum_viewport_width, $url_metric->get_viewport_width() ); + $this->assertLessThanOrEqual( $maximum_viewport_width, $url_metric->get_viewport()['width'] ); } } } diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index 8c3c39308d..35e1bc70ba 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -46,7 +46,7 @@ public function test_rest_request_good_params() { $url_metrics = ilo_parse_stored_url_metrics( $post ); $this->assertCount( 1, $url_metrics, 'Expected number of URL metrics stored.' ); $this->assertSame( $valid_params['elements'], $url_metrics[0]->get_elements() ); - $this->assertSame( $valid_params['viewport']['width'], $url_metrics[0]->get_viewport_width() ); + $this->assertSame( $valid_params['viewport']['width'], $url_metrics[0]->get_viewport()['width'] ); } /** From db118278348e61c09eb0efbcd09a37b04b954879 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 21 Feb 2024 20:07:09 -0800 Subject: [PATCH 253/371] Tidy up more static analysis issues --- .../optimization-tests.php | 22 +++++++++++++------ .../storage/data-tests.php | 8 ++++++- .../storage/post-type-tests.php | 2 ++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 9083ef45cb..885001bfdd 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -4,12 +4,20 @@ * * @package performance-lab * @group image-loading-optimization + * + * @noinspection HtmlUnknownTarget */ class ILO_Optimization_Tests extends WP_UnitTestCase { + /** + * @var string + */ private $original_request_uri; + /** + * @var string + */ private $original_request_method; public function set_up() { @@ -76,7 +84,6 @@ public function data_provider_test_ilo_can_optimize_response(): array { 'set_up' => function () { $this->go_to( home_url( '/' ) ); global $wp_customize; - /** @noinspection PhpIncludeInspection */ require_once ABSPATH . 'wp-includes/class-wp-customize-manager.php'; $wp_customize = new WP_Customize_Manager(); $wp_customize->start_previewing_theme(); @@ -341,7 +348,7 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array ... - + ', @@ -354,7 +361,7 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array - + ', @@ -369,8 +376,8 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array ... - - + + ', @@ -382,8 +389,8 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array - - + + ', @@ -1011,6 +1018,7 @@ private function normalize_whitespace( string $str ): string { * * @param int $viewport_width Viewport width for the URL metric. * @return ILO_URL_Metric URL metric. + * @throws Exception From ILO_URL_Metric if there is a parse error, but there won't be. */ private function get_validated_url_metric( int $viewport_width, array $elements = array() ): ILO_URL_Metric { $data = array( diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 9a27438c47..2002f9ef5c 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -4,10 +4,15 @@ * * @package performance-lab * @group image-loading-optimization + * + * @noinspection PhpUnhandledExceptionInspection */ class ILO_Storage_Data_Tests extends WP_UnitTestCase { + /** + * @var string + */ private $original_request_uri; public function set_up() { @@ -344,7 +349,6 @@ function ( $viewport_width ) { $this->assertLessThan( $maximum_viewport_width, $minimum_viewport_width ); } - $this->assertIsArray( $grouped_url_metrics[ $minimum_viewport_width ] ); foreach ( $grouped_url_metrics[ $minimum_viewport_width ] as $url_metric ) { $this->assertGreaterThanOrEqual( $minimum_viewport_width, $url_metric->get_viewport()['width'] ); if ( isset( $maximum_viewport_width ) ) { @@ -553,7 +557,9 @@ public function test_ilo_get_needed_minimum_viewport_widths( array $url_metrics, * @param int $viewport_width Viewport width. * @param string[] $breadcrumbs Breadcrumb tags. * @param bool $is_lcp Whether LCP. + * * @return ILO_URL_Metric Validated URL metric. + * @throws Exception From ILO_URL_Metric if there is a parse error, but there won't be. */ private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): ILO_URL_Metric { $data = array( diff --git a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php index 82a77e2852..c8a4bf1a61 100644 --- a/tests/modules/images/image-loading-optimization/storage/post-type-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/post-type-tests.php @@ -4,6 +4,8 @@ * * @package performance-lab * @group image-loading-optimization + * + * @noinspection PhpUnhandledExceptionInspection */ class ILO_Storage_Post_Type_Tests extends WP_UnitTestCase { From af957691e7cc3491fa51bb480a4f1eb54cb2b496 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 21 Feb 2024 22:07:16 -0800 Subject: [PATCH 254/371] Address additional PhpStorm inspections --- .../class-ilo-html-tag-processor-tests.php | 17 +++++++++++++---- .../optimization-tests.php | 1 + 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php index e288211aa5..e402b650be 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php @@ -6,6 +6,15 @@ * @group image-loading-optimization * * @coversDefaultClass ILO_HTML_Tag_Processor + * + * @noinspection HtmlUnknownTarget + * @noinspection HtmlRequiredTitleElement + * @noinspection HtmlRequiredAltAttribute + * @noinspection HtmlRequiredLangAttribute + * @noinspection HtmlDeprecatedTag + * @noinspection HtmlDeprecatedAttribute + * @noinspection HtmlExtraClosingTag + * @todo What are the other inspection IDs which can turn off inspections for the other irrelevant warnings? Remaining is "The tag is marked as deprecated." */ class ILO_HTML_Tag_Processor_Tests extends WP_UnitTestCase { @@ -26,7 +35,7 @@ public function data_provider_sample_documents(): array {

      Foo!
      - Foo + Foo

      The end!
      @@ -124,14 +133,14 @@ public function data_provider_sample_documents(): array {
      - + - + - + diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 885001bfdd..9835998a48 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -6,6 +6,7 @@ * @group image-loading-optimization * * @noinspection HtmlUnknownTarget + * @todo There are "Cannot resolve ..." errors and "Element img doesn't have a required attribute src" warnings that should be excluded from inspection. */ class ILO_Optimization_Tests extends WP_UnitTestCase { From 607327466529982984c4517b462f36b8326da02a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 21 Feb 2024 22:38:23 -0800 Subject: [PATCH 255/371] Remove need for HtmlUnknownTarget suppression --- .../class-ilo-html-tag-processor-tests.php | 1 - .../optimization-tests.php | 37 +++++++++---------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php index e402b650be..e3afd5328d 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-html-tag-processor-tests.php @@ -7,7 +7,6 @@ * * @coversDefaultClass ILO_HTML_Tag_Processor * - * @noinspection HtmlUnknownTarget * @noinspection HtmlRequiredTitleElement * @noinspection HtmlRequiredAltAttribute * @noinspection HtmlRequiredLangAttribute diff --git a/tests/modules/images/image-loading-optimization/optimization-tests.php b/tests/modules/images/image-loading-optimization/optimization-tests.php index 9835998a48..6631aaf21e 100644 --- a/tests/modules/images/image-loading-optimization/optimization-tests.php +++ b/tests/modules/images/image-loading-optimization/optimization-tests.php @@ -5,7 +5,6 @@ * @package performance-lab * @group image-loading-optimization * - * @noinspection HtmlUnknownTarget * @todo There are "Cannot resolve ..." errors and "Element img doesn't have a required attribute src" warnings that should be excluded from inspection. */ @@ -156,47 +155,47 @@ public function data_provider_test_ilo_construct_preload_links(): array { 'lcp_elements_by_minimum_viewport_widths' => array( 0 => array( 'img_attributes' => array( - 'src' => 'elva-fairy-800w.jpg', - 'srcset' => 'elva-fairy-480w.jpg 480w, elva-fairy-800w.jpg 800w', + 'src' => 'https://example.com/elva-fairy-800w.jpg', + 'srcset' => 'https://example.com/elva-fairy-480w.jpg 480w, https://example.com/elva-fairy-800w.jpg 800w', 'sizes' => '(max-width: 600px) 480px, 800px', 'crossorigin' => 'anonymous', ), ), ), 'expected' => ' - + ', ), 'two-breakpoint-responsive-lcp-images' => array( 'lcp_elements_by_minimum_viewport_widths' => array( 0 => array( 'img_attributes' => array( - 'src' => 'elva-fairy-800w.jpg', - 'srcset' => 'elva-fairy-480w.jpg 480w, elva-fairy-800w.jpg 800w', + 'src' => 'https://example.com/elva-fairy-800w.jpg', + 'srcset' => 'https://example.com/elva-fairy-480w.jpg 480w, https://example.com/elva-fairy-800w.jpg 800w', 'sizes' => '(max-width: 600px) 480px, 800px', 'crossorigin' => 'anonymous', ), ), 601 => array( 'img_attributes' => array( - 'src' => 'alt-elva-fairy-800w.jpg', - 'srcset' => 'alt-elva-fairy-480w.jpg 480w, alt-elva-fairy-800w.jpg 800w', + 'src' => 'https://example.com/alt-elva-fairy-800w.jpg', + 'srcset' => 'https://example.com/alt-elva-fairy-480w.jpg 480w, https://example.com/alt-elva-fairy-800w.jpg 800w', 'sizes' => '(max-width: 600px) 480px, 800px', 'crossorigin' => 'anonymous', ), ), ), 'expected' => ' - - + + ', ), 'two-non-consecutive-responsive-lcp-images' => array( 'lcp_elements_by_minimum_viewport_widths' => array( 0 => array( 'img_attributes' => array( - 'src' => 'elva-fairy-800w.jpg', - 'srcset' => 'elva-fairy-480w.jpg 480w, elva-fairy-800w.jpg 800w', + 'src' => 'https://example.com/elva-fairy-800w.jpg', + 'srcset' => 'https://example.com/elva-fairy-480w.jpg 480w, https://example.com/elva-fairy-800w.jpg 800w', 'sizes' => '(max-width: 600px) 480px, 800px', 'crossorigin' => 'anonymous', ), @@ -204,16 +203,16 @@ public function data_provider_test_ilo_construct_preload_links(): array { 481 => false, 601 => array( 'img_attributes' => array( - 'src' => 'alt-elva-fairy-800w.jpg', - 'srcset' => 'alt-elva-fairy-480w.jpg 480w, alt-elva-fairy-800w.jpg 800w', + 'src' => 'https://example.com/alt-elva-fairy-800w.jpg', + 'srcset' => 'https://example.com/alt-elva-fairy-480w.jpg 480w, https://example.com/alt-elva-fairy-800w.jpg 800w', 'sizes' => '(max-width: 600px) 480px, 800px', 'crossorigin' => 'anonymous', ), ), ), 'expected' => ' - - + + ', ), 'one-background-lcp-image' => array( @@ -244,8 +243,8 @@ public function data_provider_test_ilo_construct_preload_links(): array { 'lcp_elements_by_minimum_viewport_widths' => array( 0 => array( 'img_attributes' => array( - 'src' => 'mobile-800w.jpg', - 'srcset' => 'mobile-480w.jpg 480w, mobile-800w.jpg 800w', + 'src' => 'https://example.com/mobile-800w.jpg', + 'srcset' => 'https://example.com/mobile-480w.jpg 480w, https://example.com/mobile-800w.jpg 800w', 'sizes' => '(max-width: 600px) 480px, 800px', 'crossorigin' => 'anonymous', ), @@ -255,7 +254,7 @@ public function data_provider_test_ilo_construct_preload_links(): array { ), ), 'expected' => ' - + ', ), From 4f3119c32fed7228702e15bce61d3eee2c240d7c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 21 Feb 2024 22:39:45 -0800 Subject: [PATCH 256/371] Add return type for usort callback --- modules/images/image-loading-optimization/storage/data.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 2a00b5f2f5..5da3af4df8 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -140,7 +140,7 @@ static function ( $breakpoint_url_metrics ) use ( $sample_size ) { // Sort URL metrics in descending order by timestamp. usort( $breakpoint_url_metrics, - static function ( ILO_URL_Metric $a, ILO_URL_Metric $b ) { + static function ( ILO_URL_Metric $a, ILO_URL_Metric $b ): int { return $b->get_timestamp() <=> $a->get_timestamp(); } ); From adf37d069d0eb3154b023d0e9095506caf70bd39 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 21 Feb 2024 22:41:59 -0800 Subject: [PATCH 257/371] Update parameter description --- modules/images/image-loading-optimization/storage/data.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 5da3af4df8..99ad6bb2da 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -122,7 +122,7 @@ function ilo_verify_url_metrics_storage_nonce( string $nonce, string $slug ): bo * @access private * * @param ILO_URL_Metric[] $url_metrics Existing URL metrics. Each URL metric is expected to have a timestamp key. - * @param ILO_URL_Metric $new_url_metric Validated URL metric. See JSON Schema defined in ilo_register_endpoint(). + * @param ILO_URL_Metric $new_url_metric New URL metric. * @param int[] $breakpoints Breakpoint max widths. * @param int $sample_size Sample size for URL metrics at a given breakpoint. * From 64727dda52bc12987aea662258d468941f7b2d87 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 22 Feb 2024 17:46:03 -0800 Subject: [PATCH 258/371] Create initial draft of ILO_Grouped_URL_Metrics class --- .../class-ilo-grouped-url-metrics.php | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php new file mode 100644 index 0000000000..fe2d2a2d07 --- /dev/null +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -0,0 +1,253 @@ + + */ + private $groups; + + /** + * Breakpoints in max widths. + * + * @var int[] + */ + private $breakpoints; + + /** + * Sample size for URL metrics for a given breakpoint. + * + * @var int + */ + private $sample_size; + + /** + * Freshness age (TTL) for a given URL metric. + * + * @var int + */ + private $freshness_ttl; + + /** + * Constructor. + * + * @param ILO_URL_Metric[] $url_metrics URL metrics. + * @param int[] $breakpoints Breakpoints in max widths. + * @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints. + * @param int $freshness_ttl Freshness age (TTL) for a given URL metric. + */ + public function __construct( array $url_metrics, array $breakpoints, int $sample_size, int $freshness_ttl ) { + $this->breakpoints = $breakpoints; + $this->sample_size = $sample_size; + $this->freshness_ttl = $freshness_ttl; + $this->groups = $this->ilo_group_url_metrics_by_breakpoint( $url_metrics ); + } + + /** + * Unshifts a new URL metric, potentially pushing out older URL metrics when exceeding the sample size. + * + * @since n.e.x.t + * @access private + * + * @param ILO_URL_Metric $new_url_metric New URL metric. + */ + public function ilo_unshift_url_metrics( ILO_URL_Metric $new_url_metric ) { + $url_metrics = array_merge( ...array_values( $this->groups ) ); + array_unshift( $url_metrics, $new_url_metric ); + + $grouped_url_metrics = $this->ilo_group_url_metrics_by_breakpoint( $url_metrics ); + + // Make sure there is at most $sample_size number of URL metrics for each breakpoint. + $grouped_url_metrics = array_map( + function ( $breakpoint_url_metrics ) { + if ( count( $breakpoint_url_metrics ) > $this->sample_size ) { + + // Sort URL metrics in descending order by timestamp. + usort( + $breakpoint_url_metrics, + static function ( ILO_URL_Metric $a, ILO_URL_Metric $b ): int { + return $b->get_timestamp() <=> $a->get_timestamp(); + } + ); + + // Only keep the sample size of the newest URL metrics. + $breakpoint_url_metrics = array_slice( $breakpoint_url_metrics, 0, $this->sample_size ); + } + return $breakpoint_url_metrics; + }, + $grouped_url_metrics + ); + + $this->groups = $grouped_url_metrics; + } + + + /** + * Groups URL metrics by breakpoint. + * + * @since n.e.x.t + * @access private + * + * @param ILO_URL_Metric[] $url_metrics URL metrics. + * @return array URL metrics grouped by breakpoint. The array keys are the minimum widths for a viewport to lie within + * the breakpoint. The returned array is always one larger than the provided array of breakpoints, since + * the breakpoints reflect the max inclusive boundaries whereas the return value is the groups of page + * metrics with viewports on either side of the breakpoint boundaries. + */ + private function ilo_group_url_metrics_by_breakpoint( array $url_metrics ): array { + + // Convert breakpoint max widths into viewport minimum widths. + $minimum_viewport_widths = array_map( + static function ( $breakpoint ) { + return $breakpoint + 1; + }, + $this->breakpoints + ); + + $grouped = array_fill_keys( array_merge( array( 0 ), $minimum_viewport_widths ), array() ); + + foreach ( $url_metrics as $url_metric ) { + $current_minimum_viewport = 0; + foreach ( $minimum_viewport_widths as $viewport_minimum_width ) { + if ( $url_metric->get_viewport()['width'] > $viewport_minimum_width ) { + $current_minimum_viewport = $viewport_minimum_width; + } else { + break; + } + } + + $grouped[ $current_minimum_viewport ][] = $url_metric; + } + return $grouped; + } + + /** + * Gets needed minimum viewport widths. + * + * @since n.e.x.t + * @access private + * + * @param float $current_time Current time, defaults to `microtime(true)`. + * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. + */ + public function ilo_get_needed_minimum_viewport_widths( float $current_time = null ): array { + if ( null === $current_time ) { + $current_time = microtime( true ); + } + + $needed_minimum_viewport_widths = array(); + foreach ( $this->groups as $minimum_viewport_width => $viewport_url_metrics ) { + $needs_url_metrics = false; + if ( count( $viewport_url_metrics ) < $this->sample_size ) { + $needs_url_metrics = true; + } else { + foreach ( $viewport_url_metrics as $url_metric ) { + if ( $url_metric->get_timestamp() + $this->freshness_ttl < $current_time ) { + $needs_url_metrics = true; + break; + } + } + } + $needed_minimum_viewport_widths[] = array( + $minimum_viewport_width, + $needs_url_metrics, + ); + } + + return $needed_minimum_viewport_widths; + } + + /** + * 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 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 + * + * @return array LCP elements keyed by its minimum viewport width. If there is no supported LCP element at a breakpoint, then `false` is used. + */ + public function ilo_get_lcp_elements_by_minimum_viewport_widths(): array { + $lcp_element_by_viewport_minimum_width = array(); + foreach ( $this->groups as $viewport_minimum_width => $breakpoint_url_metrics ) { + + // The following arrays all share array indices. + $seen_breadcrumbs = array(); + $breadcrumb_counts = array(); + $breadcrumb_element = array(); + + foreach ( $breakpoint_url_metrics as $breakpoint_url_metric ) { + foreach ( $breakpoint_url_metric->get_elements() as $element ) { + if ( ! $element['isLCP'] ) { + continue; + } + + $i = array_search( $element['xpath'], $seen_breadcrumbs, true ); + if ( false === $i ) { + $i = count( $seen_breadcrumbs ); + $seen_breadcrumbs[ $i ] = $element['xpath']; + $breadcrumb_counts[ $i ] = 0; + } + + $breadcrumb_counts[ $i ] += 1; + $breadcrumb_element[ $i ] = $element; + break; // We found the LCP element for the URL metric, go to the next URL metric. + } + } + + // Now sort by the breadcrumb counts in descending order, so the remaining first key is the most common breadcrumb. + if ( $seen_breadcrumbs ) { + arsort( $breadcrumb_counts ); + $most_common_breadcrumb_index = key( $breadcrumb_counts ); + + $lcp_element_by_viewport_minimum_width[ $viewport_minimum_width ] = $breadcrumb_element[ $most_common_breadcrumb_index ]; + } elseif ( ! empty( $breakpoint_url_metrics ) ) { + $lcp_element_by_viewport_minimum_width[ $viewport_minimum_width ] = false; // No LCP image at this breakpoint. + } + } + + // Now merge the breakpoints when there is an LCP element common between them. + $prev_lcp_element = null; + return array_filter( + $lcp_element_by_viewport_minimum_width, + static function ( $lcp_element ) use ( &$prev_lcp_element ) { + $include = ( + // First element in list. + null === $prev_lcp_element + || + ( is_array( $prev_lcp_element ) && is_array( $lcp_element ) + ? + // This breakpoint and previous breakpoint had LCP element, and they were not the same element. + $prev_lcp_element['xpath'] !== $lcp_element['xpath'] + : + // This LCP element and the last LCP element were not the same. In this case, either variable may be + // false or an array, but both cannot be an array. If both are false, we don't want to include since + // it is the same. If one is an array and the other is false, then do want to include because this + // indicates a difference at this breakpoint. + $prev_lcp_element !== $lcp_element + ) + ); + $prev_lcp_element = $lcp_element; + return $include; + } + ); + } +} From f32a0e5f889e31f5af8a30701c3a5a55a1b37504 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 26 Feb 2024 15:03:16 -0800 Subject: [PATCH 259/371] Update code references to utilize new group class --- .../class-ilo-grouped-url-metrics.php | 31 ++- .../class-ilo-url-metric.php | 4 +- .../image-loading-optimization/load.php | 1 + .../optimization.php | 15 +- .../storage/data.php | 200 ------------------ .../storage/post-type.php | 18 +- .../storage/rest-api.php | 5 +- 7 files changed, 56 insertions(+), 218 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index fe2d2a2d07..8cf669d398 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -66,7 +66,7 @@ public function __construct( array $url_metrics, array $breakpoints, int $sample * @param ILO_URL_Metric $new_url_metric New URL metric. */ public function ilo_unshift_url_metrics( ILO_URL_Metric $new_url_metric ) { - $url_metrics = array_merge( ...array_values( $this->groups ) ); + $url_metrics = $this->flatten(); array_unshift( $url_metrics, $new_url_metric ); $grouped_url_metrics = $this->ilo_group_url_metrics_by_breakpoint( $url_metrics ); @@ -250,4 +250,33 @@ static function ( $lcp_element ) use ( &$prev_lcp_element ) { } ); } + + /** + * Checks whether all groups have URL metrics. + * + * @return bool + */ + public function all_breakpoints_have_url_metrics(): bool { + return count( array_filter( $this->groups ) ) === count( $this->breakpoints ) + 1; + + // TODO: The following should be the same as the above, but simpler. + foreach ( $this->groups as $group ) { + if ( empty( $group ) ) { + return false; + } + } + return true; + + } + + /** + * Flatten groups of URL metrics into an array of URL metrics. + * + * @return ILO_URL_Metric[] URL metrics. + */ + public function flatten(): array { + return array_merge( + ...array_values( $this->groups ) + ); + } } diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index 0d05782c75..afd4228e05 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -172,9 +172,9 @@ public function get_elements(): array { } /** - * Gets the JSON representation of the object. + * Specifies data which should be serialized to JSON. * - * @return array + * @return array Data which can be serialized by json_encode(). */ public function jsonSerialize(): array { return $this->data; diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index 73741da3eb..082f5e2f84 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -19,6 +19,7 @@ // Storage logic. require_once __DIR__ . '/class-ilo-url-metric.php'; +require_once __DIR__ . '/class-ilo-grouped-url-metrics.php'; require_once __DIR__ . '/storage/lock.php'; require_once __DIR__ . '/storage/post-type.php'; require_once __DIR__ . '/storage/data.php'; diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 9d935f7e94..3aa324ea72 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -149,16 +149,15 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); $post = ilo_get_url_metrics_post( $slug ); - $url_metrics = $post ? ilo_parse_stored_url_metrics( $post ) : array(); - - $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( - $url_metrics, - microtime( true ), + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( + $post ? ilo_parse_stored_url_metrics( $post ) : array(), ilo_get_breakpoint_max_widths(), ilo_get_url_metrics_breakpoint_sample_size(), ilo_get_url_metric_freshness_ttl() ); + $needed_minimum_viewport_widths = $grouped_url_metrics->ilo_get_needed_minimum_viewport_widths( microtime( true ) ); + // Whether we need to add the data-ilo-xpath attribute to elements and whether the detection script should be injected. $needs_detection = in_array( true, @@ -167,10 +166,8 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { 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 ); - $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $url_metrics_grouped_by_breakpoint ); - $all_breakpoints_have_url_metrics = count( array_filter( $url_metrics_grouped_by_breakpoint ) ) === count( $breakpoint_max_widths ) + 1; + $lcp_elements_by_minimum_viewport_widths = $grouped_url_metrics->ilo_get_lcp_elements_by_minimum_viewport_widths(); + $all_breakpoints_have_url_metrics = $grouped_url_metrics->all_breakpoints_have_url_metrics(); /** * Optimized lookup of the LCP element viewport widths by XPath. diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 99ad6bb2da..587e94735c 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -115,47 +115,6 @@ function ilo_verify_url_metrics_storage_nonce( string $nonce, string $slug ): bo return (bool) wp_verify_nonce( $nonce, "store_url_metrics:$slug" ); } -/** - * Unshifts a new URL metric onto an array of URL metrics. - * - * @since n.e.x.t - * @access private - * - * @param ILO_URL_Metric[] $url_metrics Existing URL metrics. Each URL metric is expected to have a timestamp key. - * @param ILO_URL_Metric $new_url_metric New URL metric. - * @param int[] $breakpoints Breakpoint max widths. - * @param int $sample_size Sample size for URL metrics at a given breakpoint. - * - * @return ILO_URL_Metric[] Updated URL metrics. - */ -function ilo_unshift_url_metrics( array $url_metrics, ILO_URL_Metric $new_url_metric, array $breakpoints, int $sample_size ): array { - array_unshift( $url_metrics, $new_url_metric ); - $grouped_url_metrics = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoints ); - - // 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 ( ILO_URL_Metric $a, ILO_URL_Metric $b ): int { - return $b->get_timestamp() <=> $a->get_timestamp(); - } - ); - - // 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 ); -} - /** * Gets the breakpoint max widths to group URL metrics for various viewports. * @@ -224,162 +183,3 @@ function ilo_get_url_metrics_breakpoint_sample_size(): int { */ return (int) apply_filters( 'ilo_url_metrics_breakpoint_sample_size', 3 ); } - -/** - * Groups URL metrics by breakpoint. - * - * @since n.e.x.t - * @access private - * - * @param ILO_URL_Metric[] $url_metrics URL metrics. - * @param int[] $breakpoints Viewport breakpoint max widths, sorted in ascending order. - * @return array URL metrics grouped by breakpoint. The array keys are the minimum widths for a viewport to lie within - * the breakpoint. The returned array is always one larger than the provided array of breakpoints, since - * the breakpoints reflect the max inclusive boundaries whereas the return value is the groups of page - * metrics with viewports on either side of the breakpoint boundaries. - */ -function ilo_group_url_metrics_by_breakpoint( array $url_metrics, array $breakpoints ): array { - - // Convert breakpoint max widths into viewport minimum widths. - $minimum_viewport_widths = array_map( - static function ( $breakpoint ) { - return $breakpoint + 1; - }, - $breakpoints - ); - - $grouped = array_fill_keys( array_merge( array( 0 ), $minimum_viewport_widths ), array() ); - - foreach ( $url_metrics as $url_metric ) { - $current_minimum_viewport = 0; - foreach ( $minimum_viewport_widths as $viewport_minimum_width ) { - if ( $url_metric->get_viewport()['width'] > $viewport_minimum_width ) { - $current_minimum_viewport = $viewport_minimum_width; - } else { - break; - } - } - - $grouped[ $current_minimum_viewport ][] = $url_metric; - } - return $grouped; -} - -/** - * 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 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 supported LCP element at a breakpoint, then `false` is used. - */ -function ilo_get_lcp_elements_by_minimum_viewport_widths( array $grouped_url_metrics ): array { - - $lcp_element_by_viewport_minimum_width = array(); - foreach ( $grouped_url_metrics as $viewport_minimum_width => $breakpoint_url_metrics ) { - - // The following arrays all share array indices. - $seen_breadcrumbs = array(); - $breadcrumb_counts = array(); - $breadcrumb_element = array(); - - foreach ( $breakpoint_url_metrics as $breakpoint_url_metric ) { - foreach ( $breakpoint_url_metric->get_elements() as $element ) { - if ( ! $element['isLCP'] ) { - continue; - } - - $i = array_search( $element['xpath'], $seen_breadcrumbs, true ); - if ( false === $i ) { - $i = count( $seen_breadcrumbs ); - $seen_breadcrumbs[ $i ] = $element['xpath']; - $breadcrumb_counts[ $i ] = 0; - } - - $breadcrumb_counts[ $i ] += 1; - $breadcrumb_element[ $i ] = $element; - break; // We found the LCP element for the URL metric, go to the next URL metric. - } - } - - // Now sort by the breadcrumb counts in descending order, so the remaining first key is the most common breadcrumb. - if ( $seen_breadcrumbs ) { - arsort( $breadcrumb_counts ); - $most_common_breadcrumb_index = key( $breadcrumb_counts ); - - $lcp_element_by_viewport_minimum_width[ $viewport_minimum_width ] = $breadcrumb_element[ $most_common_breadcrumb_index ]; - } elseif ( ! empty( $breakpoint_url_metrics ) ) { - $lcp_element_by_viewport_minimum_width[ $viewport_minimum_width ] = false; // No LCP image at this breakpoint. - } - } - - // Now merge the breakpoints when there is an LCP element common between them. - $prev_lcp_element = null; - return array_filter( - $lcp_element_by_viewport_minimum_width, - static function ( $lcp_element ) use ( &$prev_lcp_element ) { - $include = ( - // First element in list. - null === $prev_lcp_element - || - ( is_array( $prev_lcp_element ) && is_array( $lcp_element ) - ? - // This breakpoint and previous breakpoint had LCP element, and they were not the same element. - $prev_lcp_element['xpath'] !== $lcp_element['xpath'] - : - // This LCP element and the last LCP element were not the same. In this case, either variable may be - // false or an array, but both cannot be an array. If both are false, we don't want to include since - // it is the same. If one is an array and the other is false, then do want to include because this - // indicates a difference at this breakpoint. - $prev_lcp_element !== $lcp_element - ) - ); - $prev_lcp_element = $lcp_element; - return $include; - } - ); -} - -/** - * Gets needed minimum viewport widths. - * - * @since n.e.x.t - * @access private - * - * @param ILO_URL_Metric[] $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. - * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. - */ -function ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $current_time, array $breakpoint_max_widths, int $sample_size, int $freshness_ttl ): array { - $metrics_by_breakpoint = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoint_max_widths ); - $needed_minimum_viewport_widths = array(); - foreach ( $metrics_by_breakpoint as $minimum_viewport_width => $viewport_url_metrics ) { - $needs_url_metrics = false; - if ( count( $viewport_url_metrics ) < $sample_size ) { - $needs_url_metrics = true; - } else { - foreach ( $viewport_url_metrics as $url_metric ) { - if ( $url_metric->get_timestamp() + $freshness_ttl < $current_time ) { - $needs_url_metrics = true; - break; - } - } - } - $needed_minimum_viewport_widths[] = array( - $minimum_viewport_width, - $needs_url_metrics, - ); - } - - return $needed_minimum_viewport_widths; -} diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index 5f3e391c5b..645e8bdb2e 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -168,12 +168,22 @@ function ilo_store_url_metric( string $url, string $slug, ILO_URL_Metric $new_ur $url_metrics = array(); } - $breakpoints = ilo_get_breakpoint_max_widths(); - $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); - $url_metrics = ilo_unshift_url_metrics( $url_metrics, $new_url_metric, $breakpoints, $sample_size ); + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( + $url_metrics, + ilo_get_breakpoint_max_widths(), + ilo_get_url_metrics_breakpoint_sample_size(), + ilo_get_url_metric_freshness_ttl() + ); + + $grouped_url_metrics->ilo_unshift_url_metrics( $new_url_metric ); $post_data['post_content'] = wp_json_encode( - $url_metrics, + array_map( + static function ( ILO_URL_Metric $url_metric ): array { + return $url_metric->jsonSerialize(); + }, + $grouped_url_metrics->flatten() + ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES // TODO: No need for pretty-printing. ); diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 1cb2c856d0..845e6fbe17 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -118,14 +118,15 @@ function ilo_register_endpoint() { function ilo_handle_rest_request( WP_REST_Request $request ) { $post = ilo_get_url_metrics_post( $request->get_param( 'slug' ) ); - $needed_minimum_viewport_widths = ilo_get_needed_minimum_viewport_widths( + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $post ? ilo_parse_stored_url_metrics( $post ) : array(), - microtime( true ), ilo_get_breakpoint_max_widths(), ilo_get_url_metrics_breakpoint_sample_size(), ilo_get_url_metric_freshness_ttl() ); + $needed_minimum_viewport_widths = $grouped_url_metrics->ilo_get_needed_minimum_viewport_widths( microtime( true ) ); + // Block the request if URL metrics aren't needed for the provided viewport width. // This logic is the same as the isViewportNeeded() function in detect.js. $viewport_width = $request->get_param( 'viewport' )['width']; From 4668efa1ce48bf5585ce085af8663c32e126d678 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 27 Feb 2024 16:24:34 -0800 Subject: [PATCH 260/371] Fix tests to use new ILO_Grouped_URL_Metrics --- .../class-ilo-grouped-url-metrics.php | 21 +- .../storage/data-tests.php | 191 +++++++++--------- 2 files changed, 118 insertions(+), 94 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index 8cf669d398..7a558161e0 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -12,7 +12,7 @@ * @since n.e.x.t * @access private */ -final class ILO_Grouped_URL_Metrics /*implements Iterator*/ { +final class ILO_Grouped_URL_Metrics { /** * URL metrics grouped by minimum viewport width for the provided breakpoints. @@ -57,6 +57,24 @@ public function __construct( array $url_metrics, array $breakpoints, int $sample $this->groups = $this->ilo_group_url_metrics_by_breakpoint( $url_metrics ); } + /** + * Gets grouped keyed by the minimum viewport width. + * + * @return array Groups. + */ + public function get_groups(): array { + return $this->groups; + } + + /** + * Gets minimum viewport widths for the groups of URL metrics divided by the breakpoints. + * + * @return int[] + */ + public function get_minimum_viewport_widths(): array { + return array_keys( $this->groups ); + } + /** * Unshifts a new URL metric, potentially pushing out older URL metrics when exceeding the sample size. * @@ -95,7 +113,6 @@ static function ( ILO_URL_Metric $a, ILO_URL_Metric $b ): int { $this->groups = $grouped_url_metrics; } - /** * Groups URL metrics by breakpoint. * diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 2002f9ef5c..27af7b42a8 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -192,63 +192,69 @@ public function data_provider_sample_size_and_breakpoints(): array { /** * Test ilo_unshift_url_metrics(). * - * @covers ::ilo_unshift_url_metrics + * @covers ILO_Grouped_URL_Metrics::ilo_unshift_url_metrics * * @dataProvider data_provider_sample_size_and_breakpoints */ public function test_ilo_unshift_url_metrics( int $sample_size, array $breakpoints, array $viewport_widths ) { - $old_timestamp = 1701978742; + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( array(), $breakpoints, $sample_size, HOUR_IN_SECONDS ); - // Fully populate the sample size for the breakpoints. - $all_url_metrics = array(); + // Over-populate the sample size for the breakpoints by a dozen. foreach ( $viewport_widths as $viewport_width ) { - for ( $i = 0; $i < $sample_size; $i++ ) { - $all_url_metrics = ilo_unshift_url_metrics( - $all_url_metrics, - $this->get_validated_url_metric( $viewport_width ), - $breakpoints, - $sample_size - ); + for ( $i = 0; $i < $sample_size + 12; $i++ ) { + $grouped_url_metrics->ilo_unshift_url_metrics( $this->get_validated_url_metric( $viewport_width ) ); } } $max_possible_url_metrics_count = $sample_size * ( count( $breakpoints ) + 1 ); $this->assertCount( $max_possible_url_metrics_count, - $all_url_metrics, + $grouped_url_metrics->flatten(), sprintf( 'Expected there to be exactly sample size (%d) times the number of breakpoint groups (which is %d + 1)', $sample_size, count( $breakpoints ) ) ); + } + + /** + * Test that ilo_unshift_url_metrics() pushes out old metrics. + * + * @covers ILO_Grouped_URL_Metrics::ilo_unshift_url_metrics + * + * @dataProvider data_provider_sample_size_and_breakpoints + * @throws Exception When a parse error happens. + */ + public function test_ilo_unshift_url_metrics_pushes_out_old_metrics( int $sample_size, array $breakpoints, array $viewport_widths ) { + $old_timestamp = microtime( true ) - ( HOUR_IN_SECONDS + 1 ); + + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( array(), $breakpoints, $sample_size, HOUR_IN_SECONDS ); - // Make sure that ilo_unshift_url_metrics() added a timestamp and then force them to all be old. - $all_url_metrics = array_map( - static function ( $url_metric ) use ( $old_timestamp ): ILO_URL_Metric { - return new ILO_URL_Metric( - array_merge( - $url_metric->jsonSerialize(), - array( - 'timestamp' => $old_timestamp, + // Populate the groups with stale URL metrics. + foreach ( $viewport_widths as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + $grouped_url_metrics->ilo_unshift_url_metrics( + new ILO_URL_Metric( + array_merge( + $this->get_validated_url_metric( $viewport_width )->jsonSerialize(), + array( + 'timestamp' => $old_timestamp, + ) ) ) ); - }, - $all_url_metrics - ); + } + } // Try adding one URL metric for each breakpoint group. foreach ( $viewport_widths as $viewport_width ) { - $all_url_metrics = ilo_unshift_url_metrics( - $all_url_metrics, - $this->get_validated_url_metric( $viewport_width ), - $breakpoints, - $sample_size - ); + $grouped_url_metrics->ilo_unshift_url_metrics( $this->get_validated_url_metric( $viewport_width ) ); } + + $max_possible_url_metrics_count = $sample_size * ( count( $breakpoints ) + 1 ); $this->assertCount( $max_possible_url_metrics_count, - $all_url_metrics, + $grouped_url_metrics->flatten(), 'Expected the total count of URL metrics to not exceed the multiple of the sample size.' ); $new_count = 0; - foreach ( $all_url_metrics as $url_metric ) { + foreach ( $grouped_url_metrics->flatten() as $url_metric ) { if ( $url_metric->get_timestamp() > $old_timestamp ) { ++$new_count; } @@ -316,7 +322,9 @@ public function data_provider_test_ilo_group_url_metrics_by_breakpoint(): array /** * Test ilo_group_url_metrics_by_breakpoint(). * - * @covers ::ilo_group_url_metrics_by_breakpoint + * @covers ILO_Grouped_URL_Metrics::ilo_group_url_metrics_by_breakpoint + * @covers ILO_Grouped_URL_Metrics::get_groups + * @covers ILO_Grouped_URL_Metrics::get_minimum_viewport_widths * * @dataProvider data_provider_test_ilo_group_url_metrics_by_breakpoint */ @@ -328,15 +336,17 @@ function ( $viewport_width ) { $viewport_widths ); - $grouped_url_metrics = ilo_group_url_metrics_by_breakpoint( $url_metrics, $breakpoints ); - $this->assertCount( count( $breakpoints ) + 1, $grouped_url_metrics, 'Expected number of breakpoint groups to always be one greater than the number of breakpoints.' ); - $minimum_viewport_widths = array_keys( $grouped_url_metrics ); + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, 3, HOUR_IN_SECONDS ); + + $this->assertCount( count( $breakpoints ) + 1, $grouped_url_metrics->get_groups(), 'Expected number of breakpoint groups to always be one greater than the number of breakpoints.' ); + $minimum_viewport_widths = $grouped_url_metrics->get_minimum_viewport_widths(); + $this->assertSame( array_keys( $grouped_url_metrics->get_groups() ), $minimum_viewport_widths ); $this->assertSame( 0, array_shift( $minimum_viewport_widths ), 'Expected the first minimum viewport width to always be zero.' ); foreach ( $breakpoints as $breakpoint ) { $this->assertSame( $breakpoint + 1, array_shift( $minimum_viewport_widths ) ); } - $minimum_viewport_widths = array_keys( $grouped_url_metrics ); + $minimum_viewport_widths = $grouped_url_metrics->get_minimum_viewport_widths(); for ( $i = 0, $len = count( $minimum_viewport_widths ); $i < $len; $i++ ) { $minimum_viewport_width = $minimum_viewport_widths[ $i ]; $maximum_viewport_width = $minimum_viewport_widths[ $i + 1 ] ?? null; @@ -349,7 +359,7 @@ function ( $viewport_width ) { $this->assertLessThan( $maximum_viewport_width, $minimum_viewport_width ); } - foreach ( $grouped_url_metrics[ $minimum_viewport_width ] as $url_metric ) { + foreach ( $grouped_url_metrics->get_groups()[ $minimum_viewport_width ] as $url_metric ) { $this->assertGreaterThanOrEqual( $minimum_viewport_width, $url_metric->get_viewport()['width'] ); if ( isset( $maximum_viewport_width ) ) { $this->assertLessThanOrEqual( $maximum_viewport_width, $url_metric->get_viewport()['width'] ); @@ -361,68 +371,62 @@ function ( $viewport_width ) { public function data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widths(): array { return array( 'common_lcp_element_across_breakpoints' => array( - 'grouped_url_metrics' => array( - 0 => array( - $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. - $this->get_validated_url_metric( 599, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - ), - 600 => array( - $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - ), - 800 => array( - $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - ), + 'breakpoints' => array( 600, 800 ), + 'url_metrics' => array( + // 0. + $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. + $this->get_validated_url_metric( 599, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + // 600. + $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + // 800. + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), ), 'expected_lcp_element_xpaths' => array( 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), ), ), 'different_lcp_elements_across_breakpoint' => array( - 'grouped_url_metrics' => array( - 0 => array( - $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. - $this->get_validated_url_metric( 599, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - ), - 600 => array( - $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - ), + 'breakpoints' => array( 600 ), + 'url_metrics' => array( + // 0. + $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. + $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + // 600. + $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), ), 'expected_lcp_element_xpaths' => array( 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), - 600 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + 601 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), ), ), 'same_lcp_element_across_non_consecutive_breakpoints' => array( - 'grouped_url_metrics' => array( - 0 => array( - $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - ), - 400 => array( - $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'HEADER', 'IMG' ), false ), - ), - 600 => array( - $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - ), + 'breakpoints' => array( 400, 600 ), + 'url_metrics' => array( + // 0. + $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + // 400. + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'HEADER', 'IMG' ), false ), + // 600. + $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), ), 'expected_lcp_element_xpaths' => array( 0 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), - 400 => false, // The (image) element is either not visible at this breakpoint or it is not LCP element. - 600 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + 401 => false, // The (image) element is either not visible at this breakpoint or it is not LCP element. + 601 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), ), ), 'no_lcp_image_elements' => array( - 'grouped_url_metrics' => array( - 0 => array( - $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'IMG' ), false ), - ), - 600 => array( - $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'IMG' ), false ), - ), + 'breakpoints' => array( 600 ), + 'url_metrics' => array( + // 0. + $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'IMG' ), false ), + // 600. + $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'IMG' ), false ), ), 'expected_lcp_element_xpaths' => array( 0 => false, @@ -434,11 +438,13 @@ public function data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widt /** * Test ilo_get_lcp_elements_by_minimum_viewport_widths(). * - * @covers ::ilo_get_lcp_elements_by_minimum_viewport_widths + * @covers ILO_Grouped_URL_Metrics::ilo_get_lcp_elements_by_minimum_viewport_widths * @dataProvider data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widths */ - public function test_ilo_get_lcp_elements_by_minimum_viewport_widths( array $grouped_url_metrics, array $expected_lcp_element_xpaths ) { - $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $grouped_url_metrics ); + public function test_ilo_get_lcp_elements_by_minimum_viewport_widths( array $breakpoints, array $url_metrics, array $expected_lcp_element_xpaths ) { + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, 10, HOUR_IN_SECONDS ); + + $lcp_elements_by_minimum_viewport_widths = $grouped_url_metrics->ilo_get_lcp_elements_by_minimum_viewport_widths(); $lcp_element_xpaths_by_minimum_viewport_widths = array(); foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { @@ -466,7 +472,7 @@ public function data_provider_test_ilo_get_needed_minimum_viewport_widths(): arr $current_time = microtime( true ); $none_needed_data = array( - 'url_metrics' => ( function () use ( $current_time ): array { + 'url_metrics' => ( function () use ( $current_time ): array { return array_merge( array_fill( 0, @@ -490,10 +496,10 @@ public function data_provider_test_ilo_get_needed_minimum_viewport_widths(): arr ) ); } )(), - 'current_time' => $current_time, - 'breakpoint_max_widths' => array( 480 ), - 'sample_size' => 3, - 'freshness_ttl' => HOUR_IN_SECONDS, + 'current_time' => $current_time, + 'breakpoints' => array( 480 ), + 'sample_size' => 3, + 'freshness_ttl' => HOUR_IN_SECONDS, ); return array( @@ -540,14 +546,15 @@ public function data_provider_test_ilo_get_needed_minimum_viewport_widths(): arr /** * Test ilo_get_needed_minimum_viewport_widths(). * - * @covers ::ilo_get_needed_minimum_viewport_widths + * @covers ILO_Grouped_URL_Metrics::ilo_get_needed_minimum_viewport_widths * * @dataProvider data_provider_test_ilo_get_needed_minimum_viewport_widths */ - public function test_ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $current_time, array $breakpoint_max_widths, int $sample_size, int $freshness_ttl, array $expected ) { + public function test_ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $current_time, array $breakpoints, int $sample_size, int $freshness_ttl, array $expected ) { + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, $sample_size, $freshness_ttl ); $this->assertSame( $expected, - ilo_get_needed_minimum_viewport_widths( $url_metrics, $current_time, $breakpoint_max_widths, $sample_size, $freshness_ttl ) + $grouped_url_metrics->ilo_get_needed_minimum_viewport_widths() ); } From abea08f49ad714e23a002ad2d797724d40b24cba Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 27 Feb 2024 16:26:06 -0800 Subject: [PATCH 261/371] Simplify all_breakpoints_have_url_metrics method --- .../class-ilo-grouped-url-metrics.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index 7a558161e0..410a029c93 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -274,16 +274,12 @@ static function ( $lcp_element ) use ( &$prev_lcp_element ) { * @return bool */ public function all_breakpoints_have_url_metrics(): bool { - return count( array_filter( $this->groups ) ) === count( $this->breakpoints ) + 1; - - // TODO: The following should be the same as the above, but simpler. foreach ( $this->groups as $group ) { if ( empty( $group ) ) { return false; } } return true; - } /** From f4a62e846af3b501dfee2706a4adcb51d472d862 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 27 Feb 2024 16:34:31 -0800 Subject: [PATCH 262/371] Update method names on ILO_Grouped_URL_Metrics --- .../class-ilo-grouped-url-metrics.php | 18 +++++++++--------- .../optimization.php | 6 +++--- .../storage/post-type.php | 2 +- .../storage/rest-api.php | 2 +- .../storage/data-tests.php | 19 +++++++++---------- 5 files changed, 23 insertions(+), 24 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index 410a029c93..417d56a264 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -54,7 +54,7 @@ public function __construct( array $url_metrics, array $breakpoints, int $sample $this->breakpoints = $breakpoints; $this->sample_size = $sample_size; $this->freshness_ttl = $freshness_ttl; - $this->groups = $this->ilo_group_url_metrics_by_breakpoint( $url_metrics ); + $this->groups = $this->group_url_metrics_by_breakpoint( $url_metrics ); } /** @@ -83,11 +83,11 @@ public function get_minimum_viewport_widths(): array { * * @param ILO_URL_Metric $new_url_metric New URL metric. */ - public function ilo_unshift_url_metrics( ILO_URL_Metric $new_url_metric ) { + public function add( ILO_URL_Metric $new_url_metric ) { $url_metrics = $this->flatten(); array_unshift( $url_metrics, $new_url_metric ); - $grouped_url_metrics = $this->ilo_group_url_metrics_by_breakpoint( $url_metrics ); + $grouped_url_metrics = $this->group_url_metrics_by_breakpoint( $url_metrics ); // Make sure there is at most $sample_size number of URL metrics for each breakpoint. $grouped_url_metrics = array_map( @@ -125,7 +125,7 @@ static function ( ILO_URL_Metric $a, ILO_URL_Metric $b ): int { * the breakpoints reflect the max inclusive boundaries whereas the return value is the groups of page * metrics with viewports on either side of the breakpoint boundaries. */ - private function ilo_group_url_metrics_by_breakpoint( array $url_metrics ): array { + private function group_url_metrics_by_breakpoint( array $url_metrics ): array { // Convert breakpoint max widths into viewport minimum widths. $minimum_viewport_widths = array_map( @@ -161,7 +161,7 @@ static function ( $breakpoint ) { * @param float $current_time Current time, defaults to `microtime(true)`. * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. */ - public function ilo_get_needed_minimum_viewport_widths( float $current_time = null ): array { + public function get_needed_minimum_viewport_widths( float $current_time = null ): array { if ( null === $current_time ) { $current_time = microtime( true ); } @@ -202,7 +202,7 @@ public function ilo_get_needed_minimum_viewport_widths( float $current_time = nu * * @return array LCP elements keyed by its minimum viewport width. If there is no supported LCP element at a breakpoint, then `false` is used. */ - public function ilo_get_lcp_elements_by_minimum_viewport_widths(): array { + public function get_lcp_elements_by_minimum_viewport_widths(): array { $lcp_element_by_viewport_minimum_width = array(); foreach ( $this->groups as $viewport_minimum_width => $breakpoint_url_metrics ) { @@ -271,9 +271,9 @@ static function ( $lcp_element ) use ( &$prev_lcp_element ) { /** * Checks whether all groups have URL metrics. * - * @return bool + * @return bool Whether all groups have URL metrics. */ - public function all_breakpoints_have_url_metrics(): bool { + public function are_all_groups_populated(): bool { foreach ( $this->groups as $group ) { if ( empty( $group ) ) { return false; @@ -285,7 +285,7 @@ public function all_breakpoints_have_url_metrics(): bool { /** * Flatten groups of URL metrics into an array of URL metrics. * - * @return ILO_URL_Metric[] URL metrics. + * @return ILO_URL_Metric[] Ungrouped URL metrics. */ public function flatten(): array { return array_merge( diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 3aa324ea72..3623f366fe 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -156,7 +156,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { ilo_get_url_metric_freshness_ttl() ); - $needed_minimum_viewport_widths = $grouped_url_metrics->ilo_get_needed_minimum_viewport_widths( microtime( true ) ); + $needed_minimum_viewport_widths = $grouped_url_metrics->get_needed_minimum_viewport_widths( microtime( true ) ); // Whether we need to add the data-ilo-xpath attribute to elements and whether the detection script should be injected. $needs_detection = in_array( @@ -166,8 +166,8 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { true ); - $lcp_elements_by_minimum_viewport_widths = $grouped_url_metrics->ilo_get_lcp_elements_by_minimum_viewport_widths(); - $all_breakpoints_have_url_metrics = $grouped_url_metrics->all_breakpoints_have_url_metrics(); + $lcp_elements_by_minimum_viewport_widths = $grouped_url_metrics->get_lcp_elements_by_minimum_viewport_widths(); + $all_breakpoints_have_url_metrics = $grouped_url_metrics->are_all_groups_populated(); /** * Optimized lookup of the LCP element viewport widths by XPath. diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index 645e8bdb2e..4ac6041b1a 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -175,7 +175,7 @@ function ilo_store_url_metric( string $url, string $slug, ILO_URL_Metric $new_ur ilo_get_url_metric_freshness_ttl() ); - $grouped_url_metrics->ilo_unshift_url_metrics( $new_url_metric ); + $grouped_url_metrics->add( $new_url_metric ); $post_data['post_content'] = wp_json_encode( array_map( diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 845e6fbe17..0036829efc 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -125,7 +125,7 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { ilo_get_url_metric_freshness_ttl() ); - $needed_minimum_viewport_widths = $grouped_url_metrics->ilo_get_needed_minimum_viewport_widths( microtime( true ) ); + $needed_minimum_viewport_widths = $grouped_url_metrics->get_needed_minimum_viewport_widths( microtime( true ) ); // Block the request if URL metrics aren't needed for the provided viewport width. // This logic is the same as the isViewportNeeded() function in detect.js. diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 27af7b42a8..c23f22210a 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -192,7 +192,7 @@ public function data_provider_sample_size_and_breakpoints(): array { /** * Test ilo_unshift_url_metrics(). * - * @covers ILO_Grouped_URL_Metrics::ilo_unshift_url_metrics + * @covers ILO_Grouped_URL_Metrics::add * * @dataProvider data_provider_sample_size_and_breakpoints */ @@ -202,7 +202,7 @@ public function test_ilo_unshift_url_metrics( int $sample_size, array $breakpoin // Over-populate the sample size for the breakpoints by a dozen. foreach ( $viewport_widths as $viewport_width ) { for ( $i = 0; $i < $sample_size + 12; $i++ ) { - $grouped_url_metrics->ilo_unshift_url_metrics( $this->get_validated_url_metric( $viewport_width ) ); + $grouped_url_metrics->add( $this->get_validated_url_metric( $viewport_width ) ); } } $max_possible_url_metrics_count = $sample_size * ( count( $breakpoints ) + 1 ); @@ -216,7 +216,7 @@ public function test_ilo_unshift_url_metrics( int $sample_size, array $breakpoin /** * Test that ilo_unshift_url_metrics() pushes out old metrics. * - * @covers ILO_Grouped_URL_Metrics::ilo_unshift_url_metrics + * @covers ILO_Grouped_URL_Metrics::add * * @dataProvider data_provider_sample_size_and_breakpoints * @throws Exception When a parse error happens. @@ -229,7 +229,7 @@ public function test_ilo_unshift_url_metrics_pushes_out_old_metrics( int $sample // Populate the groups with stale URL metrics. foreach ( $viewport_widths as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - $grouped_url_metrics->ilo_unshift_url_metrics( + $grouped_url_metrics->add( new ILO_URL_Metric( array_merge( $this->get_validated_url_metric( $viewport_width )->jsonSerialize(), @@ -244,7 +244,7 @@ public function test_ilo_unshift_url_metrics_pushes_out_old_metrics( int $sample // Try adding one URL metric for each breakpoint group. foreach ( $viewport_widths as $viewport_width ) { - $grouped_url_metrics->ilo_unshift_url_metrics( $this->get_validated_url_metric( $viewport_width ) ); + $grouped_url_metrics->add( $this->get_validated_url_metric( $viewport_width ) ); } $max_possible_url_metrics_count = $sample_size * ( count( $breakpoints ) + 1 ); @@ -322,7 +322,6 @@ public function data_provider_test_ilo_group_url_metrics_by_breakpoint(): array /** * Test ilo_group_url_metrics_by_breakpoint(). * - * @covers ILO_Grouped_URL_Metrics::ilo_group_url_metrics_by_breakpoint * @covers ILO_Grouped_URL_Metrics::get_groups * @covers ILO_Grouped_URL_Metrics::get_minimum_viewport_widths * @@ -438,13 +437,13 @@ public function data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widt /** * Test ilo_get_lcp_elements_by_minimum_viewport_widths(). * - * @covers ILO_Grouped_URL_Metrics::ilo_get_lcp_elements_by_minimum_viewport_widths + * @covers ILO_Grouped_URL_Metrics::get_lcp_elements_by_minimum_viewport_widths * @dataProvider data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widths */ public function test_ilo_get_lcp_elements_by_minimum_viewport_widths( array $breakpoints, array $url_metrics, array $expected_lcp_element_xpaths ) { $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, 10, HOUR_IN_SECONDS ); - $lcp_elements_by_minimum_viewport_widths = $grouped_url_metrics->ilo_get_lcp_elements_by_minimum_viewport_widths(); + $lcp_elements_by_minimum_viewport_widths = $grouped_url_metrics->get_lcp_elements_by_minimum_viewport_widths(); $lcp_element_xpaths_by_minimum_viewport_widths = array(); foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { @@ -546,7 +545,7 @@ public function data_provider_test_ilo_get_needed_minimum_viewport_widths(): arr /** * Test ilo_get_needed_minimum_viewport_widths(). * - * @covers ILO_Grouped_URL_Metrics::ilo_get_needed_minimum_viewport_widths + * @covers ILO_Grouped_URL_Metrics::get_needed_minimum_viewport_widths * * @dataProvider data_provider_test_ilo_get_needed_minimum_viewport_widths */ @@ -554,7 +553,7 @@ public function test_ilo_get_needed_minimum_viewport_widths( array $url_metrics, $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, $sample_size, $freshness_ttl ); $this->assertSame( $expected, - $grouped_url_metrics->ilo_get_needed_minimum_viewport_widths() + $grouped_url_metrics->get_needed_minimum_viewport_widths() ); } From 3010833c349380a55322ae0b0bd4d741d977a7c6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 27 Feb 2024 16:57:23 -0800 Subject: [PATCH 263/371] Move ILO_Grouped_URL_Metrics tests into separate file and add coverage --- .../class-ilo-grouped-url-metrics-tests.php | 460 ++++++++++++++++++ .../storage/data-tests.php | 386 --------------- 2 files changed, 460 insertions(+), 386 deletions(-) create mode 100644 tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php diff --git a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php new file mode 100644 index 0000000000..a4c889787a --- /dev/null +++ b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php @@ -0,0 +1,460 @@ + array( + 'sample_size' => 3, + 'breakpoints' => array( 480, 782 ), + 'viewport_widths' => array( 400, 600, 800 ), + ), + '1 sample size and 1 breakpoint' => array( + 'sample_size' => 1, + 'breakpoints' => array( 480 ), + 'viewport_widths' => array( 400, 800 ), + ), + ); + } + + /** + * Test add(). + * + * @covers ::add + * + * @dataProvider data_provider_sample_size_and_breakpoints + */ + public function test_add( int $sample_size, array $breakpoints, array $viewport_widths ) { + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( array(), $breakpoints, $sample_size, HOUR_IN_SECONDS ); + + // Over-populate the sample size for the breakpoints by a dozen. + foreach ( $viewport_widths as $viewport_width ) { + for ( $i = 0; $i < $sample_size + 12; $i++ ) { + $grouped_url_metrics->add( $this->get_validated_url_metric( $viewport_width ) ); + } + } + $max_possible_url_metrics_count = $sample_size * ( count( $breakpoints ) + 1 ); + $this->assertCount( + $max_possible_url_metrics_count, + $grouped_url_metrics->flatten(), + sprintf( 'Expected there to be exactly sample size (%d) times the number of breakpoint groups (which is %d + 1)', $sample_size, count( $breakpoints ) ) + ); + } + + /** + * Test that add() pushes out old metrics. + * + * @covers ::add + * + * @dataProvider data_provider_sample_size_and_breakpoints + * @throws Exception When a parse error happens. + */ + public function test_adding_pushes_out_old_metrics( int $sample_size, array $breakpoints, array $viewport_widths ) { + $old_timestamp = microtime( true ) - ( HOUR_IN_SECONDS + 1 ); + + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( array(), $breakpoints, $sample_size, HOUR_IN_SECONDS ); + + // Populate the groups with stale URL metrics. + foreach ( $viewport_widths as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + $grouped_url_metrics->add( + new ILO_URL_Metric( + array_merge( + $this->get_validated_url_metric( $viewport_width )->jsonSerialize(), + array( + 'timestamp' => $old_timestamp, + ) + ) + ) + ); + } + } + + // Try adding one URL metric for each breakpoint group. + foreach ( $viewport_widths as $viewport_width ) { + $grouped_url_metrics->add( $this->get_validated_url_metric( $viewport_width ) ); + } + + $max_possible_url_metrics_count = $sample_size * ( count( $breakpoints ) + 1 ); + $this->assertCount( + $max_possible_url_metrics_count, + $grouped_url_metrics->flatten(), + 'Expected the total count of URL metrics to not exceed the multiple of the sample size.' + ); + $new_count = 0; + foreach ( $grouped_url_metrics->flatten() as $url_metric ) { + if ( $url_metric->get_timestamp() > $old_timestamp ) { + ++$new_count; + } + } + $this->assertGreaterThan( 0, $new_count, 'Expected there to be at least one new URL metric.' ); + $this->assertSame( count( $viewport_widths ), $new_count, 'Expected the new URL metrics to all have been added.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_provider_test_get_groups_and_get_minimum_viewport_widths(): array { + return array( + '2-breakpoints-and-3-viewport-widths' => array( + 'breakpoints' => array( 480, 640 ), + 'viewport_widths' => array( 400, 480, 800 ), + ), + '1-breakpoint-and-4-viewport-widths' => array( + 'breakpoints' => array( 480 ), + 'viewport_widths' => array( 400, 600, 800, 1000 ), + ), + ); + } + + /** + * Test get_groups() and get_minimum_viewport_widths(). + * + * @covers ::get_groups + * @covers ::get_minimum_viewport_widths + * + * @dataProvider data_provider_test_get_groups_and_get_minimum_viewport_widths + */ + public function test_get_groups_and_get_minimum_viewport_widths( array $breakpoints, array $viewport_widths ) { + $url_metrics = array_map( + function ( $viewport_width ) { + return $this->get_validated_url_metric( $viewport_width ); + }, + $viewport_widths + ); + + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, 3, HOUR_IN_SECONDS ); + + $this->assertCount( count( $breakpoints ) + 1, $grouped_url_metrics->get_groups(), 'Expected number of breakpoint groups to always be one greater than the number of breakpoints.' ); + $minimum_viewport_widths = $grouped_url_metrics->get_minimum_viewport_widths(); + $this->assertSame( array_keys( $grouped_url_metrics->get_groups() ), $minimum_viewport_widths ); + $this->assertSame( 0, array_shift( $minimum_viewport_widths ), 'Expected the first minimum viewport width to always be zero.' ); + foreach ( $breakpoints as $breakpoint ) { + $this->assertSame( $breakpoint + 1, array_shift( $minimum_viewport_widths ) ); + } + + $minimum_viewport_widths = $grouped_url_metrics->get_minimum_viewport_widths(); + for ( $i = 0, $len = count( $minimum_viewport_widths ); $i < $len; $i++ ) { + $minimum_viewport_width = $minimum_viewport_widths[ $i ]; + $maximum_viewport_width = $minimum_viewport_widths[ $i + 1 ] ?? null; + if ( 0 === $i ) { + $this->assertSame( 0, $minimum_viewport_width ); + } else { + $this->assertGreaterThan( 0, $minimum_viewport_width ); + } + if ( isset( $maximum_viewport_width ) ) { + $this->assertLessThan( $maximum_viewport_width, $minimum_viewport_width ); + } + + foreach ( $grouped_url_metrics->get_groups()[ $minimum_viewport_width ] as $url_metric ) { + $this->assertGreaterThanOrEqual( $minimum_viewport_width, $url_metric->get_viewport()['width'] ); + if ( isset( $maximum_viewport_width ) ) { + $this->assertLessThanOrEqual( $maximum_viewport_width, $url_metric->get_viewport()['width'] ); + } + } + } + } + + /** + * Data provider. + * + * @throws Exception When invalid URL metric (which there should not be). + * @return array[] + */ + public function data_provider_test_get_lcp_elements_by_minimum_viewport_widths(): array { + return array( + 'common_lcp_element_across_breakpoints' => array( + 'breakpoints' => array( 600, 800 ), + 'url_metrics' => array( + // 0. + $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. + $this->get_validated_url_metric( 599, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + // 600. + $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + // 800. + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), + ), + ), + 'different_lcp_elements_across_breakpoint' => array( + 'breakpoints' => array( 600 ), + 'url_metrics' => array( + // 0. + $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. + $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + // 600. + $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), + 601 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + ), + ), + 'same_lcp_element_across_non_consecutive_breakpoints' => array( + 'breakpoints' => array( 400, 600 ), + 'url_metrics' => array( + // 0. + $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + // 400. + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'HEADER', 'IMG' ), false ), + // 600. + $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + 401 => false, // The (image) element is either not visible at this breakpoint or it is not LCP element. + 601 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + ), + ), + 'no_lcp_image_elements' => array( + 'breakpoints' => array( 600 ), + 'url_metrics' => array( + // 0. + $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'IMG' ), false ), + // 600. + $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'IMG' ), false ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => false, + ), + ), + ); + } + + /** + * Test get_lcp_elements_by_minimum_viewport_widths(). + * + * @covers ::get_lcp_elements_by_minimum_viewport_widths + * @dataProvider data_provider_test_get_lcp_elements_by_minimum_viewport_widths + */ + public function test_get_lcp_elements_by_minimum_viewport_widths( array $breakpoints, array $url_metrics, array $expected_lcp_element_xpaths ) { + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, 10, HOUR_IN_SECONDS ); + + $lcp_elements_by_minimum_viewport_widths = $grouped_url_metrics->get_lcp_elements_by_minimum_viewport_widths(); + + $lcp_element_xpaths_by_minimum_viewport_widths = array(); + foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { + $this->assertTrue( is_array( $lcp_element ) || false === $lcp_element ); + if ( is_array( $lcp_element ) ) { + $this->assertTrue( $lcp_element['isLCP'] ); + $this->assertTrue( $lcp_element['isLCPCandidate'] ); + $this->assertIsString( $lcp_element['xpath'] ); + $this->assertIsNumeric( $lcp_element['intersectionRatio'] ); + $lcp_element_xpaths_by_minimum_viewport_widths[ $minimum_viewport_width ] = $lcp_element['xpath']; + } else { + $lcp_element_xpaths_by_minimum_viewport_widths[ $minimum_viewport_width ] = false; + } + } + + $this->assertSame( $expected_lcp_element_xpaths, $lcp_element_xpaths_by_minimum_viewport_widths ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_provider_test_get_needed_minimum_viewport_widths(): array { + $current_time = microtime( true ); + + $none_needed_data = array( + 'url_metrics' => ( function () use ( $current_time ): array { + return array_merge( + array_fill( + 0, + 3, + new ILO_URL_Metric( + array_merge( + $this->get_validated_url_metric( 400 )->jsonSerialize(), + array( 'timestamp' => $current_time ) + ) + ) + ), + array_fill( + 0, + 3, + new ILO_URL_Metric( + array_merge( + $this->get_validated_url_metric( 600 )->jsonSerialize(), + array( 'timestamp' => $current_time ) + ) + ) + ) + ); + } )(), + 'current_time' => $current_time, + 'breakpoints' => array( 480 ), + 'sample_size' => 3, + 'freshness_ttl' => HOUR_IN_SECONDS, + ); + + return array( + 'none-needed' => array_merge( + $none_needed_data, + array( + 'expected' => array( + array( 0, false ), + array( 481, false ), + ), + ) + ), + + 'not-enough-url-metrics' => array_merge( + $none_needed_data, + array( + 'sample_size' => $none_needed_data['sample_size'] + 1, + ), + array( + 'expected' => array( + array( 0, true ), + array( 481, true ), + ), + ) + ), + + 'url-metric-too-old' => array_merge( + ( static function ( $data ): array { + $url_metrics_data = $data['url_metrics'][0]->jsonSerialize(); + $url_metrics_data['timestamp'] -= $data['freshness_ttl'] + 1; + $data['url_metrics'][0] = new ILO_URL_Metric( $url_metrics_data ); + return $data; + } )( $none_needed_data ), + array( + 'expected' => array( + array( 0, true ), + array( 481, false ), + ), + ) + ), + ); + } + + /** + * Test get_needed_minimum_viewport_widths(). + * + * @covers ::get_needed_minimum_viewport_widths + * + * @dataProvider data_provider_test_get_needed_minimum_viewport_widths + */ + public function test_get_needed_minimum_viewport_widths( array $url_metrics, float $current_time, array $breakpoints, int $sample_size, int $freshness_ttl, array $expected ) { + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, $sample_size, $freshness_ttl ); + $this->assertSame( + $expected, + $grouped_url_metrics->get_needed_minimum_viewport_widths() + ); + } + + /** + * Test are_all_groups_populated(). + * + * @covers ::are_all_groups_populated + */ + public function test_are_all_groups_populated() { + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( + array(), + array( 480, 800 ), + 3, + HOUR_IN_SECONDS + ); + $this->assertFalse( $grouped_url_metrics->are_all_groups_populated() ); + $grouped_url_metrics->add( $this->get_validated_url_metric( 200 ) ); + $this->assertFalse( $grouped_url_metrics->are_all_groups_populated() ); + $grouped_url_metrics->add( $this->get_validated_url_metric( 500 ) ); + $this->assertFalse( $grouped_url_metrics->are_all_groups_populated() ); + $grouped_url_metrics->add( $this->get_validated_url_metric( 900 ) ); + $this->assertTrue( $grouped_url_metrics->are_all_groups_populated() ); + } + + /** + * Test flatten(). + * + * @covers ::flatten + */ + public function test_flatten() { + $url_metrics = array( + $this->get_validated_url_metric( 400 ), + $this->get_validated_url_metric( 600 ), + $this->get_validated_url_metric( 800 ), + ); + + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( + $url_metrics, + array( 500, 700 ), + 3, + HOUR_IN_SECONDS + ); + + $this->assertEquals( $url_metrics, $grouped_url_metrics->flatten() ); + } + + /** + * Gets a validated URL metric for testing. + * + * @param int $viewport_width Viewport width. + * @param string[] $breadcrumbs Breadcrumb tags. + * @param bool $is_lcp Whether LCP. + * + * @return ILO_URL_Metric Validated URL metric. + * @throws Exception From ILO_URL_Metric if there is a parse error, but there won't be. + */ + private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): ILO_URL_Metric { + $data = array( + 'viewport' => array( + 'width' => $viewport_width, + 'height' => 640, + ), + 'timestamp' => microtime( true ), + 'elements' => array( + array( + 'isLCP' => $is_lcp, + 'isLCPCandidate' => $is_lcp, + 'xpath' => $this->get_xpath( ...$breadcrumbs ), + 'intersectionRatio' => 1, + ), + ), + ); + return new ILO_URL_Metric( $data ); + } + + /** + * Gets sample XPath. + * + * @param string ...$breadcrumbs List of tags. + * @return string XPath. + */ + private function get_xpath( ...$breadcrumbs ): string { + return implode( + '', + array_map( + static function ( $tag ) { + return sprintf( '/*[0][self::%s]', strtoupper( $tag ) ); + }, + $breadcrumbs + ) + ); + } +} diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index c23f22210a..2b61068301 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -174,94 +174,6 @@ static function ( int $life, string $action ) use ( &$nonce_life_actions ): int } } - public function data_provider_sample_size_and_breakpoints(): array { - return array( - '3 sample size and 2 breakpoints' => array( - 'sample_size' => 3, - 'breakpoints' => array( 480, 782 ), - 'viewport_widths' => array( 400, 600, 800 ), - ), - '1 sample size and 1 breakpoint' => array( - 'sample_size' => 1, - 'breakpoints' => array( 480 ), - 'viewport_widths' => array( 400, 800 ), - ), - ); - } - - /** - * Test ilo_unshift_url_metrics(). - * - * @covers ILO_Grouped_URL_Metrics::add - * - * @dataProvider data_provider_sample_size_and_breakpoints - */ - public function test_ilo_unshift_url_metrics( int $sample_size, array $breakpoints, array $viewport_widths ) { - $grouped_url_metrics = new ILO_Grouped_URL_Metrics( array(), $breakpoints, $sample_size, HOUR_IN_SECONDS ); - - // Over-populate the sample size for the breakpoints by a dozen. - foreach ( $viewport_widths as $viewport_width ) { - for ( $i = 0; $i < $sample_size + 12; $i++ ) { - $grouped_url_metrics->add( $this->get_validated_url_metric( $viewport_width ) ); - } - } - $max_possible_url_metrics_count = $sample_size * ( count( $breakpoints ) + 1 ); - $this->assertCount( - $max_possible_url_metrics_count, - $grouped_url_metrics->flatten(), - sprintf( 'Expected there to be exactly sample size (%d) times the number of breakpoint groups (which is %d + 1)', $sample_size, count( $breakpoints ) ) - ); - } - - /** - * Test that ilo_unshift_url_metrics() pushes out old metrics. - * - * @covers ILO_Grouped_URL_Metrics::add - * - * @dataProvider data_provider_sample_size_and_breakpoints - * @throws Exception When a parse error happens. - */ - public function test_ilo_unshift_url_metrics_pushes_out_old_metrics( int $sample_size, array $breakpoints, array $viewport_widths ) { - $old_timestamp = microtime( true ) - ( HOUR_IN_SECONDS + 1 ); - - $grouped_url_metrics = new ILO_Grouped_URL_Metrics( array(), $breakpoints, $sample_size, HOUR_IN_SECONDS ); - - // Populate the groups with stale URL metrics. - foreach ( $viewport_widths as $viewport_width ) { - for ( $i = 0; $i < $sample_size; $i++ ) { - $grouped_url_metrics->add( - new ILO_URL_Metric( - array_merge( - $this->get_validated_url_metric( $viewport_width )->jsonSerialize(), - array( - 'timestamp' => $old_timestamp, - ) - ) - ) - ); - } - } - - // Try adding one URL metric for each breakpoint group. - foreach ( $viewport_widths as $viewport_width ) { - $grouped_url_metrics->add( $this->get_validated_url_metric( $viewport_width ) ); - } - - $max_possible_url_metrics_count = $sample_size * ( count( $breakpoints ) + 1 ); - $this->assertCount( - $max_possible_url_metrics_count, - $grouped_url_metrics->flatten(), - 'Expected the total count of URL metrics to not exceed the multiple of the sample size.' - ); - $new_count = 0; - foreach ( $grouped_url_metrics->flatten() as $url_metric ) { - if ( $url_metric->get_timestamp() > $old_timestamp ) { - ++$new_count; - } - } - $this->assertGreaterThan( 0, $new_count, 'Expected there to be at least one new URL metric.' ); - $this->assertSame( count( $viewport_widths ), $new_count, 'Expected the new URL metrics to all have been added.' ); - } /** * Test ilo_get_breakpoint_max_widths(). @@ -305,302 +217,4 @@ static function () { $this->assertSame( 1, ilo_get_url_metrics_breakpoint_sample_size() ); } - - public function data_provider_test_ilo_group_url_metrics_by_breakpoint(): array { - return array( - '2-breakpoints-and-3-viewport-widths' => array( - 'breakpoints' => array( 480, 640 ), - 'viewport_widths' => array( 400, 480, 800 ), - ), - '1-breakpoint-and-4-viewport-widths' => array( - 'breakpoints' => array( 480 ), - 'viewport_widths' => array( 400, 600, 800, 1000 ), - ), - ); - } - - /** - * Test ilo_group_url_metrics_by_breakpoint(). - * - * @covers ILO_Grouped_URL_Metrics::get_groups - * @covers ILO_Grouped_URL_Metrics::get_minimum_viewport_widths - * - * @dataProvider data_provider_test_ilo_group_url_metrics_by_breakpoint - */ - public function test_ilo_group_url_metrics_by_breakpoint( array $breakpoints, array $viewport_widths ) { - $url_metrics = array_map( - function ( $viewport_width ) { - return $this->get_validated_url_metric( $viewport_width ); - }, - $viewport_widths - ); - - $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, 3, HOUR_IN_SECONDS ); - - $this->assertCount( count( $breakpoints ) + 1, $grouped_url_metrics->get_groups(), 'Expected number of breakpoint groups to always be one greater than the number of breakpoints.' ); - $minimum_viewport_widths = $grouped_url_metrics->get_minimum_viewport_widths(); - $this->assertSame( array_keys( $grouped_url_metrics->get_groups() ), $minimum_viewport_widths ); - $this->assertSame( 0, array_shift( $minimum_viewport_widths ), 'Expected the first minimum viewport width to always be zero.' ); - foreach ( $breakpoints as $breakpoint ) { - $this->assertSame( $breakpoint + 1, array_shift( $minimum_viewport_widths ) ); - } - - $minimum_viewport_widths = $grouped_url_metrics->get_minimum_viewport_widths(); - for ( $i = 0, $len = count( $minimum_viewport_widths ); $i < $len; $i++ ) { - $minimum_viewport_width = $minimum_viewport_widths[ $i ]; - $maximum_viewport_width = $minimum_viewport_widths[ $i + 1 ] ?? null; - if ( 0 === $i ) { - $this->assertSame( 0, $minimum_viewport_width ); - } else { - $this->assertGreaterThan( 0, $minimum_viewport_width ); - } - if ( isset( $maximum_viewport_width ) ) { - $this->assertLessThan( $maximum_viewport_width, $minimum_viewport_width ); - } - - foreach ( $grouped_url_metrics->get_groups()[ $minimum_viewport_width ] as $url_metric ) { - $this->assertGreaterThanOrEqual( $minimum_viewport_width, $url_metric->get_viewport()['width'] ); - if ( isset( $maximum_viewport_width ) ) { - $this->assertLessThanOrEqual( $maximum_viewport_width, $url_metric->get_viewport()['width'] ); - } - } - } - } - - public function data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widths(): array { - return array( - 'common_lcp_element_across_breakpoints' => array( - 'breakpoints' => array( 600, 800 ), - 'url_metrics' => array( - // 0. - $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. - $this->get_validated_url_metric( 599, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - // 600. - $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - // 800. - $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - ), - 'expected_lcp_element_xpaths' => array( - 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), - ), - ), - 'different_lcp_elements_across_breakpoint' => array( - 'breakpoints' => array( 600 ), - 'url_metrics' => array( - // 0. - $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. - $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - // 600. - $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - ), - 'expected_lcp_element_xpaths' => array( - 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), - 601 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), - ), - ), - 'same_lcp_element_across_non_consecutive_breakpoints' => array( - 'breakpoints' => array( 400, 600 ), - 'url_metrics' => array( - // 0. - $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - // 400. - $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'HEADER', 'IMG' ), false ), - // 600. - $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - ), - 'expected_lcp_element_xpaths' => array( - 0 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), - 401 => false, // The (image) element is either not visible at this breakpoint or it is not LCP element. - 601 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), - ), - ), - 'no_lcp_image_elements' => array( - 'breakpoints' => array( 600 ), - 'url_metrics' => array( - // 0. - $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'IMG' ), false ), - // 600. - $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'IMG' ), false ), - ), - 'expected_lcp_element_xpaths' => array( - 0 => false, - ), - ), - ); - } - - /** - * Test ilo_get_lcp_elements_by_minimum_viewport_widths(). - * - * @covers ILO_Grouped_URL_Metrics::get_lcp_elements_by_minimum_viewport_widths - * @dataProvider data_provider_test_ilo_get_lcp_elements_by_minimum_viewport_widths - */ - public function test_ilo_get_lcp_elements_by_minimum_viewport_widths( array $breakpoints, array $url_metrics, array $expected_lcp_element_xpaths ) { - $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, 10, HOUR_IN_SECONDS ); - - $lcp_elements_by_minimum_viewport_widths = $grouped_url_metrics->get_lcp_elements_by_minimum_viewport_widths(); - - $lcp_element_xpaths_by_minimum_viewport_widths = array(); - foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { - $this->assertTrue( is_array( $lcp_element ) || false === $lcp_element ); - if ( is_array( $lcp_element ) ) { - $this->assertTrue( $lcp_element['isLCP'] ); - $this->assertTrue( $lcp_element['isLCPCandidate'] ); - $this->assertIsString( $lcp_element['xpath'] ); - $this->assertIsNumeric( $lcp_element['intersectionRatio'] ); - $lcp_element_xpaths_by_minimum_viewport_widths[ $minimum_viewport_width ] = $lcp_element['xpath']; - } else { - $lcp_element_xpaths_by_minimum_viewport_widths[ $minimum_viewport_width ] = false; - } - } - - $this->assertSame( $expected_lcp_element_xpaths, $lcp_element_xpaths_by_minimum_viewport_widths ); - } - - /** - * Data provider. - * - * @return array[] - */ - public function data_provider_test_ilo_get_needed_minimum_viewport_widths(): array { - $current_time = microtime( true ); - - $none_needed_data = array( - 'url_metrics' => ( function () use ( $current_time ): array { - return array_merge( - array_fill( - 0, - 3, - new ILO_URL_Metric( - array_merge( - $this->get_validated_url_metric( 400 )->jsonSerialize(), - array( 'timestamp' => $current_time ) - ) - ) - ), - array_fill( - 0, - 3, - new ILO_URL_Metric( - array_merge( - $this->get_validated_url_metric( 600 )->jsonSerialize(), - array( 'timestamp' => $current_time ) - ) - ) - ) - ); - } )(), - 'current_time' => $current_time, - 'breakpoints' => array( 480 ), - 'sample_size' => 3, - 'freshness_ttl' => HOUR_IN_SECONDS, - ); - - return array( - 'none-needed' => array_merge( - $none_needed_data, - array( - 'expected' => array( - array( 0, false ), - array( 481, false ), - ), - ) - ), - - 'not-enough-url-metrics' => array_merge( - $none_needed_data, - array( - 'sample_size' => $none_needed_data['sample_size'] + 1, - ), - array( - 'expected' => array( - array( 0, true ), - array( 481, true ), - ), - ) - ), - - 'url-metric-too-old' => array_merge( - ( static function ( $data ): array { - $url_metrics_data = $data['url_metrics'][0]->jsonSerialize(); - $url_metrics_data['timestamp'] -= $data['freshness_ttl'] + 1; - $data['url_metrics'][0] = new ILO_URL_Metric( $url_metrics_data ); - return $data; - } )( $none_needed_data ), - array( - 'expected' => array( - array( 0, true ), - array( 481, false ), - ), - ) - ), - ); - } - - /** - * Test ilo_get_needed_minimum_viewport_widths(). - * - * @covers ILO_Grouped_URL_Metrics::get_needed_minimum_viewport_widths - * - * @dataProvider data_provider_test_ilo_get_needed_minimum_viewport_widths - */ - public function test_ilo_get_needed_minimum_viewport_widths( array $url_metrics, float $current_time, array $breakpoints, int $sample_size, int $freshness_ttl, array $expected ) { - $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, $sample_size, $freshness_ttl ); - $this->assertSame( - $expected, - $grouped_url_metrics->get_needed_minimum_viewport_widths() - ); - } - - /** - * Gets a validated URL metric for testing. - * - * @param int $viewport_width Viewport width. - * @param string[] $breadcrumbs Breadcrumb tags. - * @param bool $is_lcp Whether LCP. - * - * @return ILO_URL_Metric Validated URL metric. - * @throws Exception From ILO_URL_Metric if there is a parse error, but there won't be. - */ - private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): ILO_URL_Metric { - $data = array( - 'viewport' => array( - 'width' => $viewport_width, - 'height' => 640, - ), - 'timestamp' => microtime( true ), - 'elements' => array( - array( - 'isLCP' => $is_lcp, - 'isLCPCandidate' => $is_lcp, - 'xpath' => $this->get_xpath( ...$breadcrumbs ), - 'intersectionRatio' => 1, - ), - ), - ); - return new ILO_URL_Metric( $data ); - } - - /** - * Gets sample XPath. - * - * @param string ...$breadcrumbs List of tags. - * @return string XPath. - */ - private function get_xpath( ...$breadcrumbs ): string { - return implode( - '', - array_map( - static function ( $tag ) { - return sprintf( '/*[0][self::%s]', strtoupper( $tag ) ); - }, - $breadcrumbs - ) - ); - } } From f606112d0fd8b8d9751a6b95d183b3588fd5b3d0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 27 Feb 2024 17:27:44 -0800 Subject: [PATCH 264/371] Fix class name in file header --- .../class-ilo-grouped-url-metrics.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index 417d56a264..a3b87d4526 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -1,6 +1,6 @@ Date: Tue, 27 Feb 2024 17:42:59 -0800 Subject: [PATCH 265/371] Refactor storage lock functions into ILO_Storage_Lock class --- .../class-ilo-storage-lock.php | 94 +++++++++++++++++++ .../image-loading-optimization/detection.php | 2 +- .../image-loading-optimization/load.php | 2 +- .../storage/lock.php | 89 ------------------ .../storage/rest-api.php | 4 +- ...s.php => class-ilo-storage-lock-tests.php} | 50 +++++----- .../storage/rest-api-tests.php | 2 +- 7 files changed, 126 insertions(+), 117 deletions(-) create mode 100644 modules/images/image-loading-optimization/class-ilo-storage-lock.php delete mode 100644 modules/images/image-loading-optimization/storage/lock.php rename tests/modules/images/image-loading-optimization/{storage/lock-tests.php => class-ilo-storage-lock-tests.php} (62%) diff --git a/modules/images/image-loading-optimization/class-ilo-storage-lock.php b/modules/images/image-loading-optimization/class-ilo-storage-lock.php new file mode 100644 index 0000000000..a30df73bf0 --- /dev/null +++ b/modules/images/image-loading-optimization/class-ilo-storage-lock.php @@ -0,0 +1,94 @@ + $slug, 'urlMetricsNonce' => ilo_get_url_metrics_storage_nonce( $slug ), 'neededMinimumViewportWidths' => $needed_minimum_viewport_widths, - 'storageLockTTL' => ilo_get_url_metric_storage_lock_ttl(), + 'storageLockTTL' => ILO_Storage_Lock::get_ttl(), 'webVitalsLibrarySrc' => $web_vitals_lib_src, ); diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index 082f5e2f84..78195e9dc0 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -20,7 +20,7 @@ // Storage logic. require_once __DIR__ . '/class-ilo-url-metric.php'; require_once __DIR__ . '/class-ilo-grouped-url-metrics.php'; -require_once __DIR__ . '/storage/lock.php'; +require_once __DIR__ . '/class-ilo-storage-lock.php'; require_once __DIR__ . '/storage/post-type.php'; require_once __DIR__ . '/storage/data.php'; require_once __DIR__ . '/storage/rest-api.php'; diff --git a/modules/images/image-loading-optimization/storage/lock.php b/modules/images/image-loading-optimization/storage/lock.php deleted file mode 100644 index 2d8992e31f..0000000000 --- a/modules/images/image-loading-optimization/storage/lock.php +++ /dev/null @@ -1,89 +0,0 @@ - static function () { // Needs to be available to unauthenticated visitors. - if ( ilo_is_url_metric_storage_locked() ) { + if ( ILO_Storage_Lock::is_locked() ) { return new WP_Error( 'url_metric_storage_locked', __( 'URL metric storage is presently locked for the current IP.', 'performance-lab' ), @@ -146,7 +146,7 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { ); } - ilo_set_url_metric_storage_lock(); + ILO_Storage_Lock::set_lock(); try { $new_url_metric = new ILO_URL_Metric( diff --git a/tests/modules/images/image-loading-optimization/storage/lock-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-storage-lock-tests.php similarity index 62% rename from tests/modules/images/image-loading-optimization/storage/lock-tests.php rename to tests/modules/images/image-loading-optimization/class-ilo-storage-lock-tests.php index dfedb566d0..546b07e787 100644 --- a/tests/modules/images/image-loading-optimization/storage/lock-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-storage-lock-tests.php @@ -1,9 +1,11 @@ */ - public function data_provider_ilo_get_url_metric_storage_lock_ttl(): array { + public function data_provider_get_ttl(): array { return array( 'unfiltered' => array( 'set_up' => static function () {}, @@ -53,48 +55,50 @@ static function (): int { } /** - * Test ilo_get_url_metric_storage_lock_ttl(). + * Test get_ttl(). * - * @covers ::ilo_get_url_metric_storage_lock_ttl + * @covers ::get_ttl * - * @dataProvider data_provider_ilo_get_url_metric_storage_lock_ttl + * @dataProvider data_provider_get_ttl * * @param Closure $set_up Set up. * @param int $expected Expected value. */ - public function test_ilo_get_url_metric_storage_lock_ttl( Closure $set_up, int $expected ) { + public function test_get_ttl( Closure $set_up, int $expected ) { $set_up(); - $this->assertSame( $expected, ilo_get_url_metric_storage_lock_ttl() ); + $this->assertSame( $expected, ILO_Storage_Lock::get_ttl() ); } /** - * Test ilo_get_url_metric_storage_lock_transient_key(). + * Test get_transient_key(). * - * @covers ::ilo_get_url_metric_storage_lock_transient_key + * @covers ::get_transient_key */ - public function test_ilo_get_url_metric_storage_lock_transient_key() { + public function test_get_transient_key() { unset( $_SERVER['REMOTE_ADDR'], $_SERVER['HTTP_X_FORWARDED_FOR'] ); $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; - $first_key = ilo_get_url_metric_storage_lock_transient_key(); + $first_key = ILO_Storage_Lock::get_transient_key(); $this->assertStringStartsWith( 'url_metrics_storage_lock_', $first_key ); $_SERVER['HTTP_X_FORWARDED_FOR'] = '127.0.0.2'; - $second_key = ilo_get_url_metric_storage_lock_transient_key(); + $second_key = ILO_Storage_Lock::get_transient_key(); $this->assertStringStartsWith( 'url_metrics_storage_lock_', $second_key ); $this->assertNotEquals( $second_key, $first_key, 'Expected setting HTTP_X_FORWARDED_FOR header to take precedence over REMOTE_ADDR.' ); } /** - * Test ilo_set_url_metric_storage_lock() and ilo_is_url_metric_storage_locked(). + * Test set_lock() and is_locked(). * - * @covers ::ilo_set_url_metric_storage_lock - * @covers ::ilo_is_url_metric_storage_locked + * @covers ::set_lock + * @covers ::is_locked + * @covers ::get_transient_key + * @covers ::get_ttl */ - public function test_ilo_set_url_metric_storage_lock_and_ilo_is_url_metric_storage_locked() { - $key = ilo_get_url_metric_storage_lock_transient_key(); - $ttl = ilo_get_url_metric_storage_lock_ttl(); + public function test_set_lock_and_is_locked() { + $key = ILO_Storage_Lock::get_transient_key(); + $ttl = ILO_Storage_Lock::get_ttl(); $transient_value = null; $transient_expiration = null; @@ -110,20 +114,20 @@ static function ( $filtered_value, $filtered_expiration ) use ( &$transient_valu ); // Set the lock. - ilo_set_url_metric_storage_lock(); + ILO_Storage_Lock::set_lock(); $this->assertSame( $ttl, $transient_expiration ); $this->assertLessThanOrEqual( microtime( true ), $transient_value ); $this->assertEquals( $transient_value, get_transient( $key ) ); - $this->assertTrue( ilo_is_url_metric_storage_locked() ); + $this->assertTrue( ILO_Storage_Lock::is_locked() ); // Simulate expired lock. set_transient( $key, microtime( true ) - HOUR_IN_SECONDS ); - $this->assertFalse( ilo_is_url_metric_storage_locked() ); + $this->assertFalse( ILO_Storage_Lock::is_locked() ); // Clear the lock. add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); - ilo_set_url_metric_storage_lock(); + ILO_Storage_Lock::set_lock(); $this->assertFalse( get_transient( $key ) ); - $this->assertFalse( ilo_is_url_metric_storage_locked() ); + $this->assertFalse( ILO_Storage_Lock::is_locked() ); } } diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index 35e1bc70ba..92f2697360 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -148,7 +148,7 @@ public function test_rest_request_bad_params( array $params ) { * @covers ::ilo_handle_rest_request */ public function test_rest_request_locked() { - ilo_set_url_metric_storage_lock(); + ILO_Storage_Lock::set_lock(); $request = new WP_REST_Request( 'POST', self::ROUTE ); $request->set_body_params( $this->get_valid_params() ); From fcbfd420e63827a6b861e1ae54722ef0be324205 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 28 Feb 2024 13:48:04 -0800 Subject: [PATCH 266/371] Add dedicated test coverage for ILO_URL_Metric --- .../class-ilo-url-metric.php | 2 +- .../class-ilo-url-metric-tests.php | 131 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 tests/modules/images/image-loading-optimization/class-ilo-url-metric-tests.php diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index 0d05782c75..58b1a80169 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -53,7 +53,7 @@ public function __construct( array $data, bool $validated = false ) { /** * Gets JSON schema for URL Metric. * - * @return array + * @return array Schema. */ public static function get_json_schema(): array { $dom_rect_schema = array( diff --git a/tests/modules/images/image-loading-optimization/class-ilo-url-metric-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-url-metric-tests.php new file mode 100644 index 0000000000..b49980b620 --- /dev/null +++ b/tests/modules/images/image-loading-optimization/class-ilo-url-metric-tests.php @@ -0,0 +1,131 @@ + 640, + 'height' => 480, + ); + + return array( + 'valid_minimal' => array( + 'data' => array( + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + ), + 'valid_with_element' => array( + 'data' => array( + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + 'elements' => array( + array( + 'isLCP' => true, + 'isLCPCandidate' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + 'intersectionRatio' => 1.0, + ), + ), + ), + ), + 'missing_viewport' => array( + 'data' => array( + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + 'error' => 'viewport is a required property of ILO_URL_Metric.', + ), + 'missing_viewport_width' => array( + 'data' => array( + 'viewport' => array( 'height' => 640 ), + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + 'error' => 'width is a required property of ILO_URL_Metric[viewport].', + ), + 'bad_viewport' => array( + 'data' => array( + 'viewport' => array( + 'height' => 'tall', + 'width' => 'wide', + ), + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + 'error' => 'ILO_URL_Metric[viewport][height] is not of type integer.', + ), + 'missing_timestamp' => array( + 'data' => array( + 'viewport' => $viewport, + 'elements' => array(), + ), + 'error' => 'timestamp is a required property of ILO_URL_Metric.', + ), + 'missing_elements' => array( + 'data' => array( + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + ), + 'error' => 'elements is a required property of ILO_URL_Metric.', + ), + 'bad_elements' => array( + 'data' => array( + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + 'elements' => array( + array( + 'isElSeePee' => true, + ), + ), + ), + 'error' => 'isLCP is a required property of ILO_URL_Metric[elements][0].', + ), + ); + } + + /** + * Tests construction. + * + * @covers ::get_viewport + * @covers ::get_timestamp + * @covers ::get_elements + * @covers ::jsonSerialize + * + * @dataProvider data_provider + */ + public function test_constructor( array $data, string $error = '' ) { + if ( $error ) { + $this->expectException( Exception::class ); + $this->expectExceptionMessage( $error ); + } + $url_metric = new ILO_URL_Metric( $data ); + $this->assertSame( $data['viewport'], $url_metric->get_viewport() ); + $this->assertSame( $data['timestamp'], $url_metric->get_timestamp() ); + $this->assertSame( $data['elements'], $url_metric->get_elements() ); + $this->assertEquals( $data, $url_metric->jsonSerialize() ); + } + + /** + * Tests get_json_schema(). + * + * @covers ::get_json_schema + */ + public function test_get_json_schema() { + $schema = ILO_URL_Metric::get_json_schema(); + $this->assertArrayHasKey( 'properties', $schema ); + } +} From c5765e2d1e5f2c6d7af51de47f5070d65af152fe Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 28 Feb 2024 13:52:56 -0800 Subject: [PATCH 267/371] Include more context in error messages when parsing/validating URL Metrics --- .../image-loading-optimization/storage/post-type.php | 12 +++++++----- .../image-loading-optimization/storage/rest-api.php | 6 +++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index 5f3e391c5b..17c718c696 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -93,9 +93,10 @@ function ilo_parse_stored_url_metrics( WP_Post $post ): array { if ( json_last_error() ) { $trigger_error( sprintf( - /* translators: 1: Post type slug, 2: JSON error message */ - __( 'Contents of %1$s post type not valid JSON: %2$s', 'performance-lab' ), + /* translators: 1: Post type slug, 2: Post ID, 3: JSON error message */ + __( 'Contents of %1$s post type (ID: %2$s) not valid JSON: %3$s', 'performance-lab' ), ILO_URL_METRICS_POST_TYPE, + $post->ID, json_last_error_msg() ) ); @@ -125,9 +126,10 @@ static function ( $url_metric_data ) use ( $trigger_error ) { } catch ( Exception $e ) { $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 + /* translators: 1: Post type slug. 2: Exception message. */ + __( 'Unexpected shape to JSON array in post_content of %1$s post type: %2$s', 'performance-lab' ), + ILO_URL_METRICS_POST_TYPE, + $e->getMessage() ) ); return null; diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 1cb2c856d0..ee2f62a43b 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -163,7 +163,11 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { } catch ( Exception $e ) { return new WP_Error( 'url_metric_exception', - __( 'Exception occurred while creating URL metric.', 'performance-lab' ) + sprintf( + /* translators: %s is exception name */ + __( 'Failed to validate URL metric: %s', 'performance-lab' ), + $e->getMessage() + ) ); } From c19c371e05d3c47eba2c39d0510cc1d1bb930a9a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 28 Feb 2024 14:04:53 -0800 Subject: [PATCH 268/371] Always validate data and introduce dedicated exception class --- .../class-ilo-data-validation-exception.php | 15 +++++++++++++++ .../class-ilo-url-metric.php | 13 +++++-------- .../images/image-loading-optimization/load.php | 1 + .../storage/post-type.php | 3 +-- .../storage/rest-api.php | 5 ++--- .../class-ilo-url-metric-tests.php | 2 +- 6 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 modules/images/image-loading-optimization/class-ilo-data-validation-exception.php diff --git a/modules/images/image-loading-optimization/class-ilo-data-validation-exception.php b/modules/images/image-loading-optimization/class-ilo-data-validation-exception.php new file mode 100644 index 0000000000..91c74799d7 --- /dev/null +++ b/modules/images/image-loading-optimization/class-ilo-data-validation-exception.php @@ -0,0 +1,15 @@ +get_error_message() ) ); - } + public function __construct( array $data ) { + $valid = rest_validate_object_value_from_schema( $data, self::get_json_schema(), self::class ); + if ( is_wp_error( $valid ) ) { + throw new ILO_Data_Validation_Exception( esc_html( $valid->get_error_message() ) ); } $this->data = $data; } diff --git a/modules/images/image-loading-optimization/load.php b/modules/images/image-loading-optimization/load.php index 73741da3eb..0e5d45eb71 100644 --- a/modules/images/image-loading-optimization/load.php +++ b/modules/images/image-loading-optimization/load.php @@ -18,6 +18,7 @@ require_once __DIR__ . '/hooks.php'; // Storage logic. +require_once __DIR__ . '/class-ilo-data-validation-exception.php'; require_once __DIR__ . '/class-ilo-url-metric.php'; require_once __DIR__ . '/storage/lock.php'; require_once __DIR__ . '/storage/post-type.php'; diff --git a/modules/images/image-loading-optimization/storage/post-type.php b/modules/images/image-loading-optimization/storage/post-type.php index 17c718c696..417f31fdf5 100644 --- a/modules/images/image-loading-optimization/storage/post-type.php +++ b/modules/images/image-loading-optimization/storage/post-type.php @@ -121,9 +121,8 @@ static function ( $url_metric_data ) use ( $trigger_error ) { } try { - // TODO: This is re-validating the data which has been stored in the post type. This ensures it remains valid, but is it overkill? return new ILO_URL_Metric( $url_metric_data ); - } catch ( Exception $e ) { + } catch ( ILO_Data_Validation_Exception $e ) { $trigger_error( sprintf( /* translators: 1: Post type slug. 2: Exception message. */ diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index ee2f62a43b..f9e3431d92 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -157,10 +157,9 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { array( 'timestamp' => microtime( true ), ) - ), - true // Already validated via REST API. + ) ); - } catch ( Exception $e ) { + } catch ( ILO_Data_Validation_Exception $e ) { return new WP_Error( 'url_metric_exception', sprintf( diff --git a/tests/modules/images/image-loading-optimization/class-ilo-url-metric-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-url-metric-tests.php index b49980b620..0f454c74ad 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-url-metric-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-url-metric-tests.php @@ -109,7 +109,7 @@ public function data_provider(): array { */ public function test_constructor( array $data, string $error = '' ) { if ( $error ) { - $this->expectException( Exception::class ); + $this->expectException( ILO_Data_Validation_Exception::class ); $this->expectExceptionMessage( $error ); } $url_metric = new ILO_URL_Metric( $data ); From afc13ff340ce006a56f2a46bb6cbcd9a084a1960 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 28 Feb 2024 14:43:43 -0800 Subject: [PATCH 269/371] Rework timestamp handling in schema --- .../class-ilo-url-metric.php | 2 ++ .../storage/rest-api.php | 17 +++++----- .../storage/rest-api-tests.php | 33 ++++++++++++++++++- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index 8369ba152c..fe0b953bf9 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -95,6 +95,8 @@ public static function get_json_schema(): array { 'description' => __( 'Timestamp at which the URL metric was captured.', 'performance-lab' ), 'type' => 'number', 'required' => true, + 'readonly' => true, // Omit from REST API. + 'default' => microtime( true ), // Value provided when instantiating ILO_URL_Metric in REST API. 'minimum' => 0, ), 'elements' => array( diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index f9e3431d92..3c6f648fa8 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -75,11 +75,7 @@ function ilo_register_endpoint() { $schema = ILO_URL_Metric::get_json_schema(); - // Make timestamp not required since it is forcibly-provided in ilo_handle_rest_request(). - $schema['properties']['timestamp']['required'] = false; - $schema['properties']['timestamp']['readonly'] = true; - - $args = array_merge( $args, $schema['properties'] ); + $args = array_merge( $args, rest_get_endpoint_args_for_schema( $schema ) ); register_rest_route( ILO_REST_API_NAMESPACE, @@ -148,14 +144,17 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { ilo_set_url_metric_storage_lock(); try { - $new_url_metric = new ILO_URL_Metric( + $properties = ILO_URL_Metric::get_json_schema()['properties']; + $url_metric = new ILO_URL_Metric( array_merge( wp_array_slice_assoc( $request->get_params(), - array_keys( ILO_URL_Metric::get_json_schema()['properties'] ) + array_keys( $properties ) ), array( - 'timestamp' => microtime( true ), + // Now supply the timestamp since it was omitted from the REST API params since it is `readonly`. + // Nevertheless, it is also `required`, so it must be set to instantiate an ILO_URL_Metric. + 'timestamp' => $properties['timestamp']['default'], ) ) ); @@ -173,7 +172,7 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { $result = ilo_store_url_metric( $request->get_param( 'url' ), $request->get_param( 'slug' ), - $new_url_metric + $url_metric ); if ( $result instanceof WP_Error ) { diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index 35e1bc70ba..2da4ca523d 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -139,6 +139,37 @@ public function test_rest_request_bad_params( array $params ) { $response = rest_get_server()->dispatch( $request ); $this->assertSame( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); $this->assertSame( 'rest_invalid_param', $response->get_data()['code'], 'Response: ' . wp_json_encode( $response ) ); + + $this->assertNull( ilo_get_url_metrics_post( $params['slug'] ) ); + } + + /** + * Test timestamp ignored. + * + * @covers ::ilo_register_endpoint + * @covers ::ilo_handle_rest_request + */ + public function test_rest_request_timestamp_ignored() { + $initial_microtime = microtime( true ); + + $request = new WP_REST_Request( 'POST', self::ROUTE ); + + $params = $this->get_valid_params(); + $params['timestamp'] = microtime( true ) - HOUR_IN_SECONDS; // Should be ignored. + + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + + $post = ilo_get_url_metrics_post( $params['slug'] ); + $this->assertInstanceOf( WP_Post::class, $post ); + + $url_metrics = ilo_parse_stored_url_metrics( $post ); + $this->assertCount( 1, $url_metrics ); + $url_metric = $url_metrics[0]; + $this->assertNotEquals( $params['timestamp'], $url_metric->get_timestamp() ); + $this->assertGreaterThanOrEqual( $initial_microtime, $url_metric->get_timestamp() ); } /** @@ -230,7 +261,7 @@ private function get_valid_params(): array { ), $this->get_sample_validated_url_metric() ); - unset( $data['timestamp'] ); // Since provided by request handler. + unset( $data['timestamp'] ); // Since provided by default args. return $data; } From 949642d06415b770da5a0343f787cdf1243bc609 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 28 Feb 2024 14:45:33 -0800 Subject: [PATCH 270/371] Make endpoint registration code more concise --- .../image-loading-optimization/storage/rest-api.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 3c6f648fa8..05e080ee36 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -73,16 +73,15 @@ function ilo_register_endpoint() { ), ); - $schema = ILO_URL_Metric::get_json_schema(); - - $args = array_merge( $args, rest_get_endpoint_args_for_schema( $schema ) ); - register_rest_route( ILO_REST_API_NAMESPACE, ILO_URL_METRICS_ROUTE, array( 'methods' => 'POST', - 'args' => $args, + 'args' => array_merge( + $args, + rest_get_endpoint_args_for_schema( ILO_URL_Metric::get_json_schema() ) + ), 'callback' => static function ( WP_REST_Request $request ) { return ilo_handle_rest_request( $request ); }, From e14809183c372408f8f3a84eeb23d07f576484c2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 28 Feb 2024 15:04:12 -0800 Subject: [PATCH 271/371] Fix PHP doc spacing Co-authored-by: Felix Arntz --- .../images/image-loading-optimization/class-ilo-url-metric.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/class-ilo-url-metric.php b/modules/images/image-loading-optimization/class-ilo-url-metric.php index fe0b953bf9..3be453fc2b 100644 --- a/modules/images/image-loading-optimization/class-ilo-url-metric.php +++ b/modules/images/image-loading-optimization/class-ilo-url-metric.php @@ -35,7 +35,7 @@ final class ILO_URL_Metric implements JsonSerializable { /** * Constructor. * - * @param array $data URL metric data. + * @param array $data URL metric data. * * @throws ILO_Data_Validation_Exception When the input is invalid. */ From 9f1b6f6e5e56cd66db564bec7af8cec90c53b33c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 29 Feb 2024 15:06:48 -0800 Subject: [PATCH 272/371] Fix off-by-one error when putting URL metric into group --- .../class-ilo-grouped-url-metrics.php | 2 +- .../class-ilo-grouped-url-metrics-tests.php | 74 +++++++++++++++---- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index a3b87d4526..5e5bf3019e 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -140,7 +140,7 @@ static function ( $breakpoint ) { foreach ( $url_metrics as $url_metric ) { $current_minimum_viewport = 0; foreach ( $minimum_viewport_widths as $viewport_minimum_width ) { - if ( $url_metric->get_viewport()['width'] > $viewport_minimum_width ) { + if ( $url_metric->get_viewport()['width'] >= $viewport_minimum_width ) { $current_minimum_viewport = $viewport_minimum_width; } else { break; diff --git a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php index a4c889787a..56dc8aaf92 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php @@ -22,12 +22,44 @@ public function data_provider_sample_size_and_breakpoints(): array { '3 sample size and 2 breakpoints' => array( 'sample_size' => 3, 'breakpoints' => array( 480, 782 ), - 'viewport_widths' => array( 400, 600, 800 ), + 'viewport_widths' => array( + 400 => 3, + 600 => 3, + 800 => 1, + ), + 'expected_counts' => array( + 0 => 3, + 481 => 3, + 783 => 1, + ), + ), + '2 sample size and 3 breakpoints' => array( + 'sample_size' => 2, + 'breakpoints' => array( 480, 600, 782 ), + 'viewport_widths' => array( + 200 => 4, + 481 => 2, + 601 => 7, + 783 => 6, + ), + 'expected_counts' => array( + 0 => 2, + 481 => 2, + 601 => 2, + 783 => 2, + ), ), '1 sample size and 1 breakpoint' => array( 'sample_size' => 1, 'breakpoints' => array( 480 ), - 'viewport_widths' => array( 400, 800 ), + 'viewport_widths' => array( + 400 => 1, + 800 => 1, + ), + 'expected_counts' => array( + 0 => 1, + 481 => 1, + ), ), ); } @@ -37,23 +69,33 @@ public function data_provider_sample_size_and_breakpoints(): array { * * @covers ::add * + * @param int $sample_size Sample size. + * @param array $breakpoints Breakpoints. + * @param array $viewport_widths Viewport widths mapped to the number of URL metrics to instantiate. + * @param array $expected_counts Minimum viewport widths mapped to the expected counts in each group. + * * @dataProvider data_provider_sample_size_and_breakpoints + * @throws ILO_Data_Validation_Exception When failing to instantiate a URL metric. */ - public function test_add( int $sample_size, array $breakpoints, array $viewport_widths ) { + public function test_add( int $sample_size, array $breakpoints, array $viewport_widths, array $expected_counts ) { $grouped_url_metrics = new ILO_Grouped_URL_Metrics( array(), $breakpoints, $sample_size, HOUR_IN_SECONDS ); // Over-populate the sample size for the breakpoints by a dozen. - foreach ( $viewport_widths as $viewport_width ) { - for ( $i = 0; $i < $sample_size + 12; $i++ ) { + foreach ( $viewport_widths as $viewport_width => $count ) { + for ( $i = 0; $i < $count; $i++ ) { $grouped_url_metrics->add( $this->get_validated_url_metric( $viewport_width ) ); } } - $max_possible_url_metrics_count = $sample_size * ( count( $breakpoints ) + 1 ); - $this->assertCount( - $max_possible_url_metrics_count, - $grouped_url_metrics->flatten(), - sprintf( 'Expected there to be exactly sample size (%d) times the number of breakpoint groups (which is %d + 1)', $sample_size, count( $breakpoints ) ) + + $this->assertLessThanOrEqual( + $sample_size * ( count( $breakpoints ) + 1 ), + count( $grouped_url_metrics->flatten() ), + sprintf( 'Expected there to be at most sample size (%d) times the number of breakpoint groups (which is %d + 1)', $sample_size, count( $breakpoints ) ) ); + + foreach ( $expected_counts as $minimum_viewport_width => $count ) { + $this->assertCount( $count, $grouped_url_metrics->get_groups()[ $minimum_viewport_width ] ); + } } /** @@ -61,15 +103,17 @@ public function test_add( int $sample_size, array $breakpoints, array $viewport_ * * @covers ::add * - * @dataProvider data_provider_sample_size_and_breakpoints - * @throws Exception When a parse error happens. + * @throws ILO_Data_Validation_Exception When failing to instantiate a URL metric. */ - public function test_adding_pushes_out_old_metrics( int $sample_size, array $breakpoints, array $viewport_widths ) { - $old_timestamp = microtime( true ) - ( HOUR_IN_SECONDS + 1 ); - + public function test_adding_pushes_out_old_metrics() { + $sample_size = 3; + $breakpoints = array( 400, 600 ); $grouped_url_metrics = new ILO_Grouped_URL_Metrics( array(), $breakpoints, $sample_size, HOUR_IN_SECONDS ); // Populate the groups with stale URL metrics. + $viewport_widths = array( 300, 500, 700 ); + $old_timestamp = microtime( true ) - ( HOUR_IN_SECONDS + 1 ); + foreach ( $viewport_widths as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { $grouped_url_metrics->add( From db8ae4a6a0802b2fc964fc4f21bfa3be6200e6fd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 29 Feb 2024 15:10:28 -0800 Subject: [PATCH 273/371] Add test to ensure REST API bocks submission to fully-populated wider viewport group when narrower group is empty --- .../class-ilo-grouped-url-metrics-tests.php | 4 +- .../storage/rest-api-tests.php | 113 +++++++++++++++--- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php index 56dc8aaf92..11bf8fab19 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php @@ -219,7 +219,7 @@ function ( $viewport_width ) { /** * Data provider. * - * @throws Exception When invalid URL metric (which there should not be). + * @throws ILO_Data_Validation_Exception When failing to instantiate a URL metric. * @return array[] */ public function data_provider_test_get_lcp_elements_by_minimum_viewport_widths(): array { @@ -463,7 +463,7 @@ public function test_flatten() { * @param bool $is_lcp Whether LCP. * * @return ILO_URL_Metric Validated URL metric. - * @throws Exception From ILO_URL_Metric if there is a parse error, but there won't be. + * @throws ILO_Data_Validation_Exception From ILO_URL_Metric if there is a parse error, but there won't be. */ private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): ILO_URL_Metric { $data = array( diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index 2da4ca523d..ebb7a8954e 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -153,10 +153,12 @@ public function test_rest_request_timestamp_ignored() { $initial_microtime = microtime( true ); $request = new WP_REST_Request( 'POST', self::ROUTE ); - - $params = $this->get_valid_params(); - $params['timestamp'] = microtime( true ) - HOUR_IN_SECONDS; // Should be ignored. - + $params = $this->get_valid_params( + array( + // Timestamp should cause to be ignored. + 'timestamp' => microtime( true ) - HOUR_IN_SECONDS, + ) + ); $request->set_body_params( $params ); $response = rest_get_server()->dispatch( $request ); @@ -190,7 +192,7 @@ public function test_rest_request_locked() { } /** - * Test sending viewport data that isn't needed for a specific breakpoint. + * Test sending viewport data that isn't needed for any breakpoint. * * @covers ::ilo_register_endpoint * @covers ::ilo_handle_rest_request @@ -203,16 +205,15 @@ public function test_rest_request_breakpoint_not_needed_for_any_breakpoint() { $viewport_widths = array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ); foreach ( $viewport_widths as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - $valid_params = $this->get_valid_params(); - $valid_params['viewport']['width'] = $viewport_width; - $request = new WP_REST_Request( 'POST', self::ROUTE ); + $valid_params = $this->get_valid_params( array( 'viewport' => array( 'width' => $viewport_width ) ) ); + $request = new WP_REST_Request( 'POST', self::ROUTE ); $request->set_body_params( $valid_params ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); } } - // The next request with the same sample size will be rejected. + // The next request will be rejected because all groups are fully populated with samples. $request = new WP_REST_Request( 'POST', self::ROUTE ); $request->set_body_params( $this->get_valid_params() ); $response = rest_get_server()->dispatch( $request ); @@ -220,7 +221,7 @@ public function test_rest_request_breakpoint_not_needed_for_any_breakpoint() { } /** - * Test sending viewport data that isn't needed for any breakpoint. + * Test sending viewport data that isn't needed for a specific breakpoint. * * @covers ::ilo_register_endpoint * @covers ::ilo_handle_rest_request @@ -228,30 +229,81 @@ public function test_rest_request_breakpoint_not_needed_for_any_breakpoint() { public function test_rest_request_breakpoint_not_needed_for_specific_breakpoint() { add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); + $valid_params = $this->get_valid_params( array( 'viewport' => array( 'width' => 480 ) ) ); + // First fully populate the sample for a given breakpoint. $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); for ( $i = 0; $i < $sample_size; $i++ ) { - $valid_params = $this->get_valid_params(); - $valid_params['viewport']['width'] = 480; - $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request = new WP_REST_Request( 'POST', self::ROUTE ); $request->set_body_params( $valid_params ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); } - // The next request with the same sample size will be rejected. + // The next request will be rejected because the one group is fully populated with the needed sample size. $request = new WP_REST_Request( 'POST', self::ROUTE ); - $request->set_body_params( $this->get_valid_params() ); + $request->set_body_params( $valid_params ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 403, $response->get_status() ); } + /** + * Test fully populating the larger viewport group and then adding one more. + * + * @covers ::ilo_register_endpoint + * @covers ::ilo_handle_rest_request + */ + public function test_rest_request_over_populate_larger_viewport_group() { + add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); + + // First establish a single breakpoint, so there are two groups of URL metrics + // with viewport widths 0-480 and >481. + $breakpoint_width = 480; + add_filter( + 'ilo_breakpoint_max_widths', + static function () use ( $breakpoint_width ): array { + return array( $breakpoint_width ); + } + ); + + $wider_viewport_params = $this->get_valid_params( array( 'viewport' => array( 'width' => $breakpoint_width + 1 ) ) ); + + // Fully populate the wider viewport group, leaving the narrower one empty. + $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + for ( $i = 0; $i < $sample_size; $i++ ) { + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $wider_viewport_params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + } + + // Sanity check that the groups were constructed as expected. + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( + ilo_parse_stored_url_metrics( ilo_get_url_metrics_post( ilo_get_url_metrics_slug( array() ) ) ), + ilo_get_breakpoint_max_widths(), + ilo_get_url_metrics_breakpoint_sample_size(), + HOUR_IN_SECONDS + ); + $url_metric_groups = $grouped_url_metrics->get_groups(); + $this->assertSame( array( 0, $breakpoint_width + 1 ), array_keys( $url_metric_groups ) ); + $this->assertCount( 0, $url_metric_groups[0], 'Expected first group to be empty.' ); + $this->assertCount( $sample_size, $url_metric_groups[ $breakpoint_width + 1 ], 'Expected last group to be fully populated.' ); + + // Now attempt to store one more URL metric for the wider viewport group. + // This should fail because the group is already fully populated to the sample size. + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $wider_viewport_params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response->get_data() ) ); + } + /** * Gets valid params. * - * @return array + * @param array $extras Extra params which are recursively merged on top of the valid params. + * @return array Params. */ - private function get_valid_params(): array { + private function get_valid_params( array $extras = array() ): array { $slug = ilo_get_url_metrics_slug( array() ); $data = array_merge( array( @@ -262,9 +314,36 @@ private function get_valid_params(): array { $this->get_sample_validated_url_metric() ); unset( $data['timestamp'] ); // Since provided by default args. + if ( $extras ) { + $data = $this->recursive_merge( $data, $extras ); + } return $data; } + /** + * Merges arrays recursively non-array values being overridden. + * + * This is on contrast with `array_merge_recursive()` which creates arrays for colliding values. + * + * @param array $base_array Base array. + * @param array $sparse_array Sparse array. + * @return array Merged array. + */ + private function recursive_merge( array $base_array, array $sparse_array ): array { + foreach ( $sparse_array as $key => $value ) { + if ( + array_key_exists( $key, $base_array ) && + is_array( $base_array[ $key ] ) && + is_array( $value ) + ) { + $base_array[ $key ] = $this->recursive_merge( $base_array[ $key ], $value ); + } else { + $base_array[ $key ] = $value; + } + } + return $base_array; + } + /** * Gets sample validated URL metric data. * From 794061d10183286ec7b9041f5a2e54cc8a4d37af Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 29 Feb 2024 15:16:42 -0800 Subject: [PATCH 274/371] Try returning int[] from get_needed_minimum_viewport_widths instead of tuples including whether needed --- .../class-ilo-grouped-url-metrics.php | 10 +++++----- .../image-loading-optimization/detection.php | 4 ++-- .../detection/detect.js | 11 ++++------- .../image-loading-optimization/optimization.php | 7 +------ .../storage/rest-api.php | 4 ++-- .../class-ilo-grouped-url-metrics-tests.php | 15 +++------------ 6 files changed, 17 insertions(+), 34 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index 5e5bf3019e..543882278c 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -159,7 +159,7 @@ static function ( $breakpoint ) { * @access private * * @param float $current_time Current time, defaults to `microtime(true)`. - * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. + * @return int[] Minimum viewport widths for which URL metric(s) are needed. */ public function get_needed_minimum_viewport_widths( float $current_time = null ): array { if ( null === $current_time ) { @@ -179,10 +179,10 @@ public function get_needed_minimum_viewport_widths( float $current_time = null ) } } } - $needed_minimum_viewport_widths[] = array( - $minimum_viewport_width, - $needs_url_metrics, - ); + + if ( $needs_url_metrics ) { + $needed_minimum_viewport_widths[] = $minimum_viewport_width; + } } return $needed_minimum_viewport_widths; diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index cfae3bfff0..d486f6eeb7 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -16,8 +16,8 @@ * @since n.e.x.t * @access private * - * @param string $slug URL metrics slug. - * @param array $needed_minimum_viewport_widths Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. + * @param string $slug URL metrics slug. + * @param int[] $needed_minimum_viewport_widths Minimum viewport widths for which URL metric(s) are needed. */ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_widths ): string { /** diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 6284b7d0a5..3520bacf51 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -98,18 +98,15 @@ function error( ...message ) { /** * Checks whether the URL metric(s) for the provided viewport width is needed. * - * @param {number} viewportWidth - Current viewport width. - * @param {Array[]} neededMinimumViewportWidths - Needed minimum viewport widths, in ascending order. + * @param {number} viewportWidth - Current viewport width. + * @param {number[]} neededMinimumViewportWidths - Minimum viewport widths for which URL metric(s) are needed. * @return {boolean} Whether URL metrics are needed. */ function isViewportNeeded( viewportWidth, neededMinimumViewportWidths ) { let lastWasNeeded = false; - for ( const [ - minimumViewportWidth, - isNeeded, - ] of neededMinimumViewportWidths ) { + for ( const minimumViewportWidth of neededMinimumViewportWidths ) { if ( viewportWidth >= minimumViewportWidth ) { - lastWasNeeded = isNeeded; + lastWasNeeded = true; } else { break; } diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 3623f366fe..9d533030b6 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -159,12 +159,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $needed_minimum_viewport_widths = $grouped_url_metrics->get_needed_minimum_viewport_widths( microtime( true ) ); // Whether we need to add the data-ilo-xpath attribute to elements and whether the detection script should be injected. - $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 - ); + $needs_detection = count( $needed_minimum_viewport_widths ) > 0; $lcp_elements_by_minimum_viewport_widths = $grouped_url_metrics->get_lcp_elements_by_minimum_viewport_widths(); $all_breakpoints_have_url_metrics = $grouped_url_metrics->are_all_groups_populated(); diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index e4b142d754..c2625eba22 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -126,9 +126,9 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { // This logic is the same as the isViewportNeeded() function in detect.js. $viewport_width = $request->get_param( 'viewport' )['width']; $last_was_needed = false; - foreach ( $needed_minimum_viewport_widths as list( $minimum_viewport_width, $is_needed ) ) { + foreach ( $needed_minimum_viewport_widths as $minimum_viewport_width ) { if ( $viewport_width >= $minimum_viewport_width ) { - $last_was_needed = $is_needed; + $last_was_needed = true; } else { break; } diff --git a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php index 11bf8fab19..5d7845cd72 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php @@ -360,10 +360,7 @@ public function data_provider_test_get_needed_minimum_viewport_widths(): array { 'none-needed' => array_merge( $none_needed_data, array( - 'expected' => array( - array( 0, false ), - array( 481, false ), - ), + 'expected' => array(), ) ), @@ -373,10 +370,7 @@ public function data_provider_test_get_needed_minimum_viewport_widths(): array { 'sample_size' => $none_needed_data['sample_size'] + 1, ), array( - 'expected' => array( - array( 0, true ), - array( 481, true ), - ), + 'expected' => array( 0, 481 ), ) ), @@ -388,10 +382,7 @@ public function data_provider_test_get_needed_minimum_viewport_widths(): array { return $data; } )( $none_needed_data ), array( - 'expected' => array( - array( 0, true ), - array( 481, false ), - ), + 'expected' => array( 0 ), ) ), ); From ceeab6ec00024a1bf87e57da164e6284b67fd1ca Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 29 Feb 2024 15:29:02 -0800 Subject: [PATCH 275/371] Revert "Try returning int[] from get_needed_minimum_viewport_widths instead of tuples including whether needed" This reverts commit 794061d10183286ec7b9041f5a2e54cc8a4d37af. --- .../class-ilo-grouped-url-metrics.php | 10 +++++----- .../image-loading-optimization/detection.php | 4 ++-- .../detection/detect.js | 11 +++++++---- .../image-loading-optimization/optimization.php | 7 ++++++- .../storage/rest-api.php | 4 ++-- .../class-ilo-grouped-url-metrics-tests.php | 15 ++++++++++++--- 6 files changed, 34 insertions(+), 17 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index 543882278c..5e5bf3019e 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -159,7 +159,7 @@ static function ( $breakpoint ) { * @access private * * @param float $current_time Current time, defaults to `microtime(true)`. - * @return int[] Minimum viewport widths for which URL metric(s) are needed. + * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. */ public function get_needed_minimum_viewport_widths( float $current_time = null ): array { if ( null === $current_time ) { @@ -179,10 +179,10 @@ public function get_needed_minimum_viewport_widths( float $current_time = null ) } } } - - if ( $needs_url_metrics ) { - $needed_minimum_viewport_widths[] = $minimum_viewport_width; - } + $needed_minimum_viewport_widths[] = array( + $minimum_viewport_width, + $needs_url_metrics, + ); } return $needed_minimum_viewport_widths; diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index d486f6eeb7..cfae3bfff0 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -16,8 +16,8 @@ * @since n.e.x.t * @access private * - * @param string $slug URL metrics slug. - * @param int[] $needed_minimum_viewport_widths Minimum viewport widths for which URL metric(s) are needed. + * @param string $slug URL metrics slug. + * @param array $needed_minimum_viewport_widths Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. */ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_widths ): string { /** diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 3520bacf51..6284b7d0a5 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -98,15 +98,18 @@ function error( ...message ) { /** * Checks whether the URL metric(s) for the provided viewport width is needed. * - * @param {number} viewportWidth - Current viewport width. - * @param {number[]} neededMinimumViewportWidths - Minimum viewport widths for which URL metric(s) are needed. + * @param {number} viewportWidth - Current viewport width. + * @param {Array[]} neededMinimumViewportWidths - Needed minimum viewport widths, in ascending order. * @return {boolean} Whether URL metrics are needed. */ function isViewportNeeded( viewportWidth, neededMinimumViewportWidths ) { let lastWasNeeded = false; - for ( const minimumViewportWidth of neededMinimumViewportWidths ) { + for ( const [ + minimumViewportWidth, + isNeeded, + ] of neededMinimumViewportWidths ) { if ( viewportWidth >= minimumViewportWidth ) { - lastWasNeeded = true; + lastWasNeeded = isNeeded; } else { break; } diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 9d533030b6..3623f366fe 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -159,7 +159,12 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { $needed_minimum_viewport_widths = $grouped_url_metrics->get_needed_minimum_viewport_widths( microtime( true ) ); // Whether we need to add the data-ilo-xpath attribute to elements and whether the detection script should be injected. - $needs_detection = count( $needed_minimum_viewport_widths ) > 0; + $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 + ); $lcp_elements_by_minimum_viewport_widths = $grouped_url_metrics->get_lcp_elements_by_minimum_viewport_widths(); $all_breakpoints_have_url_metrics = $grouped_url_metrics->are_all_groups_populated(); diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index c2625eba22..e4b142d754 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -126,9 +126,9 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { // This logic is the same as the isViewportNeeded() function in detect.js. $viewport_width = $request->get_param( 'viewport' )['width']; $last_was_needed = false; - foreach ( $needed_minimum_viewport_widths as $minimum_viewport_width ) { + foreach ( $needed_minimum_viewport_widths as list( $minimum_viewport_width, $is_needed ) ) { if ( $viewport_width >= $minimum_viewport_width ) { - $last_was_needed = true; + $last_was_needed = $is_needed; } else { break; } diff --git a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php index 5d7845cd72..11bf8fab19 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php @@ -360,7 +360,10 @@ public function data_provider_test_get_needed_minimum_viewport_widths(): array { 'none-needed' => array_merge( $none_needed_data, array( - 'expected' => array(), + 'expected' => array( + array( 0, false ), + array( 481, false ), + ), ) ), @@ -370,7 +373,10 @@ public function data_provider_test_get_needed_minimum_viewport_widths(): array { 'sample_size' => $none_needed_data['sample_size'] + 1, ), array( - 'expected' => array( 0, 481 ), + 'expected' => array( + array( 0, true ), + array( 481, true ), + ), ) ), @@ -382,7 +388,10 @@ public function data_provider_test_get_needed_minimum_viewport_widths(): array { return $data; } )( $none_needed_data ), array( - 'expected' => array( 0 ), + 'expected' => array( + array( 0, true ), + array( 481, false ), + ), ) ), ); From 5a0d3d8432d8850341b7b53cc38245d03083a294 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 29 Feb 2024 17:07:36 -0800 Subject: [PATCH 276/371] Add helper method to populate URL metrics --- .../storage/rest-api-tests.php | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index ebb7a8954e..dd49f7bc13 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -204,13 +204,10 @@ public function test_rest_request_breakpoint_not_needed_for_any_breakpoint() { $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); $viewport_widths = array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ); foreach ( $viewport_widths as $viewport_width ) { - for ( $i = 0; $i < $sample_size; $i++ ) { - $valid_params = $this->get_valid_params( array( 'viewport' => array( 'width' => $viewport_width ) ) ); - $request = new WP_REST_Request( 'POST', self::ROUTE ); - $request->set_body_params( $valid_params ); - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - } + $this->populate_url_metrics( + $sample_size, + $this->get_valid_params( array( 'viewport' => array( 'width' => $viewport_width ) ) ) + ); } // The next request will be rejected because all groups are fully populated with samples. @@ -233,12 +230,10 @@ public function test_rest_request_breakpoint_not_needed_for_specific_breakpoint( // First fully populate the sample for a given breakpoint. $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); - for ( $i = 0; $i < $sample_size; $i++ ) { - $request = new WP_REST_Request( 'POST', self::ROUTE ); - $request->set_body_params( $valid_params ); - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - } + $this->populate_url_metrics( + $sample_size, + $valid_params + ); // The next request will be rejected because the one group is fully populated with the needed sample size. $request = new WP_REST_Request( 'POST', self::ROUTE ); @@ -248,12 +243,12 @@ public function test_rest_request_breakpoint_not_needed_for_specific_breakpoint( } /** - * Test fully populating the larger viewport group and then adding one more. + * Test fully populating the wider viewport group and then adding one more. * * @covers ::ilo_register_endpoint * @covers ::ilo_handle_rest_request */ - public function test_rest_request_over_populate_larger_viewport_group() { + public function test_rest_request_over_populate_wider_viewport_group() { add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); // First establish a single breakpoint, so there are two groups of URL metrics @@ -270,12 +265,10 @@ static function () use ( $breakpoint_width ): array { // Fully populate the wider viewport group, leaving the narrower one empty. $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); - for ( $i = 0; $i < $sample_size; $i++ ) { - $request = new WP_REST_Request( 'POST', self::ROUTE ); - $request->set_body_params( $wider_viewport_params ); - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - } + $this->populate_url_metrics( + $sample_size, + $wider_viewport_params + ); // Sanity check that the groups were constructed as expected. $grouped_url_metrics = new ILO_Grouped_URL_Metrics( @@ -297,6 +290,21 @@ static function () use ( $breakpoint_width ): array { $this->assertSame( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response->get_data() ) ); } + /** + * Populate URL metrics. + * + * @param int $count Count of URL metrics to populate. + * @param array $params Params for URL metric. + */ + private function populate_url_metrics( int $count, array $params ) { + for ( $i = 0; $i < $count; $i++ ) { + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + } + } + /** * Gets valid params. * From a99b6cddd795769457efc18f77cf21ddbd933752 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 29 Feb 2024 17:09:03 -0800 Subject: [PATCH 277/371] Test fully populating the narrower viewport group and then adding one more --- .../storage/rest-api-tests.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php index dd49f7bc13..83abd55135 100644 --- a/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/rest-api-tests.php @@ -290,6 +290,41 @@ static function () use ( $breakpoint_width ): array { $this->assertSame( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response->get_data() ) ); } + /** + * Test fully populating the narrower viewport group and then adding one more. + * + * @covers ::ilo_register_endpoint + * @covers ::ilo_handle_rest_request + */ + public function test_rest_request_over_populate_narrower_viewport_group() { + add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); + + // First establish a single breakpoint, so there are two groups of URL metrics + // with viewport widths 0-480 and >481. + $breakpoint_width = 480; + add_filter( + 'ilo_breakpoint_max_widths', + static function () use ( $breakpoint_width ): array { + return array( $breakpoint_width ); + } + ); + + $narrower_viewport_params = $this->get_valid_params( array( 'viewport' => array( 'width' => $breakpoint_width ) ) ); + + // Fully populate the narrower viewport group, leaving the wider one empty. + $this->populate_url_metrics( + ilo_get_url_metrics_breakpoint_sample_size(), + $narrower_viewport_params + ); + + // Now attempt to store one more URL metric for the narrower viewport group. + // This should fail because the group is already fully populated to the sample size. + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $narrower_viewport_params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response->get_data() ) ); + } + /** * Populate URL metrics. * From 08d6e68028860f4f69b52cf40970048d2de18abd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 29 Feb 2024 17:18:29 -0800 Subject: [PATCH 278/371] Remove unnecessary current_time arg to get_needed_minimum_viewport_widths --- .../class-ilo-grouped-url-metrics.php | 7 ++----- modules/images/image-loading-optimization/optimization.php | 2 +- .../images/image-loading-optimization/storage/rest-api.php | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index 5e5bf3019e..c2a0b6a911 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -158,13 +158,10 @@ static function ( $breakpoint ) { * @since n.e.x.t * @access private * - * @param float $current_time Current time, defaults to `microtime(true)`. * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. */ - public function get_needed_minimum_viewport_widths( float $current_time = null ): array { - if ( null === $current_time ) { - $current_time = microtime( true ); - } + public function get_needed_minimum_viewport_widths(): array { + $current_time = microtime( true ); $needed_minimum_viewport_widths = array(); foreach ( $this->groups as $minimum_viewport_width => $viewport_url_metrics ) { diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 3623f366fe..0568652019 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -156,7 +156,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { ilo_get_url_metric_freshness_ttl() ); - $needed_minimum_viewport_widths = $grouped_url_metrics->get_needed_minimum_viewport_widths( microtime( true ) ); + $needed_minimum_viewport_widths = $grouped_url_metrics->get_needed_minimum_viewport_widths(); // Whether we need to add the data-ilo-xpath attribute to elements and whether the detection script should be injected. $needs_detection = in_array( diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index e4b142d754..3e4ca4ddd2 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -120,7 +120,7 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { ilo_get_url_metric_freshness_ttl() ); - $needed_minimum_viewport_widths = $grouped_url_metrics->get_needed_minimum_viewport_widths( microtime( true ) ); + $needed_minimum_viewport_widths = $grouped_url_metrics->get_needed_minimum_viewport_widths(); // Block the request if URL metrics aren't needed for the provided viewport width. // This logic is the same as the isViewportNeeded() function in detect.js. From b7d0819f3d2fe9d5fe9c0056caa8f0c1766e13ca Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 29 Feb 2024 17:20:59 -0800 Subject: [PATCH 279/371] Add is_group_filled method --- .../class-ilo-grouped-url-metrics.php | 18 ++++++++++++++++++ .../storage/rest-api.php | 14 ++------------ .../class-ilo-grouped-url-metrics-tests.php | 9 +++++++++ 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index c2a0b6a911..d33de6a341 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -152,6 +152,24 @@ static function ( $breakpoint ) { return $grouped; } + /** + * Determines whether the group for a given viewport has been filled to the sample size. + * + * @param int $viewport_width Viewport width. + * @return bool Whether group is filled. + */ + public function is_group_filled( int $viewport_width ): bool { + $last_was_needed = false; + foreach ( $this->get_needed_minimum_viewport_widths() as list( $minimum_viewport_width, $is_needed ) ) { + if ( $viewport_width >= $minimum_viewport_width ) { + $last_was_needed = $is_needed; + } else { + break; + } + } + return $last_was_needed; + } + /** * Gets needed minimum viewport widths. * diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 3e4ca4ddd2..45f2ab7a35 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -120,20 +120,10 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { ilo_get_url_metric_freshness_ttl() ); - $needed_minimum_viewport_widths = $grouped_url_metrics->get_needed_minimum_viewport_widths(); - // Block the request if URL metrics aren't needed for the provided viewport width. // This logic is the same as the isViewportNeeded() function in detect.js. - $viewport_width = $request->get_param( 'viewport' )['width']; - $last_was_needed = false; - foreach ( $needed_minimum_viewport_widths as list( $minimum_viewport_width, $is_needed ) ) { - if ( $viewport_width >= $minimum_viewport_width ) { - $last_was_needed = $is_needed; - } else { - break; - } - } - if ( ! $last_was_needed ) { + $viewport_width = $request->get_param( 'viewport' )['width']; + if ( ! $grouped_url_metrics->is_group_filled( $viewport_width ) ) { return new WP_Error( 'no_url_metric_needed', __( 'No URL metric needed for the provided viewport width.', 'performance-lab' ), diff --git a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php index 11bf8fab19..c08dc3cd02 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php @@ -397,6 +397,15 @@ public function data_provider_test_get_needed_minimum_viewport_widths(): array { ); } + /** + * Test is_group_filled(). + * + * @covers ::is_group_filled + */ + public function test_is_group_filled() { + $this->markTestIncomplete(); + } + /** * Test get_needed_minimum_viewport_widths(). * From bdeaaccd8621aa5ca95091d0318ab7b421f19a03 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 29 Feb 2024 17:36:39 -0800 Subject: [PATCH 280/371] Move ilo_get_lcp_elements_by_minimum_viewport_widths() back to global function --- .../class-ilo-grouped-url-metrics.php | 80 ---------- .../optimization.php | 2 +- .../storage/data.php | 82 ++++++++++ .../class-ilo-grouped-url-metrics-tests.php | 131 +-------------- .../storage/data-tests.php | 149 ++++++++++++++++++ 5 files changed, 237 insertions(+), 207 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index d33de6a341..483f6ec284 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -203,86 +203,6 @@ public function get_needed_minimum_viewport_widths(): array { return $needed_minimum_viewport_widths; } - /** - * 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 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 - * - * @return array LCP elements keyed by its minimum viewport width. If there is no supported LCP element at a breakpoint, then `false` is used. - */ - public function get_lcp_elements_by_minimum_viewport_widths(): array { - $lcp_element_by_viewport_minimum_width = array(); - foreach ( $this->groups as $viewport_minimum_width => $breakpoint_url_metrics ) { - - // The following arrays all share array indices. - $seen_breadcrumbs = array(); - $breadcrumb_counts = array(); - $breadcrumb_element = array(); - - foreach ( $breakpoint_url_metrics as $breakpoint_url_metric ) { - foreach ( $breakpoint_url_metric->get_elements() as $element ) { - if ( ! $element['isLCP'] ) { - continue; - } - - $i = array_search( $element['xpath'], $seen_breadcrumbs, true ); - if ( false === $i ) { - $i = count( $seen_breadcrumbs ); - $seen_breadcrumbs[ $i ] = $element['xpath']; - $breadcrumb_counts[ $i ] = 0; - } - - $breadcrumb_counts[ $i ] += 1; - $breadcrumb_element[ $i ] = $element; - break; // We found the LCP element for the URL metric, go to the next URL metric. - } - } - - // Now sort by the breadcrumb counts in descending order, so the remaining first key is the most common breadcrumb. - if ( $seen_breadcrumbs ) { - arsort( $breadcrumb_counts ); - $most_common_breadcrumb_index = key( $breadcrumb_counts ); - - $lcp_element_by_viewport_minimum_width[ $viewport_minimum_width ] = $breadcrumb_element[ $most_common_breadcrumb_index ]; - } elseif ( ! empty( $breakpoint_url_metrics ) ) { - $lcp_element_by_viewport_minimum_width[ $viewport_minimum_width ] = false; // No LCP image at this breakpoint. - } - } - - // Now merge the breakpoints when there is an LCP element common between them. - $prev_lcp_element = null; - return array_filter( - $lcp_element_by_viewport_minimum_width, - static function ( $lcp_element ) use ( &$prev_lcp_element ) { - $include = ( - // First element in list. - null === $prev_lcp_element - || - ( is_array( $prev_lcp_element ) && is_array( $lcp_element ) - ? - // This breakpoint and previous breakpoint had LCP element, and they were not the same element. - $prev_lcp_element['xpath'] !== $lcp_element['xpath'] - : - // This LCP element and the last LCP element were not the same. In this case, either variable may be - // false or an array, but both cannot be an array. If both are false, we don't want to include since - // it is the same. If one is an array and the other is false, then do want to include because this - // indicates a difference at this breakpoint. - $prev_lcp_element !== $lcp_element - ) - ); - $prev_lcp_element = $lcp_element; - return $include; - } - ); - } - /** * Checks whether all groups have URL metrics. * diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 0568652019..4b365e5d4b 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -166,7 +166,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { true ); - $lcp_elements_by_minimum_viewport_widths = $grouped_url_metrics->get_lcp_elements_by_minimum_viewport_widths(); + $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $grouped_url_metrics ); $all_breakpoints_have_url_metrics = $grouped_url_metrics->are_all_groups_populated(); /** diff --git a/modules/images/image-loading-optimization/storage/data.php b/modules/images/image-loading-optimization/storage/data.php index 587e94735c..67e0c1261a 100644 --- a/modules/images/image-loading-optimization/storage/data.php +++ b/modules/images/image-loading-optimization/storage/data.php @@ -183,3 +183,85 @@ function ilo_get_url_metrics_breakpoint_sample_size(): int { */ return (int) apply_filters( 'ilo_url_metrics_breakpoint_sample_size', 3 ); } + + +/** + * 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 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 ILO_Grouped_URL_Metrics $grouped_url_metrics Grouped URL metrics. + * @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( ILO_Grouped_URL_Metrics $grouped_url_metrics ): array { + $lcp_element_by_viewport_minimum_width = array(); + foreach ( $grouped_url_metrics->get_groups() as $viewport_minimum_width => $breakpoint_url_metrics ) { + + // The following arrays all share array indices. + $seen_breadcrumbs = array(); + $breadcrumb_counts = array(); + $breadcrumb_element = array(); + + foreach ( $breakpoint_url_metrics as $breakpoint_url_metric ) { + foreach ( $breakpoint_url_metric->get_elements() as $element ) { + if ( ! $element['isLCP'] ) { + continue; + } + + $i = array_search( $element['xpath'], $seen_breadcrumbs, true ); + if ( false === $i ) { + $i = count( $seen_breadcrumbs ); + $seen_breadcrumbs[ $i ] = $element['xpath']; + $breadcrumb_counts[ $i ] = 0; + } + + $breadcrumb_counts[ $i ] += 1; + $breadcrumb_element[ $i ] = $element; + break; // We found the LCP element for the URL metric, go to the next URL metric. + } + } + + // Now sort by the breadcrumb counts in descending order, so the remaining first key is the most common breadcrumb. + if ( $seen_breadcrumbs ) { + arsort( $breadcrumb_counts ); + $most_common_breadcrumb_index = key( $breadcrumb_counts ); + + $lcp_element_by_viewport_minimum_width[ $viewport_minimum_width ] = $breadcrumb_element[ $most_common_breadcrumb_index ]; + } elseif ( ! empty( $breakpoint_url_metrics ) ) { + $lcp_element_by_viewport_minimum_width[ $viewport_minimum_width ] = false; // No LCP image at this breakpoint. + } + } + + // Now merge the breakpoints when there is an LCP element common between them. + $prev_lcp_element = null; + return array_filter( + $lcp_element_by_viewport_minimum_width, + static function ( $lcp_element ) use ( &$prev_lcp_element ) { + $include = ( + // First element in list. + null === $prev_lcp_element + || + ( is_array( $prev_lcp_element ) && is_array( $lcp_element ) + ? + // This breakpoint and previous breakpoint had LCP element, and they were not the same element. + $prev_lcp_element['xpath'] !== $lcp_element['xpath'] + : + // This LCP element and the last LCP element were not the same. In this case, either variable may be + // false or an array, but both cannot be an array. If both are false, we don't want to include since + // it is the same. If one is an array and the other is false, then do want to include because this + // indicates a difference at this breakpoint. + $prev_lcp_element !== $lcp_element + ) + ); + $prev_lcp_element = $lcp_element; + return $include; + } + ); +} diff --git a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php index c08dc3cd02..278164eeb7 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php @@ -216,107 +216,6 @@ function ( $viewport_width ) { } } - /** - * Data provider. - * - * @throws ILO_Data_Validation_Exception When failing to instantiate a URL metric. - * @return array[] - */ - public function data_provider_test_get_lcp_elements_by_minimum_viewport_widths(): array { - return array( - 'common_lcp_element_across_breakpoints' => array( - 'breakpoints' => array( 600, 800 ), - 'url_metrics' => array( - // 0. - $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. - $this->get_validated_url_metric( 599, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - // 600. - $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - // 800. - $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - ), - 'expected_lcp_element_xpaths' => array( - 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), - ), - ), - 'different_lcp_elements_across_breakpoint' => array( - 'breakpoints' => array( 600 ), - 'url_metrics' => array( - // 0. - $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. - $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), - // 600. - $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - ), - 'expected_lcp_element_xpaths' => array( - 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), - 601 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), - ), - ), - 'same_lcp_element_across_non_consecutive_breakpoints' => array( - 'breakpoints' => array( 400, 600 ), - 'url_metrics' => array( - // 0. - $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - // 400. - $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'HEADER', 'IMG' ), false ), - // 600. - $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), - ), - 'expected_lcp_element_xpaths' => array( - 0 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), - 401 => false, // The (image) element is either not visible at this breakpoint or it is not LCP element. - 601 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), - ), - ), - 'no_lcp_image_elements' => array( - 'breakpoints' => array( 600 ), - 'url_metrics' => array( - // 0. - $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'IMG' ), false ), - // 600. - $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'IMG' ), false ), - ), - 'expected_lcp_element_xpaths' => array( - 0 => false, - ), - ), - ); - } - - /** - * Test get_lcp_elements_by_minimum_viewport_widths(). - * - * @covers ::get_lcp_elements_by_minimum_viewport_widths - * @dataProvider data_provider_test_get_lcp_elements_by_minimum_viewport_widths - */ - public function test_get_lcp_elements_by_minimum_viewport_widths( array $breakpoints, array $url_metrics, array $expected_lcp_element_xpaths ) { - $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, 10, HOUR_IN_SECONDS ); - - $lcp_elements_by_minimum_viewport_widths = $grouped_url_metrics->get_lcp_elements_by_minimum_viewport_widths(); - - $lcp_element_xpaths_by_minimum_viewport_widths = array(); - foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { - $this->assertTrue( is_array( $lcp_element ) || false === $lcp_element ); - if ( is_array( $lcp_element ) ) { - $this->assertTrue( $lcp_element['isLCP'] ); - $this->assertTrue( $lcp_element['isLCPCandidate'] ); - $this->assertIsString( $lcp_element['xpath'] ); - $this->assertIsNumeric( $lcp_element['intersectionRatio'] ); - $lcp_element_xpaths_by_minimum_viewport_widths[ $minimum_viewport_width ] = $lcp_element['xpath']; - } else { - $lcp_element_xpaths_by_minimum_viewport_widths[ $minimum_viewport_width ] = false; - } - } - - $this->assertSame( $expected_lcp_element_xpaths, $lcp_element_xpaths_by_minimum_viewport_widths ); - } - /** * Data provider. * @@ -467,14 +366,12 @@ public function test_flatten() { /** * Gets a validated URL metric for testing. * - * @param int $viewport_width Viewport width. - * @param string[] $breadcrumbs Breadcrumb tags. - * @param bool $is_lcp Whether LCP. + * @param int $viewport_width Viewport width. * * @return ILO_URL_Metric Validated URL metric. * @throws ILO_Data_Validation_Exception From ILO_URL_Metric if there is a parse error, but there won't be. */ - private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): ILO_URL_Metric { + private function get_validated_url_metric( int $viewport_width = 480 ): ILO_URL_Metric { $data = array( 'viewport' => array( 'width' => $viewport_width, @@ -483,31 +380,13 @@ private function get_validated_url_metric( int $viewport_width = 480, array $bre 'timestamp' => microtime( true ), 'elements' => array( array( - 'isLCP' => $is_lcp, - 'isLCPCandidate' => $is_lcp, - 'xpath' => $this->get_xpath( ...$breadcrumbs ), + 'isLCP' => true, + 'isLCPCandidate' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]/*[1]', 'intersectionRatio' => 1, ), ), ); return new ILO_URL_Metric( $data ); } - - /** - * Gets sample XPath. - * - * @param string ...$breadcrumbs List of tags. - * @return string XPath. - */ - private function get_xpath( ...$breadcrumbs ): string { - return implode( - '', - array_map( - static function ( $tag ) { - return sprintf( '/*[0][self::%s]', strtoupper( $tag ) ); - }, - $breadcrumbs - ) - ); - } } diff --git a/tests/modules/images/image-loading-optimization/storage/data-tests.php b/tests/modules/images/image-loading-optimization/storage/data-tests.php index 2b61068301..eeddffb10a 100644 --- a/tests/modules/images/image-loading-optimization/storage/data-tests.php +++ b/tests/modules/images/image-loading-optimization/storage/data-tests.php @@ -217,4 +217,153 @@ static function () { $this->assertSame( 1, ilo_get_url_metrics_breakpoint_sample_size() ); } + + + /** + * Data provider. + * + * @throws ILO_Data_Validation_Exception When failing to instantiate a URL metric. + * @return array[] + */ + public function data_provider_test_get_lcp_elements_by_minimum_viewport_widths(): array { + return array( + 'common_lcp_element_across_breakpoints' => array( + 'breakpoints' => array( 600, 800 ), + 'url_metrics' => array( + // 0. + $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. + $this->get_validated_url_metric( 599, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + // 600. + $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + // 800. + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), + ), + ), + 'different_lcp_elements_across_breakpoint' => array( + 'breakpoints' => array( 600 ), + 'url_metrics' => array( + // 0. + $this->get_validated_url_metric( 400, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'DIV', 'IMG' ) ), // Ignored since less common than the other two. + $this->get_validated_url_metric( 600, array( 'HTML', 'BODY', 'FIGURE', 'IMG' ) ), + // 600. + $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => $this->get_xpath( 'HTML', 'BODY', 'FIGURE', 'IMG' ), + 601 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + ), + ), + 'same_lcp_element_across_non_consecutive_breakpoints' => array( + 'breakpoints' => array( 400, 600 ), + 'url_metrics' => array( + // 0. + $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + // 400. + $this->get_validated_url_metric( 500, array( 'HTML', 'BODY', 'HEADER', 'IMG' ), false ), + // 600. + $this->get_validated_url_metric( 800, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + $this->get_validated_url_metric( 900, array( 'HTML', 'BODY', 'MAIN', 'IMG' ) ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + 401 => false, // The (image) element is either not visible at this breakpoint or it is not LCP element. + 601 => $this->get_xpath( 'HTML', 'BODY', 'MAIN', 'IMG' ), + ), + ), + 'no_lcp_image_elements' => array( + 'breakpoints' => array( 600 ), + 'url_metrics' => array( + // 0. + $this->get_validated_url_metric( 300, array( 'HTML', 'BODY', 'IMG' ), false ), + // 600. + $this->get_validated_url_metric( 700, array( 'HTML', 'BODY', 'IMG' ), false ), + ), + 'expected_lcp_element_xpaths' => array( + 0 => false, + ), + ), + ); + } + + /** + * Test get_lcp_elements_by_minimum_viewport_widths(). + * + * @covers ::ilo_get_lcp_elements_by_minimum_viewport_widths + * @dataProvider data_provider_test_get_lcp_elements_by_minimum_viewport_widths + */ + public function test_get_lcp_elements_by_minimum_viewport_widths( array $breakpoints, array $url_metrics, array $expected_lcp_element_xpaths ) { + $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, 10, HOUR_IN_SECONDS ); + + $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $grouped_url_metrics ); + + $lcp_element_xpaths_by_minimum_viewport_widths = array(); + foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { + $this->assertTrue( is_array( $lcp_element ) || false === $lcp_element ); + if ( is_array( $lcp_element ) ) { + $this->assertTrue( $lcp_element['isLCP'] ); + $this->assertTrue( $lcp_element['isLCPCandidate'] ); + $this->assertIsString( $lcp_element['xpath'] ); + $this->assertIsNumeric( $lcp_element['intersectionRatio'] ); + $lcp_element_xpaths_by_minimum_viewport_widths[ $minimum_viewport_width ] = $lcp_element['xpath']; + } else { + $lcp_element_xpaths_by_minimum_viewport_widths[ $minimum_viewport_width ] = false; + } + } + + $this->assertSame( $expected_lcp_element_xpaths, $lcp_element_xpaths_by_minimum_viewport_widths ); + } + + /** + * Gets a validated URL metric for testing. + * + * @param int $viewport_width Viewport width. + * @param string[] $breadcrumbs Breadcrumb tags. + * @param bool $is_lcp Whether LCP. + * + * @return ILO_URL_Metric Validated URL metric. + * @throws ILO_Data_Validation_Exception From ILO_URL_Metric if there is a parse error, but there won't be. + */ + private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): ILO_URL_Metric { + $data = array( + 'viewport' => array( + 'width' => $viewport_width, + 'height' => 640, + ), + 'timestamp' => microtime( true ), + 'elements' => array( + array( + 'isLCP' => $is_lcp, + 'isLCPCandidate' => $is_lcp, + 'xpath' => $this->get_xpath( ...$breadcrumbs ), + 'intersectionRatio' => 1, + ), + ), + ); + return new ILO_URL_Metric( $data ); + } + + /** + * Gets sample XPath. + * + * @param string ...$breadcrumbs List of tags. + * @return string XPath. + */ + private function get_xpath( ...$breadcrumbs ): string { + return implode( + '', + array_map( + static function ( $tag ) { + return sprintf( '/*[0][self::%s]', strtoupper( $tag ) ); + }, + $breadcrumbs + ) + ); + } } From 71aab05cfda314b2c3867b485a247ea879960439 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 29 Feb 2024 17:41:53 -0800 Subject: [PATCH 281/371] Update phpdoc for add method --- .../class-ilo-grouped-url-metrics.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index 483f6ec284..41f076c832 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -76,7 +76,9 @@ public function get_minimum_viewport_widths(): array { } /** - * Unshifts a new URL metric, potentially pushing out older URL metrics when exceeding the sample size. + * Adds a new URL metric to a group. + * + * Once a group reaches the sample size, the oldest URL metric is pushed out. * * @since n.e.x.t * @access private From 3523e98b62ce2627502f1b75d28c5de1cee48023 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 29 Feb 2024 17:46:54 -0800 Subject: [PATCH 282/371] Rename are_all_groups_populated to is_every_group_populated --- .../class-ilo-grouped-url-metrics.php | 6 ++++-- .../image-loading-optimization/optimization.php | 2 +- .../class-ilo-grouped-url-metrics-tests.php | 14 +++++++------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index 41f076c832..6cdfe999f5 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -206,11 +206,13 @@ public function get_needed_minimum_viewport_widths(): array { } /** - * Checks whether all groups have URL metrics. + * Checks whether every group is populated with at least one URL metric each. + * + * They aren't necessarily filled to the sample size, however. * * @return bool Whether all groups have URL metrics. */ - public function are_all_groups_populated(): bool { + public function is_every_group_populated(): bool { foreach ( $this->groups as $group ) { if ( empty( $group ) ) { return false; diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 4b365e5d4b..3d765999e7 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -167,7 +167,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { ); $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $grouped_url_metrics ); - $all_breakpoints_have_url_metrics = $grouped_url_metrics->are_all_groups_populated(); + $all_breakpoints_have_url_metrics = $grouped_url_metrics->is_every_group_populated(); /** * Optimized lookup of the LCP element viewport widths by XPath. diff --git a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php index 278164eeb7..e6c32c06fa 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php @@ -321,24 +321,24 @@ public function test_get_needed_minimum_viewport_widths( array $url_metrics, flo } /** - * Test are_all_groups_populated(). + * Test is_every_group_populated(). * - * @covers ::are_all_groups_populated + * @covers ::is_every_group_populated */ - public function test_are_all_groups_populated() { + public function test_is_every_group_populated() { $grouped_url_metrics = new ILO_Grouped_URL_Metrics( array(), array( 480, 800 ), 3, HOUR_IN_SECONDS ); - $this->assertFalse( $grouped_url_metrics->are_all_groups_populated() ); + $this->assertFalse( $grouped_url_metrics->is_every_group_populated() ); $grouped_url_metrics->add( $this->get_validated_url_metric( 200 ) ); - $this->assertFalse( $grouped_url_metrics->are_all_groups_populated() ); + $this->assertFalse( $grouped_url_metrics->is_every_group_populated() ); $grouped_url_metrics->add( $this->get_validated_url_metric( 500 ) ); - $this->assertFalse( $grouped_url_metrics->are_all_groups_populated() ); + $this->assertFalse( $grouped_url_metrics->is_every_group_populated() ); $grouped_url_metrics->add( $this->get_validated_url_metric( 900 ) ); - $this->assertTrue( $grouped_url_metrics->are_all_groups_populated() ); + $this->assertTrue( $grouped_url_metrics->is_every_group_populated() ); } /** From d8ffc9c47d81b715d38d28fd59116689b131de6d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 29 Feb 2024 21:47:50 -0800 Subject: [PATCH 283/371] Rename neededMinimumViewportWidths to lackingViewportGroups and is_group_filled to is_viewport_group_lacking --- .../class-ilo-grouped-url-metrics.php | 22 +++++--- .../image-loading-optimization/detection.php | 26 ++++----- .../detection/detect.js | 37 ++++++------ .../optimization.php | 6 +- .../storage/rest-api.php | 2 +- .../class-ilo-grouped-url-metrics-tests.php | 56 ++++++++++++------- .../detection-tests.php | 6 +- 7 files changed, 88 insertions(+), 67 deletions(-) diff --git a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php index 6cdfe999f5..7877663b9c 100644 --- a/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php +++ b/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics.php @@ -155,14 +155,17 @@ static function ( $breakpoint ) { } /** - * Determines whether the group for a given viewport has been filled to the sample size. + * Determines whether the group for a given viewport is lacking URL metrics. + * + * Either the viewport group does not have enough URL metrics for the desired sample size, + * or some of the URL metrics are stale. * * @param int $viewport_width Viewport width. * @return bool Whether group is filled. */ - public function is_group_filled( int $viewport_width ): bool { + public function is_viewport_group_lacking( int $viewport_width ): bool { $last_was_needed = false; - foreach ( $this->get_needed_minimum_viewport_widths() as list( $minimum_viewport_width, $is_needed ) ) { + foreach ( $this->get_lacking_viewport_groups() as list( $minimum_viewport_width, $is_needed ) ) { if ( $viewport_width >= $minimum_viewport_width ) { $last_was_needed = $is_needed; } else { @@ -173,17 +176,20 @@ public function is_group_filled( int $viewport_width ): bool { } /** - * Gets needed minimum viewport widths. + * Gets which viewport groups are lacking URL metrics. + * + * A viewport group is lacking URL metrics if it does not have the desired sample size or + * if some of the URL metrics are stale. * * @since n.e.x.t * @access private * * @return array Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. */ - public function get_needed_minimum_viewport_widths(): array { + public function get_lacking_viewport_groups(): array { $current_time = microtime( true ); - $needed_minimum_viewport_widths = array(); + $lacking_viewport_groups = array(); foreach ( $this->groups as $minimum_viewport_width => $viewport_url_metrics ) { $needs_url_metrics = false; if ( count( $viewport_url_metrics ) < $this->sample_size ) { @@ -196,13 +202,13 @@ public function get_needed_minimum_viewport_widths(): array { } } } - $needed_minimum_viewport_widths[] = array( + $lacking_viewport_groups[] = array( $minimum_viewport_width, $needs_url_metrics, ); } - return $needed_minimum_viewport_widths; + return $lacking_viewport_groups; } /** diff --git a/modules/images/image-loading-optimization/detection.php b/modules/images/image-loading-optimization/detection.php index cfae3bfff0..d1565b865d 100644 --- a/modules/images/image-loading-optimization/detection.php +++ b/modules/images/image-loading-optimization/detection.php @@ -16,10 +16,10 @@ * @since n.e.x.t * @access private * - * @param string $slug URL metrics slug. - * @param array $needed_minimum_viewport_widths Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. + * @param string $slug URL metrics slug. + * @param array $lacking_viewport_groups Array of tuples mapping minimum viewport width to whether URL metric(s) are needed. */ -function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_widths ): string { +function ilo_get_detection_script( string $slug, array $lacking_viewport_groups ): string { /** * Filters the time window between serve time and run time in which loading detection is allowed to run. * @@ -39,16 +39,16 @@ function ilo_get_detection_script( string $slug, array $needed_minimum_viewport_ $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . '/detection/web-vitals.js' ); $detect_args = array( - 'serveTime' => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. - 'detectionTimeWindow' => $detection_time_window, - 'isDebug' => WP_DEBUG, - 'restApiEndpoint' => rest_url( ILO_REST_API_NAMESPACE . ILO_URL_METRICS_ROUTE ), - 'restApiNonce' => wp_create_nonce( 'wp_rest' ), - 'urlMetricsSlug' => $slug, - 'urlMetricsNonce' => ilo_get_url_metrics_storage_nonce( $slug ), - 'neededMinimumViewportWidths' => $needed_minimum_viewport_widths, - 'storageLockTTL' => ilo_get_url_metric_storage_lock_ttl(), - 'webVitalsLibrarySrc' => $web_vitals_lib_src, + 'serveTime' => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. + 'detectionTimeWindow' => $detection_time_window, + 'isDebug' => WP_DEBUG, + 'restApiEndpoint' => rest_url( ILO_REST_API_NAMESPACE . ILO_URL_METRICS_ROUTE ), + 'restApiNonce' => wp_create_nonce( 'wp_rest' ), + 'urlMetricsSlug' => $slug, + 'urlMetricsNonce' => ilo_get_url_metrics_storage_nonce( $slug ), + 'lackingViewportGroups' => $lacking_viewport_groups, + 'storageLockTTL' => ilo_get_url_metric_storage_lock_ttl(), + 'webVitalsLibrarySrc' => $web_vitals_lib_src, ); return wp_get_inline_script_tag( diff --git a/modules/images/image-loading-optimization/detection/detect.js b/modules/images/image-loading-optimization/detection/detect.js index 6284b7d0a5..8201a4f8ae 100644 --- a/modules/images/image-loading-optimization/detection/detect.js +++ b/modules/images/image-loading-optimization/detection/detect.js @@ -98,16 +98,13 @@ function error( ...message ) { /** * Checks whether the URL metric(s) for the provided viewport width is needed. * - * @param {number} viewportWidth - Current viewport width. - * @param {Array[]} neededMinimumViewportWidths - Needed minimum viewport widths, in ascending order. + * @param {number} viewportWidth - Current viewport width. + * @param {Array[]} lackingViewportGroups - Needed minimum viewport widths, in ascending order. * @return {boolean} Whether URL metrics are needed. */ -function isViewportNeeded( viewportWidth, neededMinimumViewportWidths ) { +function isViewportNeeded( viewportWidth, lackingViewportGroups ) { let lastWasNeeded = false; - for ( const [ - minimumViewportWidth, - isNeeded, - ] of neededMinimumViewportWidths ) { + for ( const [ minimumViewportWidth, isNeeded ] of lackingViewportGroups ) { if ( viewportWidth >= minimumViewportWidth ) { lastWasNeeded = isNeeded; } else { @@ -129,17 +126,17 @@ function getCurrentTime() { /** * Detects the LCP element, loaded images, client viewport and store for future optimizations. * - * @param {Object} args Args. - * @param {number} args.serveTime The serve time of the page in milliseconds from PHP via `microtime( true ) * 1000`. - * @param {number} args.detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. - * @param {boolean} args.isDebug Whether to show debug messages. - * @param {string} args.restApiEndpoint URL for where to send the detection data. - * @param {string} args.restApiNonce Nonce for writing to the REST API. - * @param {string} args.urlMetricsSlug Slug for URL metrics. - * @param {string} args.urlMetricsNonce Nonce for URL metrics storage. - * @param {Array[]} args.neededMinimumViewportWidths Needed minimum viewport widths for URL metrics. - * @param {number} args.storageLockTTL The TTL (in seconds) for the URL metric storage lock. - * @param {string} args.webVitalsLibrarySrc The URL for the web-vitals library. + * @param {Object} args Args. + * @param {number} args.serveTime The serve time of the page in milliseconds from PHP via `microtime( true ) * 1000`. + * @param {number} args.detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. + * @param {boolean} args.isDebug Whether to show debug messages. + * @param {string} args.restApiEndpoint URL for where to send the detection data. + * @param {string} args.restApiNonce Nonce for writing to the REST API. + * @param {string} args.urlMetricsSlug Slug for URL metrics. + * @param {string} args.urlMetricsNonce Nonce for URL metrics storage. + * @param {Array[]} args.lackingViewportGroups Needed minimum viewport widths for URL metrics. + * @param {number} args.storageLockTTL The TTL (in seconds) for the URL metric storage lock. + * @param {string} args.webVitalsLibrarySrc The URL for the web-vitals library. */ export default async function detect( { serveTime, @@ -149,7 +146,7 @@ export default async function detect( { restApiNonce, urlMetricsSlug, urlMetricsNonce, - neededMinimumViewportWidths, + lackingViewportGroups, storageLockTTL, webVitalsLibrarySrc, } ) { @@ -166,7 +163,7 @@ export default async function detect( { } // Abort if the current viewport is not among those which need URL metrics. - if ( ! isViewportNeeded( win.innerWidth, neededMinimumViewportWidths ) ) { + if ( ! isViewportNeeded( win.innerWidth, lackingViewportGroups ) ) { if ( isDebug ) { log( 'No need for URL metrics from the current viewport.' ); } diff --git a/modules/images/image-loading-optimization/optimization.php b/modules/images/image-loading-optimization/optimization.php index 3d765999e7..333a7af380 100644 --- a/modules/images/image-loading-optimization/optimization.php +++ b/modules/images/image-loading-optimization/optimization.php @@ -156,13 +156,13 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { ilo_get_url_metric_freshness_ttl() ); - $needed_minimum_viewport_widths = $grouped_url_metrics->get_needed_minimum_viewport_widths(); + $lacking_viewport_groups = $grouped_url_metrics->get_lacking_viewport_groups(); // Whether we need to add the data-ilo-xpath attribute to elements and whether the detection script should be injected. $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 ), + array_column( $lacking_viewport_groups, 1 ), true ); @@ -315,7 +315,7 @@ function ilo_optimize_template_output_buffer( string $buffer ): string { // Inject detection script. // TODO: When optimizing above, if we find that there is a stored LCP element but it fails to match, it should perhaps set $needs_detection to true and send the request with an override nonce. However, this would require backtracking and adding the data-ilo-xpath attributes. if ( $needs_detection ) { - $head_injection .= ilo_get_detection_script( $slug, $needed_minimum_viewport_widths ); + $head_injection .= ilo_get_detection_script( $slug, $lacking_viewport_groups ); } if ( $head_injection ) { diff --git a/modules/images/image-loading-optimization/storage/rest-api.php b/modules/images/image-loading-optimization/storage/rest-api.php index 45f2ab7a35..ba002f6c5b 100644 --- a/modules/images/image-loading-optimization/storage/rest-api.php +++ b/modules/images/image-loading-optimization/storage/rest-api.php @@ -123,7 +123,7 @@ function ilo_handle_rest_request( WP_REST_Request $request ) { // Block the request if URL metrics aren't needed for the provided viewport width. // This logic is the same as the isViewportNeeded() function in detect.js. $viewport_width = $request->get_param( 'viewport' )['width']; - if ( ! $grouped_url_metrics->is_group_filled( $viewport_width ) ) { + if ( ! $grouped_url_metrics->is_viewport_group_lacking( $viewport_width ) ) { return new WP_Error( 'no_url_metric_needed', __( 'No URL metric needed for the provided viewport width.', 'performance-lab' ), diff --git a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php index e6c32c06fa..b967bfd1bc 100644 --- a/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php +++ b/tests/modules/images/image-loading-optimization/class-ilo-grouped-url-metrics-tests.php @@ -221,7 +221,7 @@ function ( $viewport_width ) { * * @return array[] */ - public function data_provider_test_get_needed_minimum_viewport_widths(): array { + public function data_provider_test_get_lacking_viewport_groups(): array { $current_time = microtime( true ); $none_needed_data = array( @@ -259,10 +259,15 @@ public function data_provider_test_get_needed_minimum_viewport_widths(): array { 'none-needed' => array_merge( $none_needed_data, array( - 'expected' => array( + 'expected_return' => array( array( 0, false ), array( 481, false ), ), + 'expected_is_group_filled' => array( + 400 => false, + 480 => false, + 600 => false, + ), ) ), @@ -272,10 +277,16 @@ public function data_provider_test_get_needed_minimum_viewport_widths(): array { 'sample_size' => $none_needed_data['sample_size'] + 1, ), array( - 'expected' => array( + 'expected_return' => array( array( 0, true ), array( 481, true ), ), + 'expected_is_group_filled' => array( + 200 => true, + 480 => true, + 481 => true, + 500 => true, + ), ) ), @@ -287,37 +298,44 @@ public function data_provider_test_get_needed_minimum_viewport_widths(): array { return $data; } )( $none_needed_data ), array( - 'expected' => array( + 'expected_return' => array( array( 0, true ), array( 481, false ), ), + 'expected_is_group_filled' => array( + 200 => true, + 400 => true, + 480 => true, + 481 => false, + 500 => false, + ), ) ), ); } /** - * Test is_group_filled(). - * - * @covers ::is_group_filled - */ - public function test_is_group_filled() { - $this->markTestIncomplete(); - } - - /** - * Test get_needed_minimum_viewport_widths(). + * Test get_lacking_viewport_groups(). * - * @covers ::get_needed_minimum_viewport_widths + * @covers ::get_lacking_viewport_groups + * @covers ::is_viewport_group_lacking * - * @dataProvider data_provider_test_get_needed_minimum_viewport_widths + * @dataProvider data_provider_test_get_lacking_viewport_groups */ - public function test_get_needed_minimum_viewport_widths( array $url_metrics, float $current_time, array $breakpoints, int $sample_size, int $freshness_ttl, array $expected ) { + public function test_get_lacking_viewport_groups( array $url_metrics, float $current_time, array $breakpoints, int $sample_size, int $freshness_ttl, array $expected_return, array $expected_is_group_filled ) { $grouped_url_metrics = new ILO_Grouped_URL_Metrics( $url_metrics, $breakpoints, $sample_size, $freshness_ttl ); $this->assertSame( - $expected, - $grouped_url_metrics->get_needed_minimum_viewport_widths() + $expected_return, + $grouped_url_metrics->get_lacking_viewport_groups() ); + + foreach ( $expected_is_group_filled as $viewport_width => $expected ) { + $this->assertSame( + $expected, + $grouped_url_metrics->is_viewport_group_lacking( $viewport_width ), + "Unexpected value for viewport width of $viewport_width" + ); + } } /** diff --git a/tests/modules/images/image-loading-optimization/detection-tests.php b/tests/modules/images/image-loading-optimization/detection-tests.php index 712f31aec0..8657816e0c 100644 --- a/tests/modules/images/image-loading-optimization/detection-tests.php +++ b/tests/modules/images/image-loading-optimization/detection-tests.php @@ -57,13 +57,13 @@ static function (): int { */ public function test_ilo_get_detection_script_returns_script( Closure $set_up, array $expected_exports ) { $set_up(); - $slug = ilo_get_url_metrics_slug( array( 'p' => '1' ) ); - $needed_minimum_viewport_widths = array( + $slug = ilo_get_url_metrics_slug( array( 'p' => '1' ) ); + $lacking_viewport_groups = array( array( 480, false ), array( 600, false ), array( 782, true ), ); - $script = ilo_get_detection_script( $slug, $needed_minimum_viewport_widths ); + $script = ilo_get_detection_script( $slug, $lacking_viewport_groups ); $this->assertStringContainsString( ' - Foo + Foo ', @@ -322,7 +322,7 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array ', - // There should be no data-ilo-xpath added to the DIV because it is using a data: URL for the background-image. + // There should be no data-od-xpath added to the DIV because it is using a data: URL for the background-image. 'expected' => ' @@ -351,7 +351,7 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array ', - // There should be no data-ilo-xpath added to the IMG because it is using a data: URL. + // There should be no data-od-xpath added to the IMG because it is using a data: URL. 'expected' => ' @@ -397,11 +397,11 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array 'common-lcp-image-with-fully-populated-sample-data' => array( 'set_up' => function () { - $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); - $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); - foreach ( array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), $slug, $this->get_validated_url_metric( @@ -438,11 +438,11 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array ... - + - Foo - Bar + Foo + Bar ', @@ -450,11 +450,11 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array 'common-lcp-image-with-stale-sample-data' => array( 'set_up' => function () { - $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); - $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); - foreach ( array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), $slug, $this->get_validated_url_metric( @@ -499,11 +499,11 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array 'common-lcp-background-image-with-fully-populated-sample-data' => array( 'set_up' => function () { - $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); - $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); - foreach ( array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), $slug, $this->get_validated_url_metric( @@ -535,7 +535,7 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array ... - +
      This is so background!
      @@ -550,14 +550,14 @@ public function data_provider_test_ilo_optimize_template_output_buffer(): array $tablet_breakpoint = 600; $desktop_breakpoint = 782; add_filter( - 'ilo_breakpoint_max_widths', + 'od_breakpoint_max_widths', static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { return array( $mobile_breakpoint, $tablet_breakpoint ); } ); - $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); - $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); $div_index_to_viewport_width_mapping = array( 0 => $desktop_breakpoint, 1 => $tablet_breakpoint, @@ -566,7 +566,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { foreach ( $div_index_to_viewport_width_mapping as $div_index => $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), $slug, $this->get_validated_url_metric( @@ -602,9 +602,9 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ... - - - + + +
      This is the desktop background!
      @@ -617,11 +617,11 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { 'fetch-priority-high-already-on-common-lcp-image-with-fully-populated-sample-data' => array( 'set_up' => function () { - $slug = ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ); - $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); - foreach ( array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), $slug, $this->get_validated_url_metric( @@ -653,10 +653,10 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ... - + - Foo + Foo ', @@ -664,9 +664,9 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { 'url-metric-only-captured-for-one-breakpoint' => array( 'set_up' => function () { - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), - ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 400, array( @@ -694,11 +694,11 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ... - + - Foo + Foo ', @@ -706,9 +706,9 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { 'different-lcp-elements-for-different-breakpoints' => array( 'set_up' => function () { - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), - ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 400, array( @@ -723,9 +723,9 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ) ) ); - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), - ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 800, array( @@ -758,13 +758,13 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ... - - + + - Mobile Logo - Desktop Logo + Mobile Logo + Desktop Logo ', @@ -773,15 +773,15 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { 'different-lcp-elements-for-two-non-consecutive-breakpoints' => array( 'set_up' => function () { add_filter( - 'ilo_breakpoint_max_widths', + 'od_breakpoint_max_widths', static function () { return array( 480, 600, 782 ); } ); - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), - ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 400, array( @@ -796,9 +796,9 @@ static function () { ) ) ); - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), - ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 500, array( @@ -813,9 +813,9 @@ static function () { ) ) ); - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), - ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 700, array( @@ -830,9 +830,9 @@ static function () { ) ) ); - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), - ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 800, array( @@ -865,13 +865,13 @@ static function () { ... - - + + - Mobile Logo - Desktop Logo + Mobile Logo + Desktop Logo ', @@ -880,15 +880,15 @@ static function () { 'different-lcp-elements-for-two-non-consecutive-breakpoints-and-one-is-stale' => array( 'set_up' => function () { add_filter( - 'ilo_breakpoint_max_widths', + 'od_breakpoint_max_widths', static function () { return array( 480, 600, 782 ); } ); - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), - ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 500, array( @@ -903,9 +903,9 @@ static function () { ) ) ); - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), - ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 650, array( @@ -920,9 +920,9 @@ static function () { ) ) ); - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), - ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 800, array( @@ -937,9 +937,9 @@ static function () { ) ) ); - ilo_store_url_metric( + od_store_url_metric( home_url( '/' ), - ilo_get_url_metrics_slug( ilo_get_normalized_query_vars() ), + od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 800, array( @@ -973,13 +973,13 @@ static function () { ... - + - Mobile Logo + Mobile Logo

      New paragraph since URL Metrics were captured!

      - Desktop Logo + Desktop Logo ', @@ -988,17 +988,17 @@ static function () { } /** - * Test ilo_optimize_template_output_buffer(). + * Test od_optimize_template_output_buffer(). * - * @covers ::ilo_optimize_template_output_buffer + * @covers ::od_optimize_template_output_buffer * - * @dataProvider data_provider_test_ilo_optimize_template_output_buffer + * @dataProvider data_provider_test_od_optimize_template_output_buffer */ - public function test_ilo_optimize_template_output_buffer( Closure $set_up, string $buffer, string $expected ) { + public function test_od_optimize_template_output_buffer( Closure $set_up, string $buffer, string $expected ) { $set_up(); $this->assertEquals( $this->parse_html_document( $expected ), - $this->parse_html_document( ilo_optimize_template_output_buffer( $buffer ) ) + $this->parse_html_document( od_optimize_template_output_buffer( $buffer ) ) ); } @@ -1016,10 +1016,10 @@ private function normalize_whitespace( string $str ): string { * Gets a validated URL metric. * * @param int $viewport_width Viewport width for the URL metric. - * @return ILO_URL_Metric URL metric. - * @throws Exception From ILO_URL_Metric if there is a parse error, but there won't be. + * @return OD_URL_Metric URL metric. + * @throws Exception From OD_URL_Metric if there is a parse error, but there won't be. */ - private function get_validated_url_metric( int $viewport_width, array $elements = array() ): ILO_URL_Metric { + private function get_validated_url_metric( int $viewport_width, array $elements = array() ): OD_URL_Metric { $data = array( 'viewport' => array( 'width' => $viewport_width, @@ -1039,7 +1039,7 @@ static function ( array $element ): array { $elements ), ); - return new ILO_URL_Metric( $data ); + return new OD_URL_Metric( $data ); } /** @@ -1067,7 +1067,7 @@ protected function parse_html_document( string $markup ): DOMDocument { $node->parentNode->insertBefore( $dom->createTextNode( "\n" ), $node ); } - // Normalize contents of module script output by ilo_get_detection_script(). + // Normalize contents of module script output by od_get_detection_script(). foreach ( $xpath->query( '//script[ contains( text(), "import detect" ) ]' ) as $script ) { $script->textContent = '/* import detect ... */'; } diff --git a/tests/plugins/optimization-detective/storage/data-tests.php b/tests/plugins/optimization-detective/storage/data-tests.php index c35635c9a1..0e3294dc86 100644 --- a/tests/plugins/optimization-detective/storage/data-tests.php +++ b/tests/plugins/optimization-detective/storage/data-tests.php @@ -1,13 +1,13 @@ assertSame( DAY_IN_SECONDS, ilo_get_url_metric_freshness_ttl() ); + public function test_od_get_url_metric_freshness_ttl() { + $this->assertSame( DAY_IN_SECONDS, od_get_url_metric_freshness_ttl() ); add_filter( - 'ilo_url_metric_freshness_ttl', + 'od_url_metric_freshness_ttl', static function (): int { return HOUR_IN_SECONDS; } ); - $this->assertSame( HOUR_IN_SECONDS, ilo_get_url_metric_freshness_ttl() ); + $this->assertSame( HOUR_IN_SECONDS, od_get_url_metric_freshness_ttl() ); } /** - * Test bad ilo_get_url_metric_freshness_ttl(). + * Test bad od_get_url_metric_freshness_ttl(). * - * @expectedIncorrectUsage ilo_get_url_metric_freshness_ttl - * @covers ::ilo_get_url_metric_freshness_ttl + * @expectedIncorrectUsage od_get_url_metric_freshness_ttl + * @covers ::od_get_url_metric_freshness_ttl */ - public function test_bad_ilo_get_url_metric_freshness_ttl() { + public function test_bad_od_get_url_metric_freshness_ttl() { add_filter( - 'ilo_url_metric_freshness_ttl', + 'od_url_metric_freshness_ttl', static function (): int { return -1; } ); - $this->assertSame( 0, ilo_get_url_metric_freshness_ttl() ); + $this->assertSame( 0, od_get_url_metric_freshness_ttl() ); } /** @@ -65,7 +65,7 @@ static function (): int { * * @return array */ - public function data_provider_test_ilo_get_normalized_query_vars(): array { + public function data_provider_test_od_get_normalized_query_vars(): array { return array( 'homepage' => array( 'set_up' => function (): array { @@ -120,25 +120,25 @@ public function data_provider_test_ilo_get_normalized_query_vars(): array { } /** - * Test ilo_get_normalized_query_vars(). + * Test od_get_normalized_query_vars(). * - * @covers ::ilo_get_normalized_query_vars + * @covers ::od_get_normalized_query_vars * - * @dataProvider data_provider_test_ilo_get_normalized_query_vars + * @dataProvider data_provider_test_od_get_normalized_query_vars */ - public function test_ilo_get_normalized_query_vars( Closure $set_up ) { + public function test_od_get_normalized_query_vars( Closure $set_up ) { $expected = $set_up(); - $this->assertSame( $expected, ilo_get_normalized_query_vars() ); + $this->assertSame( $expected, od_get_normalized_query_vars() ); } /** - * Test ilo_get_url_metrics_slug(). + * Test od_get_url_metrics_slug(). * - * @covers ::ilo_get_url_metrics_slug + * @covers ::od_get_url_metrics_slug */ - public function test_ilo_get_url_metrics_slug() { - $first = ilo_get_url_metrics_slug( array() ); - $second = ilo_get_url_metrics_slug( array( 'p' => 1 ) ); + public function test_od_get_url_metrics_slug() { + $first = od_get_url_metrics_slug( array() ); + $second = od_get_url_metrics_slug( array( 'p' => 1 ) ); $this->assertNotEquals( $second, $first ); foreach ( array( $first, $second ) as $slug ) { $this->assertMatchesRegularExpression( '/^[0-9a-f]{32}$/', $slug ); @@ -146,12 +146,12 @@ public function test_ilo_get_url_metrics_slug() { } /** - * Test ilo_get_url_metrics_storage_nonce(). + * Test od_get_url_metrics_storage_nonce(). * - * @covers ::ilo_get_url_metrics_storage_nonce - * @covers ::ilo_verify_url_metrics_storage_nonce + * @covers ::od_get_url_metrics_storage_nonce + * @covers ::od_verify_url_metrics_storage_nonce */ - public function test_ilo_get_url_metrics_storage_nonce_and_ilo_verify_url_metrics_storage_nonce() { + public function test_od_get_url_metrics_storage_nonce_and_od_verify_url_metrics_storage_nonce() { $user_id = self::factory()->user->create(); $nonce_life_actions = array(); @@ -166,23 +166,23 @@ static function ( int $life, string $action ) use ( &$nonce_life_actions ): int ); // Create first nonce for unauthenticated user. - $slug = ilo_get_url_metrics_slug( array() ); - $nonce1 = ilo_get_url_metrics_storage_nonce( $slug ); + $slug = od_get_url_metrics_slug( array() ); + $nonce1 = od_get_url_metrics_storage_nonce( $slug ); $this->assertMatchesRegularExpression( '/^[0-9a-f]{10}$/', $nonce1 ); - $this->assertTrue( ilo_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); + $this->assertTrue( od_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); $this->assertCount( 2, $nonce_life_actions ); // Create second nonce for unauthenticated user. - $nonce2 = ilo_get_url_metrics_storage_nonce( $slug ); + $nonce2 = od_get_url_metrics_storage_nonce( $slug ); $this->assertSame( $nonce1, $nonce2 ); $this->assertCount( 3, $nonce_life_actions ); // Create third nonce, this time for authenticated user. wp_set_current_user( $user_id ); - $nonce3 = ilo_get_url_metrics_storage_nonce( $slug ); + $nonce3 = od_get_url_metrics_storage_nonce( $slug ); $this->assertNotEquals( $nonce3, $nonce2 ); - $this->assertFalse( ilo_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); - $this->assertTrue( ilo_verify_url_metrics_storage_nonce( $nonce3, $slug ) ); + $this->assertFalse( od_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); + $this->assertTrue( od_verify_url_metrics_storage_nonce( $nonce3, $slug ) ); $this->assertCount( 6, $nonce_life_actions ); foreach ( $nonce_life_actions as $nonce_life_action ) { @@ -191,20 +191,20 @@ static function ( int $life, string $action ) use ( &$nonce_life_actions ): int } /** - * Test ilo_get_breakpoint_max_widths(). + * Test od_get_breakpoint_max_widths(). * - * @covers ::ilo_get_breakpoint_max_widths + * @covers ::od_get_breakpoint_max_widths */ - public function test_ilo_get_breakpoint_max_widths() { + public function test_od_get_breakpoint_max_widths() { $this->assertSame( array( 480, 600, 782 ), - ilo_get_breakpoint_max_widths() + od_get_breakpoint_max_widths() ); $filtered_breakpoints = array( 2000, 500, '1000', 3000 ); add_filter( - 'ilo_breakpoint_max_widths', + 'od_breakpoint_max_widths', static function () use ( $filtered_breakpoints ) { return $filtered_breakpoints; } @@ -212,7 +212,7 @@ static function () use ( $filtered_breakpoints ) { $filtered_breakpoints = array_map( 'intval', $filtered_breakpoints ); sort( $filtered_breakpoints ); - $this->assertSame( $filtered_breakpoints, ilo_get_breakpoint_max_widths() ); + $this->assertSame( $filtered_breakpoints, od_get_breakpoint_max_widths() ); } /** @@ -220,7 +220,7 @@ static function () use ( $filtered_breakpoints ) { * * @return array */ - public function data_provider_test_bad_ilo_get_breakpoint_max_widths(): array { + public function data_provider_test_bad_od_get_breakpoint_max_widths(): array { return array( 'negative' => array( 'breakpoints' => array( -1 ), @@ -242,63 +242,63 @@ public function data_provider_test_bad_ilo_get_breakpoint_max_widths(): array { } /** - * Test bad ilo_get_breakpoint_max_widths(). + * Test bad od_get_breakpoint_max_widths(). * - * @covers ::ilo_get_breakpoint_max_widths + * @covers ::od_get_breakpoint_max_widths * - * @expectedIncorrectUsage ilo_get_breakpoint_max_widths - * @dataProvider data_provider_test_bad_ilo_get_breakpoint_max_widths + * @expectedIncorrectUsage od_get_breakpoint_max_widths + * @dataProvider data_provider_test_bad_od_get_breakpoint_max_widths */ - public function test_bad_ilo_get_breakpoint_max_widths( array $breakpoints, array $expected ) { + public function test_bad_od_get_breakpoint_max_widths( array $breakpoints, array $expected ) { add_filter( - 'ilo_breakpoint_max_widths', + 'od_breakpoint_max_widths', static function () use ( $breakpoints ) { return $breakpoints; } ); - $this->assertSame( $expected, ilo_get_breakpoint_max_widths() ); + $this->assertSame( $expected, od_get_breakpoint_max_widths() ); } /** - * Test ilo_get_url_metrics_breakpoint_sample_size(). + * Test od_get_url_metrics_breakpoint_sample_size(). * - * @covers ::ilo_get_url_metrics_breakpoint_sample_size + * @covers ::od_get_url_metrics_breakpoint_sample_size */ - public function test_ilo_get_url_metrics_breakpoint_sample_size() { - $this->assertSame( 3, ilo_get_url_metrics_breakpoint_sample_size() ); + public function test_od_get_url_metrics_breakpoint_sample_size() { + $this->assertSame( 3, od_get_url_metrics_breakpoint_sample_size() ); add_filter( - 'ilo_url_metrics_breakpoint_sample_size', + 'od_url_metrics_breakpoint_sample_size', static function () { return '1'; } ); - $this->assertSame( 1, ilo_get_url_metrics_breakpoint_sample_size() ); + $this->assertSame( 1, od_get_url_metrics_breakpoint_sample_size() ); } /** - * Test bad ilo_get_url_metrics_breakpoint_sample_size(). + * Test bad od_get_url_metrics_breakpoint_sample_size(). * - * @expectedIncorrectUsage ilo_get_url_metrics_breakpoint_sample_size - * @covers ::ilo_get_url_metrics_breakpoint_sample_size + * @expectedIncorrectUsage od_get_url_metrics_breakpoint_sample_size + * @covers ::od_get_url_metrics_breakpoint_sample_size */ - public function test_bad_ilo_get_url_metrics_breakpoint_sample_size() { + public function test_bad_od_get_url_metrics_breakpoint_sample_size() { add_filter( - 'ilo_url_metrics_breakpoint_sample_size', + 'od_url_metrics_breakpoint_sample_size', static function (): int { return 0; } ); - $this->assertSame( 1, ilo_get_url_metrics_breakpoint_sample_size() ); + $this->assertSame( 1, od_get_url_metrics_breakpoint_sample_size() ); } /** * Data provider. * - * @throws ILO_Data_Validation_Exception When failing to instantiate a URL metric. + * @throws OD_Data_Validation_Exception When failing to instantiate a URL metric. * @return array[] */ public function data_provider_test_get_lcp_elements_by_minimum_viewport_widths(): array { @@ -371,13 +371,13 @@ public function data_provider_test_get_lcp_elements_by_minimum_viewport_widths() /** * Test get_lcp_elements_by_minimum_viewport_widths(). * - * @covers ::ilo_get_lcp_elements_by_minimum_viewport_widths + * @covers ::od_get_lcp_elements_by_minimum_viewport_widths * @dataProvider data_provider_test_get_lcp_elements_by_minimum_viewport_widths */ public function test_get_lcp_elements_by_minimum_viewport_widths( array $breakpoints, array $url_metrics, array $expected_lcp_element_xpaths ) { - $group_collection = new ILO_URL_Metrics_Group_Collection( $url_metrics, $breakpoints, 10, HOUR_IN_SECONDS ); + $group_collection = new OD_URL_Metrics_Group_Collection( $url_metrics, $breakpoints, 10, HOUR_IN_SECONDS ); - $lcp_elements_by_minimum_viewport_widths = ilo_get_lcp_elements_by_minimum_viewport_widths( $group_collection ); + $lcp_elements_by_minimum_viewport_widths = od_get_lcp_elements_by_minimum_viewport_widths( $group_collection ); $lcp_element_xpaths_by_minimum_viewport_widths = array(); foreach ( $lcp_elements_by_minimum_viewport_widths as $minimum_viewport_width => $lcp_element ) { @@ -403,10 +403,10 @@ public function test_get_lcp_elements_by_minimum_viewport_widths( array $breakpo * @param string[] $breadcrumbs Breadcrumb tags. * @param bool $is_lcp Whether LCP. * - * @return ILO_URL_Metric Validated URL metric. - * @throws ILO_Data_Validation_Exception From ILO_URL_Metric if there is a parse error, but there won't be. + * @return OD_URL_Metric Validated URL metric. + * @throws OD_Data_Validation_Exception From OD_URL_Metric if there is a parse error, but there won't be. */ - private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): ILO_URL_Metric { + private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): OD_URL_Metric { $data = array( 'viewport' => array( 'width' => $viewport_width, @@ -422,7 +422,7 @@ private function get_validated_url_metric( int $viewport_width = 480, array $bre ), ), ); - return new ILO_URL_Metric( $data ); + return new OD_URL_Metric( $data ); } /** diff --git a/tests/plugins/optimization-detective/storage/post-type-tests.php b/tests/plugins/optimization-detective/storage/post-type-tests.php index 40809b57b3..6155f75e03 100644 --- a/tests/plugins/optimization-detective/storage/post-type-tests.php +++ b/tests/plugins/optimization-detective/storage/post-type-tests.php @@ -1,62 +1,62 @@ assertSame( 10, has_action( 'init', 'ilo_register_url_metrics_post_type' ) ); - $post_type_object = get_post_type_object( ILO_URL_METRICS_POST_TYPE ); + public function test_od_register_url_metrics_post_type() { + $this->assertSame( 10, has_action( 'init', 'od_register_url_metrics_post_type' ) ); + $post_type_object = get_post_type_object( OD_URL_METRICS_POST_TYPE ); $this->assertInstanceOf( WP_Post_Type::class, $post_type_object ); $this->assertFalse( $post_type_object->public ); } /** - * Test ilo_get_url_metrics_post() when there is no post. + * Test od_get_url_metrics_post() when there is no post. * - * @covers ::ilo_get_url_metrics_post + * @covers ::od_get_url_metrics_post */ - public function test_ilo_get_url_metrics_post_when_absent() { - $slug = ilo_get_url_metrics_slug( array( 'p' => '1' ) ); - $this->assertNull( ilo_get_url_metrics_post( $slug ) ); + public function test_od_get_url_metrics_post_when_absent() { + $slug = od_get_url_metrics_slug( array( 'p' => '1' ) ); + $this->assertNull( od_get_url_metrics_post( $slug ) ); } /** - * Test ilo_get_url_metrics_post() when there is a post. + * Test od_get_url_metrics_post() when there is a post. * - * @covers ::ilo_get_url_metrics_post + * @covers ::od_get_url_metrics_post */ - public function test_ilo_get_url_metrics_post_when_present() { - $slug = ilo_get_url_metrics_slug( array( 'p' => '1' ) ); + public function test_od_get_url_metrics_post_when_present() { + $slug = od_get_url_metrics_slug( array( 'p' => '1' ) ); $post_id = self::factory()->post->create( array( - 'post_type' => ILO_URL_METRICS_POST_TYPE, + 'post_type' => OD_URL_METRICS_POST_TYPE, 'post_name' => $slug, ) ); - $post = ilo_get_url_metrics_post( $slug ); + $post = od_get_url_metrics_post( $slug ); $this->assertInstanceOf( WP_Post::class, $post ); $this->assertSame( $post_id, $post->ID ); } /** - * Data provider for test_ilo_parse_stored_url_metrics. + * Data provider for test_od_parse_stored_url_metrics. * * @return array */ - public function data_provider_test_ilo_parse_stored_url_metrics(): array { + public function data_provider_test_od_parse_stored_url_metrics(): array { $valid_content = array( array( 'viewport' => array( @@ -89,40 +89,40 @@ public function data_provider_test_ilo_parse_stored_url_metrics(): array { } /** - * Test ilo_parse_stored_url_metrics(). + * Test od_parse_stored_url_metrics(). * - * @covers ::ilo_parse_stored_url_metrics + * @covers ::od_parse_stored_url_metrics * - * @dataProvider data_provider_test_ilo_parse_stored_url_metrics + * @dataProvider data_provider_test_od_parse_stored_url_metrics */ - public function test_ilo_parse_stored_url_metrics( string $post_content, array $expected_value ) { + public function test_od_parse_stored_url_metrics( string $post_content, array $expected_value ) { $post = self::factory()->post->create_and_get( array( - 'post_type' => ILO_URL_METRICS_POST_TYPE, + 'post_type' => OD_URL_METRICS_POST_TYPE, 'post_content' => $post_content, ) ); $url_metrics = array_map( - static function ( ILO_URL_Metric $url_metric ): array { + static function ( OD_URL_Metric $url_metric ): array { return $url_metric->jsonSerialize(); }, - ilo_parse_stored_url_metrics( $post ) + od_parse_stored_url_metrics( $post ) ); $this->assertSame( $expected_value, $url_metrics ); } /** - * Test ilo_store_url_metric(). + * Test od_store_url_metric(). * - * @covers ::ilo_store_url_metric + * @covers ::od_store_url_metric */ - public function test_ilo_store_url_metric() { + public function test_od_store_url_metric() { $url = home_url( '/' ); - $slug = ilo_get_url_metrics_slug( array( 'p' => 1 ) ); + $slug = od_get_url_metrics_slug( array( 'p' => 1 ) ); - $validated_url_metric = new ILO_URL_Metric( + $validated_url_metric = new OD_URL_Metric( array( 'viewport' => array( 'width' => 480, @@ -140,20 +140,20 @@ public function test_ilo_store_url_metric() { ) ); - $post_id = ilo_store_url_metric( $url, $slug, $validated_url_metric ); + $post_id = od_store_url_metric( $url, $slug, $validated_url_metric ); $this->assertIsInt( $post_id ); - $post = ilo_get_url_metrics_post( $slug ); + $post = od_get_url_metrics_post( $slug ); $this->assertInstanceOf( WP_Post::class, $post ); $this->assertSame( $post_id, $post->ID ); - $url_metrics = ilo_parse_stored_url_metrics( $post ); + $url_metrics = od_parse_stored_url_metrics( $post ); $this->assertCount( 1, $url_metrics ); - $again_post_id = ilo_store_url_metric( $url, $slug, $validated_url_metric ); + $again_post_id = od_store_url_metric( $url, $slug, $validated_url_metric ); $post = get_post( $again_post_id ); $this->assertSame( $post_id, $again_post_id ); - $url_metrics = ilo_parse_stored_url_metrics( $post ); + $url_metrics = od_parse_stored_url_metrics( $post ); $this->assertCount( 2, $url_metrics ); } } diff --git a/tests/plugins/optimization-detective/storage/rest-api-tests.php b/tests/plugins/optimization-detective/storage/rest-api-tests.php index ad54b0e47f..8948b77028 100644 --- a/tests/plugins/optimization-detective/storage/rest-api-tests.php +++ b/tests/plugins/optimization-detective/storage/rest-api-tests.php @@ -1,36 +1,36 @@ assertSame( 10, has_action( 'rest_api_init', 'ilo_register_endpoint' ) ); + public function test_od_register_endpoint_hooked() { + $this->assertSame( 10, has_action( 'rest_api_init', 'od_register_endpoint' ) ); } /** * Test good params. * - * @covers ::ilo_register_endpoint - * @covers ::ilo_handle_rest_request + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request */ public function test_rest_request_good_params() { $request = new WP_REST_Request( 'POST', self::ROUTE ); $valid_params = $this->get_valid_params(); - $this->assertCount( 0, get_posts( array( 'post_type' => ILO_URL_METRICS_POST_TYPE ) ) ); + $this->assertCount( 0, get_posts( array( 'post_type' => OD_URL_METRICS_POST_TYPE ) ) ); $request->set_body_params( $valid_params ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); @@ -38,11 +38,11 @@ public function test_rest_request_good_params() { $data = $response->get_data(); $this->assertTrue( $data['success'] ); - $this->assertCount( 1, get_posts( array( 'post_type' => ILO_URL_METRICS_POST_TYPE ) ) ); - $post = ilo_get_url_metrics_post( $valid_params['slug'] ); + $this->assertCount( 1, get_posts( array( 'post_type' => OD_URL_METRICS_POST_TYPE ) ) ); + $post = od_get_url_metrics_post( $valid_params['slug'] ); $this->assertInstanceOf( WP_Post::class, $post ); - $url_metrics = ilo_parse_stored_url_metrics( $post ); + $url_metrics = od_parse_stored_url_metrics( $post ); $this->assertCount( 1, $url_metrics, 'Expected number of URL metrics stored.' ); $this->assertSame( $valid_params['elements'], $url_metrics[0]->get_elements() ); $this->assertSame( $valid_params['viewport']['width'], $url_metrics[0]->get_viewport_width() ); @@ -76,7 +76,7 @@ function ( $params ) { 'nonce' => 'not even a hash', ), 'invalid_nonce' => array( - 'nonce' => ilo_get_url_metrics_storage_nonce( ilo_get_url_metrics_slug( array( 'different' => 'query vars' ) ) ), + 'nonce' => od_get_url_metrics_storage_nonce( od_get_url_metrics_slug( array( 'different' => 'query vars' ) ) ), ), 'invalid_viewport_type' => array( 'viewport' => '640x480', @@ -127,8 +127,8 @@ function ( $params ) { /** * Test bad params. * - * @covers ::ilo_register_endpoint - * @covers ::ilo_handle_rest_request + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request * * @dataProvider data_provider_invalid_params */ @@ -139,14 +139,14 @@ public function test_rest_request_bad_params( array $params ) { $this->assertSame( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); $this->assertSame( 'rest_invalid_param', $response->get_data()['code'], 'Response: ' . wp_json_encode( $response ) ); - $this->assertNull( ilo_get_url_metrics_post( $params['slug'] ) ); + $this->assertNull( od_get_url_metrics_post( $params['slug'] ) ); } /** * Test timestamp ignored. * - * @covers ::ilo_register_endpoint - * @covers ::ilo_handle_rest_request + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request */ public function test_rest_request_timestamp_ignored() { $initial_microtime = microtime( true ); @@ -163,10 +163,10 @@ public function test_rest_request_timestamp_ignored() { $this->assertSame( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); - $post = ilo_get_url_metrics_post( $params['slug'] ); + $post = od_get_url_metrics_post( $params['slug'] ); $this->assertInstanceOf( WP_Post::class, $post ); - $url_metrics = ilo_parse_stored_url_metrics( $post ); + $url_metrics = od_parse_stored_url_metrics( $post ); $this->assertCount( 1, $url_metrics ); $url_metric = $url_metrics[0]; $this->assertNotEquals( $params['timestamp'], $url_metric->get_timestamp() ); @@ -176,11 +176,11 @@ public function test_rest_request_timestamp_ignored() { /** * Test REST API request when metric storage is locked. * - * @covers ::ilo_register_endpoint - * @covers ::ilo_handle_rest_request + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request */ public function test_rest_request_locked() { - ILO_Storage_Lock::set_lock(); + OD_Storage_Lock::set_lock(); $request = new WP_REST_Request( 'POST', self::ROUTE ); $request->set_body_params( $this->get_valid_params() ); @@ -193,15 +193,15 @@ public function test_rest_request_locked() { /** * Test sending viewport data that isn't needed for any breakpoint. * - * @covers ::ilo_register_endpoint - * @covers ::ilo_handle_rest_request + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request */ public function test_rest_request_breakpoint_not_needed_for_any_breakpoint() { - add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); + add_filter( 'od_url_metric_storage_lock_ttl', '__return_zero' ); // First fully populate the sample for all breakpoints. - $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); - $viewport_widths = array_merge( ilo_get_breakpoint_max_widths(), array( 1000 ) ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + $viewport_widths = array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ); foreach ( $viewport_widths as $viewport_width ) { $this->populate_url_metrics( $sample_size, @@ -219,16 +219,16 @@ public function test_rest_request_breakpoint_not_needed_for_any_breakpoint() { /** * Test sending viewport data that isn't needed for a specific breakpoint. * - * @covers ::ilo_register_endpoint - * @covers ::ilo_handle_rest_request + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request */ public function test_rest_request_breakpoint_not_needed_for_specific_breakpoint() { - add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); + add_filter( 'od_url_metric_storage_lock_ttl', '__return_zero' ); $valid_params = $this->get_valid_params( array( 'viewport' => array( 'width' => 480 ) ) ); // First fully populate the sample for a given breakpoint. - $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); $this->populate_url_metrics( $sample_size, $valid_params @@ -244,17 +244,17 @@ public function test_rest_request_breakpoint_not_needed_for_specific_breakpoint( /** * Test fully populating the wider viewport group and then adding one more. * - * @covers ::ilo_register_endpoint - * @covers ::ilo_handle_rest_request + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request */ public function test_rest_request_over_populate_wider_viewport_group() { - add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); + add_filter( 'od_url_metric_storage_lock_ttl', '__return_zero' ); // First establish a single breakpoint, so there are two groups of URL metrics // with viewport widths 0-480 and >481. $breakpoint_width = 480; add_filter( - 'ilo_breakpoint_max_widths', + 'od_breakpoint_max_widths', static function () use ( $breakpoint_width ): array { return array( $breakpoint_width ); } @@ -263,24 +263,24 @@ static function () use ( $breakpoint_width ): array { $wider_viewport_params = $this->get_valid_params( array( 'viewport' => array( 'width' => $breakpoint_width + 1 ) ) ); // Fully populate the wider viewport group, leaving the narrower one empty. - $sample_size = ilo_get_url_metrics_breakpoint_sample_size(); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); $this->populate_url_metrics( $sample_size, $wider_viewport_params ); // Sanity check that the groups were constructed as expected. - $group_collection = new ILO_URL_Metrics_Group_Collection( - ilo_parse_stored_url_metrics( ilo_get_url_metrics_post( ilo_get_url_metrics_slug( array() ) ) ), - ilo_get_breakpoint_max_widths(), - ilo_get_url_metrics_breakpoint_sample_size(), + $group_collection = new OD_URL_Metrics_Group_Collection( + od_parse_stored_url_metrics( od_get_url_metrics_post( od_get_url_metrics_slug( array() ) ) ), + od_get_breakpoint_max_widths(), + od_get_url_metrics_breakpoint_sample_size(), HOUR_IN_SECONDS ); $url_metric_groups = iterator_to_array( $group_collection ); $this->assertSame( array( 0, $breakpoint_width + 1 ), array_map( - static function ( ILO_URL_Metrics_Group $group ) { + static function ( OD_URL_Metrics_Group $group ) { return $group->get_minimum_viewport_width(); }, $url_metric_groups @@ -300,17 +300,17 @@ static function ( ILO_URL_Metrics_Group $group ) { /** * Test fully populating the narrower viewport group and then adding one more. * - * @covers ::ilo_register_endpoint - * @covers ::ilo_handle_rest_request + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request */ public function test_rest_request_over_populate_narrower_viewport_group() { - add_filter( 'ilo_url_metric_storage_lock_ttl', '__return_zero' ); + add_filter( 'od_url_metric_storage_lock_ttl', '__return_zero' ); // First establish a single breakpoint, so there are two groups of URL metrics // with viewport widths 0-480 and >481. $breakpoint_width = 480; add_filter( - 'ilo_breakpoint_max_widths', + 'od_breakpoint_max_widths', static function () use ( $breakpoint_width ): array { return array( $breakpoint_width ); } @@ -320,7 +320,7 @@ static function () use ( $breakpoint_width ): array { // Fully populate the narrower viewport group, leaving the wider one empty. $this->populate_url_metrics( - ilo_get_url_metrics_breakpoint_sample_size(), + od_get_url_metrics_breakpoint_sample_size(), $narrower_viewport_params ); @@ -354,12 +354,12 @@ private function populate_url_metrics( int $count, array $params ) { * @return array Params. */ private function get_valid_params( array $extras = array() ): array { - $slug = ilo_get_url_metrics_slug( array() ); + $slug = od_get_url_metrics_slug( array() ); $data = array_merge( array( 'url' => home_url( '/' ), 'slug' => $slug, - 'nonce' => ilo_get_url_metrics_storage_nonce( $slug ), + 'nonce' => od_get_url_metrics_storage_nonce( $slug ), ), $this->get_sample_validated_url_metric() ); diff --git a/webpack.config.js b/webpack.config.js index 59ac510f57..1debfa13ae 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -40,7 +40,7 @@ const webVitals = () => { const source = path.resolve( __dirname, 'node_modules/web-vitals' ); const destination = path.resolve( __dirname, - 'plugins/image-loading-optimization/detection' + 'plugins/optimization-detective/detection' ); return { From 723aa694ce714e65fa90a15eeebec0c6d0ca4319 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 Mar 2024 03:28:14 -0700 Subject: [PATCH 326/371] Fix phpcs issues --- .../class-od-url-metrics-group-collection.php | 6 +++--- .../optimization-detective/class-od-url-metrics-group.php | 8 ++++---- plugins/optimization-detective/detection.php | 2 +- plugins/optimization-detective/storage/post-type.php | 4 ++-- .../speculation-rules/speculation-rules-helper-test.php | 2 +- .../plugins/speculation-rules/speculation-rules-test.php | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/plugins/optimization-detective/class-od-url-metrics-group-collection.php b/plugins/optimization-detective/class-od-url-metrics-group-collection.php index 4aff952bf5..55b9b1f292 100644 --- a/plugins/optimization-detective/class-od-url-metrics-group-collection.php +++ b/plugins/optimization-detective/class-od-url-metrics-group-collection.php @@ -69,9 +69,9 @@ final class OD_URL_Metrics_Group_Collection implements Countable, IteratorAggreg * @throws InvalidArgumentException When an invalid argument is supplied. * * @param OD_URL_Metric[] $url_metrics URL metrics. - * @param int[] $breakpoints Breakpoints in max widths. - * @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints. - * @param int $freshness_ttl Freshness age (TTL) for a given URL metric. + * @param int[] $breakpoints Breakpoints in max widths. + * @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints. + * @param int $freshness_ttl Freshness age (TTL) for a given URL metric. */ public function __construct( array $url_metrics, array $breakpoints, int $sample_size, int $freshness_ttl ) { // Set breakpoints. diff --git a/plugins/optimization-detective/class-od-url-metrics-group.php b/plugins/optimization-detective/class-od-url-metrics-group.php index 6fa4d6a606..30fec2de87 100644 --- a/plugins/optimization-detective/class-od-url-metrics-group.php +++ b/plugins/optimization-detective/class-od-url-metrics-group.php @@ -66,10 +66,10 @@ final class OD_URL_Metrics_Group implements IteratorAggregate, Countable { * @throws InvalidArgumentException If arguments are valid. * * @param OD_URL_Metric[] $url_metrics URL metrics to add to the group. - * @param int $minimum_viewport_width Minimum possible viewport width for the group. Must be zero or greater. - * @param int $maximum_viewport_width Maximum possible viewport width for the group. Must be greater than zero and the minimum viewport width. - * @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints. - * @param int $freshness_ttl Freshness age (TTL) for a given URL metric. + * @param int $minimum_viewport_width Minimum possible viewport width for the group. Must be zero or greater. + * @param int $maximum_viewport_width Maximum possible viewport width for the group. Must be greater than zero and the minimum viewport width. + * @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints. + * @param int $freshness_ttl Freshness age (TTL) for a given URL metric. */ public function __construct( array $url_metrics, int $minimum_viewport_width, int $maximum_viewport_width, int $sample_size, int $freshness_ttl ) { if ( $minimum_viewport_width < 0 ) { diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index a54655fc6b..040dcefbb0 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -16,7 +16,7 @@ * @since n.e.x.t * @access private * - * @param string $slug URL metrics slug. + * @param string $slug URL metrics slug. * @param OD_URL_Metrics_Group_Collection $group_collection URL metrics group collection. */ function od_get_detection_script( string $slug, OD_URL_Metrics_Group_Collection $group_collection ): string { diff --git a/plugins/optimization-detective/storage/post-type.php b/plugins/optimization-detective/storage/post-type.php index a536b6a9aa..e2ac986d8d 100644 --- a/plugins/optimization-detective/storage/post-type.php +++ b/plugins/optimization-detective/storage/post-type.php @@ -146,8 +146,8 @@ static function ( $url_metric_data ) use ( $trigger_error ) { * @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 OD_URL_Metric $new_url_metric New URL metric. * @return int|WP_Error Post ID or WP_Error otherwise. */ diff --git a/tests/plugins/speculation-rules/speculation-rules-helper-test.php b/tests/plugins/speculation-rules/speculation-rules-helper-test.php index 71cd95a06a..a275aed9fb 100644 --- a/tests/plugins/speculation-rules/speculation-rules-helper-test.php +++ b/tests/plugins/speculation-rules/speculation-rules-helper-test.php @@ -67,7 +67,7 @@ public function test_plsr_get_speculation_rules_href_exclude_paths_with_mode() { // Add filter that adds an exclusion only if the mode is 'prerender'. add_filter( 'plsr_speculation_rules_href_exclude_paths', - function ( $exclude_paths, $mode ) { + static function ( $exclude_paths, $mode ) { if ( 'prerender' === $mode ) { $exclude_paths[] = '/products/*'; } diff --git a/tests/plugins/speculation-rules/speculation-rules-test.php b/tests/plugins/speculation-rules/speculation-rules-test.php index 9b0f099c47..20cfda6fd3 100644 --- a/tests/plugins/speculation-rules/speculation-rules-test.php +++ b/tests/plugins/speculation-rules/speculation-rules-test.php @@ -49,7 +49,7 @@ public function test_plsr_print_speculation_rules_without_html5_support( bool $h $this->assertIsArray( $rules ); $this->assertArrayHasKey( 'prerender', $rules ); - // Make sure that theme support was restored. This is only relevant to WordPres 6.4 per https://core.trac.wordpress.org/ticket/60320. + // Make sure that theme support was restored. This is only relevant to WordPress 6.4 per https://core.trac.wordpress.org/ticket/60320. if ( $html5_support || version_compare( strtok( get_bloginfo( 'version' ), '-' ), '6.4', '<' ) ) { $this->assertStringNotContainsString( '/* Date: Sun, 10 Mar 2024 05:33:33 -0700 Subject: [PATCH 327/371] Fix prettier issue --- plugins/optimization-detective/detection/detect.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/optimization-detective/detection/detect.js b/plugins/optimization-detective/detection/detect.js index a130bea0cb..75e5d2911a 100644 --- a/plugins/optimization-detective/detection/detect.js +++ b/plugins/optimization-detective/detection/detect.js @@ -228,8 +228,7 @@ export default async function detect( { log( 'Proceeding with detection' ); } - const breadcrumbedElements = - doc.body.querySelectorAll( '[data-od-xpath]' ); + const breadcrumbedElements = doc.body.querySelectorAll( '[data-od-xpath]' ); /** @type {Map} */ const breadcrumbedElementsMap = new Map( From 5e070e5e341ec5206dd8fc3553cac1b605728f3c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 Mar 2024 08:17:18 -0700 Subject: [PATCH 328/371] Update ilo to od prefixes in detect.js --- plugins/optimization-detective/detection/detect.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/optimization-detective/detection/detect.js b/plugins/optimization-detective/detection/detect.js index 75e5d2911a..6836855c32 100644 --- a/plugins/optimization-detective/detection/detect.js +++ b/plugins/optimization-detective/detection/detect.js @@ -5,7 +5,7 @@ const doc = win.document; const consoleLogPrefix = '[Optimization Detective]'; -const storageLockTimeSessionKey = 'iloStorageLockTime'; +const storageLockTimeSessionKey = 'odStorageLockTime'; /** * Checks whether storage is locked. @@ -237,7 +237,7 @@ export default async function detect( { * @param {HTMLElement} element * @return {[HTMLElement, string]} Tuple of element and its XPath. */ - ( element ) => [ element, element.dataset.iloXpath ] + ( element ) => [ element, element.dataset.odXpath ] ) ); From 7f170e6fc3fda0f0891c17b4693110a309e0aa0b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 Mar 2024 15:36:58 +0800 Subject: [PATCH 329/371] Move 'url' field to core URL Metric schema --- .../class-od-url-metric.php | 8 ++++++++ .../optimization-detective/storage/rest-api.php | 13 ------------- .../class-od-url-metric-tests.php | 16 ++++++++++++++++ ...ass-od-url-metrics-group-collection-tests.php | 1 + .../class-od-url-metrics-group-tests.php | 2 ++ .../optimization-tests.php | 1 + .../storage/data-tests.php | 1 + .../storage/post-type-tests.php | 2 ++ .../storage/rest-api-tests.php | 5 +---- 9 files changed, 32 insertions(+), 17 deletions(-) diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index 21a58984c5..767b8903be 100644 --- a/plugins/optimization-detective/class-od-url-metric.php +++ b/plugins/optimization-detective/class-od-url-metric.php @@ -24,6 +24,7 @@ * boundingClientRect: RectData, * } * @phpstan-type Data array{ + * url: string, * timestamp: int, * viewport: RectData, * elements: ElementData[] @@ -83,6 +84,13 @@ public static function get_json_schema(): array { 'title' => 'od-url-metric', 'type' => 'object', 'properties' => array( + 'url' => array( + 'type' => 'string', + 'description' => __( 'The URL for which the metric was obtained.', 'optimization-detective' ), + 'required' => true, + 'format' => 'uri', + 'pattern' => '^https?://', + ), 'viewport' => array( 'description' => __( 'Viewport dimensions', 'optimization-detective' ), 'type' => 'object', diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 98a2e433ad..1a74e0312b 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -38,19 +38,6 @@ function od_register_endpoint() { $args = array( - 'url' => array( - 'type' => 'string', - 'description' => __( 'The URL for which the metric was obtained.', 'optimization-detective' ), - 'required' => true, - 'format' => 'uri', - 'validate_callback' => static function ( $url ) { - if ( ! wp_validate_redirect( $url ) ) { - return new WP_Error( 'non_origin_url', __( 'URL for another site provided.', 'optimization-detective' ) ); - } - // TODO: This is not validated as corresponding to the slug in any way. True it is not used for anything but metadata. - return true; - }, - ), 'slug' => array( 'type' => 'string', 'description' => __( 'An MD5 hash of the query args.', 'optimization-detective' ), diff --git a/tests/plugins/optimization-detective/class-od-url-metric-tests.php b/tests/plugins/optimization-detective/class-od-url-metric-tests.php index 2240c1d7d0..1fc021dad4 100644 --- a/tests/plugins/optimization-detective/class-od-url-metric-tests.php +++ b/tests/plugins/optimization-detective/class-od-url-metric-tests.php @@ -22,6 +22,7 @@ public function data_provider(): array { return array( 'valid_minimal' => array( 'data' => array( + 'url' => home_url( '/' ), 'viewport' => $viewport, 'timestamp' => microtime( true ), 'elements' => array(), @@ -29,6 +30,7 @@ public function data_provider(): array { ), 'valid_with_element' => array( 'data' => array( + 'url' => home_url( '/' ), 'viewport' => $viewport, 'timestamp' => microtime( true ), 'elements' => array( @@ -43,6 +45,7 @@ public function data_provider(): array { ), 'missing_viewport' => array( 'data' => array( + 'url' => home_url( '/' ), 'timestamp' => microtime( true ), 'elements' => array(), ), @@ -50,6 +53,7 @@ public function data_provider(): array { ), 'missing_viewport_width' => array( 'data' => array( + 'url' => home_url( '/' ), 'viewport' => array( 'height' => 640 ), 'timestamp' => microtime( true ), 'elements' => array(), @@ -58,6 +62,7 @@ public function data_provider(): array { ), 'bad_viewport' => array( 'data' => array( + 'url' => home_url( '/' ), 'viewport' => array( 'height' => 'tall', 'width' => 'wide', @@ -69,6 +74,7 @@ public function data_provider(): array { ), 'missing_timestamp' => array( 'data' => array( + 'url' => home_url( '/' ), 'viewport' => $viewport, 'elements' => array(), ), @@ -76,13 +82,23 @@ public function data_provider(): array { ), 'missing_elements' => array( 'data' => array( + 'url' => home_url( '/' ), 'viewport' => $viewport, 'timestamp' => microtime( true ), ), 'error' => 'elements is a required property of OD_URL_Metric.', ), + 'missing_url' => array( + 'data' => array( + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + 'error' => 'url is a required property of OD_URL_Metric.', + ), 'bad_elements' => array( 'data' => array( + 'url' => home_url( '/' ), 'viewport' => $viewport, 'timestamp' => microtime( true ), 'elements' => array( diff --git a/tests/plugins/optimization-detective/class-od-url-metrics-group-collection-tests.php b/tests/plugins/optimization-detective/class-od-url-metrics-group-collection-tests.php index 08844ab988..fe3ac430aa 100644 --- a/tests/plugins/optimization-detective/class-od-url-metrics-group-collection-tests.php +++ b/tests/plugins/optimization-detective/class-od-url-metrics-group-collection-tests.php @@ -561,6 +561,7 @@ public function test_get_flattened_url_metrics() { */ private function get_validated_url_metric( int $viewport_width = 480 ): OD_URL_Metric { $data = array( + 'url' => home_url( '/' ), 'viewport' => array( 'width' => $viewport_width, 'height' => 640, diff --git a/tests/plugins/optimization-detective/class-od-url-metrics-group-tests.php b/tests/plugins/optimization-detective/class-od-url-metrics-group-tests.php index ac8df0f661..0901f0fa6c 100644 --- a/tests/plugins/optimization-detective/class-od-url-metrics-group-tests.php +++ b/tests/plugins/optimization-detective/class-od-url-metrics-group-tests.php @@ -71,6 +71,7 @@ public function data_provider_test_construction(): array { 'url_metrics' => array( new OD_URL_Metric( array( + 'url' => home_url( '/' ), 'viewport' => array( 'width' => 1, 'height' => 2, @@ -192,6 +193,7 @@ public function test_add_url_metric( int $viewport_width, string $exception ) { $group->add_url_metric( new OD_URL_Metric( array( + 'url' => home_url( '/' ), 'viewport' => array( 'width' => $viewport_width, 'height' => 1000, diff --git a/tests/plugins/optimization-detective/optimization-tests.php b/tests/plugins/optimization-detective/optimization-tests.php index b17a6f25b2..ed455d8ac1 100644 --- a/tests/plugins/optimization-detective/optimization-tests.php +++ b/tests/plugins/optimization-detective/optimization-tests.php @@ -1021,6 +1021,7 @@ private function normalize_whitespace( string $str ): string { */ private function get_validated_url_metric( int $viewport_width, array $elements = array() ): OD_URL_Metric { $data = array( + 'url' => home_url( '/' ), 'viewport' => array( 'width' => $viewport_width, 'height' => 800, diff --git a/tests/plugins/optimization-detective/storage/data-tests.php b/tests/plugins/optimization-detective/storage/data-tests.php index 0e3294dc86..f9fe5eda87 100644 --- a/tests/plugins/optimization-detective/storage/data-tests.php +++ b/tests/plugins/optimization-detective/storage/data-tests.php @@ -408,6 +408,7 @@ public function test_get_lcp_elements_by_minimum_viewport_widths( array $breakpo */ private function get_validated_url_metric( int $viewport_width = 480, array $breadcrumbs = array( 'HTML', 'BODY', 'IMG' ), bool $is_lcp = true ): OD_URL_Metric { $data = array( + 'url' => home_url( '/' ), 'viewport' => array( 'width' => $viewport_width, 'height' => 640, diff --git a/tests/plugins/optimization-detective/storage/post-type-tests.php b/tests/plugins/optimization-detective/storage/post-type-tests.php index 6155f75e03..a492882d92 100644 --- a/tests/plugins/optimization-detective/storage/post-type-tests.php +++ b/tests/plugins/optimization-detective/storage/post-type-tests.php @@ -59,6 +59,7 @@ public function test_od_get_url_metrics_post_when_present() { public function data_provider_test_od_parse_stored_url_metrics(): array { $valid_content = array( array( + 'url' => home_url( '/' ), 'viewport' => array( 'width' => 640, 'height' => 480, @@ -124,6 +125,7 @@ public function test_od_store_url_metric() { $validated_url_metric = new OD_URL_Metric( array( + 'url' => home_url( '/' ), 'viewport' => array( 'width' => 480, 'height' => 640, diff --git a/tests/plugins/optimization-detective/storage/rest-api-tests.php b/tests/plugins/optimization-detective/storage/rest-api-tests.php index 8948b77028..26bdc34f68 100644 --- a/tests/plugins/optimization-detective/storage/rest-api-tests.php +++ b/tests/plugins/optimization-detective/storage/rest-api-tests.php @@ -66,9 +66,6 @@ function ( $params ) { 'bad_url' => array( 'url' => 'bad://url', ), - 'other_origin_url' => array( - 'url' => 'https://bogus.example.com/', - ), 'bad_slug' => array( 'slug' => '', ), @@ -357,7 +354,6 @@ private function get_valid_params( array $extras = array() ): array { $slug = od_get_url_metrics_slug( array() ); $data = array_merge( array( - 'url' => home_url( '/' ), 'slug' => $slug, 'nonce' => od_get_url_metrics_storage_nonce( $slug ), ), @@ -401,6 +397,7 @@ private function recursive_merge( array $base_array, array $sparse_array ): arra */ private function get_sample_validated_url_metric(): array { return array( + 'url' => home_url( '/' ), 'viewport' => array( 'width' => 480, 'height' => 640, From 7a93aad357df4d34beea98562c2e21a874168d54 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 Mar 2024 05:32:01 -0700 Subject: [PATCH 330/371] Move URL computation server-side --- .../class-od-url-metric.php | 9 + plugins/optimization-detective/detection.php | 4 +- .../detection/detect.js | 4 +- .../optimization-detective/storage/data.php | 58 ++++++- .../storage/post-type.php | 21 +-- .../storage/rest-api.php | 7 +- .../optimization-tests.php | 16 -- .../storage/data-tests.php | 156 +++++++++++++++++- .../storage/post-type-tests.php | 5 +- .../storage/rest-api-tests.php | 7 +- 10 files changed, 235 insertions(+), 52 deletions(-) diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index 767b8903be..cda567c065 100644 --- a/plugins/optimization-detective/class-od-url-metric.php +++ b/plugins/optimization-detective/class-od-url-metric.php @@ -153,6 +153,15 @@ public static function get_json_schema(): array { ); } + /** + * Gets URL. + * + * @return string URL. + */ + public function get_url(): string { + return $this->data['url']; + } + /** * Gets viewport data. * diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index 040dcefbb0..c9381f92e6 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -38,14 +38,16 @@ function od_get_detection_script( string $slug, OD_URL_Metrics_Group_Collection $web_vitals_lib_data = require __DIR__ . '/detection/web-vitals.asset.php'; $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . '/detection/web-vitals.js' ); + $current_url = od_get_current_url(); $detect_args = array( 'serveTime' => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. 'detectionTimeWindow' => $detection_time_window, 'isDebug' => WP_DEBUG, 'restApiEndpoint' => rest_url( OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ), 'restApiNonce' => wp_create_nonce( 'wp_rest' ), + 'currentUrl' => $current_url, 'urlMetricsSlug' => $slug, - 'urlMetricsNonce' => od_get_url_metrics_storage_nonce( $slug ), + 'urlMetricsNonce' => od_get_url_metrics_storage_nonce( $slug, $current_url ), 'urlMetricsGroupStatuses' => array_map( static function ( OD_URL_Metrics_Group $group ): array { return array( diff --git a/plugins/optimization-detective/detection/detect.js b/plugins/optimization-detective/detection/detect.js index 6836855c32..488b99a733 100644 --- a/plugins/optimization-detective/detection/detect.js +++ b/plugins/optimization-detective/detection/detect.js @@ -141,6 +141,7 @@ function getCurrentTime() { * @param {boolean} args.isDebug Whether to show debug messages. * @param {string} args.restApiEndpoint URL for where to send the detection data. * @param {string} args.restApiNonce Nonce for writing to the REST API. + * @param {string} args.currentUrl Current URL. * @param {string} args.urlMetricsSlug Slug for URL metrics. * @param {string} args.urlMetricsNonce Nonce for URL metrics storage. * @param {URLMetricsGroupStatus[]} args.urlMetricsGroupStatuses URL metrics group statuses. @@ -153,6 +154,7 @@ export default async function detect( { isDebug, restApiEndpoint, restApiNonce, + currentUrl, urlMetricsSlug, urlMetricsNonce, urlMetricsGroupStatuses, @@ -314,7 +316,7 @@ export default async function detect( { /** @type {URLMetrics} */ const urlMetrics = { - url: win.location.href, + url: currentUrl, slug: urlMetricsSlug, nonce: urlMetricsNonce, viewport: { diff --git a/plugins/optimization-detective/storage/data.php b/plugins/optimization-detective/storage/data.php index 0295f19e1c..dbe57c44f9 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -85,9 +85,49 @@ function od_get_normalized_query_vars(): array { return $normalized_query_vars; } +/** + * Get the URL for the current request. + * + * This is essentially the REQUEST_URI prefixed by the scheme and host for the home URL. + * This is needed in particular due to subdirectory installs. + * + * @since n.e.x.t + * @access private + * + * @return string Current URL. + */ +function od_get_current_url(): string { + $parsed_url = wp_parse_url( home_url() ); + if ( ! is_array( $parsed_url ) ) { + $parsed_url = array(); + } + + if ( empty( $parsed_url['scheme'] ) ) { + $parsed_url['scheme'] = is_ssl() ? 'https' : 'http'; + } + if ( ! isset( $parsed_url['host'] ) ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $parsed_url['host'] = isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : 'localhost'; + } + + $current_url = $parsed_url['scheme'] . '://' . $parsed_url['host']; + if ( isset( $parsed_url['port'] ) ) { + $current_url .= ':' . $parsed_url['port']; + } + $current_url .= '/'; + + if ( isset( $_SERVER['REQUEST_URI'] ) ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $current_url .= ltrim( wp_unslash( $_SERVER['REQUEST_URI'] ), '/' ); + } + return esc_url_raw( $current_url ); +} + /** * Gets slug for URL metrics. * + * A slug is the hash of the normalized query vars. + * * @since n.e.x.t * @access private * @@ -110,12 +150,14 @@ function od_get_url_metrics_slug( array $query_vars ): string { * * @see wp_create_nonce() * @see od_verify_url_metrics_storage_nonce() + * @see od_get_url_metrics_slug() * - * @param string $slug URL metrics slug. + * @param string $slug Slug (hash of normalized query vars). + * @param string $url URL. * @return string Nonce. */ -function od_get_url_metrics_storage_nonce( string $slug ): string { - return wp_create_nonce( "store_url_metrics:$slug" ); +function od_get_url_metrics_storage_nonce( string $slug, string $url ): string { + return wp_create_nonce( "store_url_metrics:$slug:$url" ); } /** @@ -126,13 +168,15 @@ function od_get_url_metrics_storage_nonce( string $slug ): string { * * @see wp_verify_nonce() * @see od_get_url_metrics_storage_nonce() + * @see od_get_url_metrics_slug() * - * @param string $nonce URL metrics storage nonce. - * @param string $slug URL metrics slug. + * @param string $nonce Nonce. + * @param string $slug Slug (hash of normalized query vars). + * @param String $url URL. * @return bool Whether the nonce is valid. */ -function od_verify_url_metrics_storage_nonce( string $nonce, string $slug ): bool { - return (bool) wp_verify_nonce( $nonce, "store_url_metrics:$slug" ); +function od_verify_url_metrics_storage_nonce( string $nonce, string $slug, string $url ): bool { + return (bool) wp_verify_nonce( $nonce, "store_url_metrics:$slug:$url" ); } /** diff --git a/plugins/optimization-detective/storage/post-type.php b/plugins/optimization-detective/storage/post-type.php index e2ac986d8d..affb2adf9f 100644 --- a/plugins/optimization-detective/storage/post-type.php +++ b/plugins/optimization-detective/storage/post-type.php @@ -141,25 +141,25 @@ static function ( $url_metric_data ) use ( $trigger_error ) { } /** - * Stores URL metric by merging it with the other URL metrics for a given URL. + * Stores URL metric by merging it with the other URL metrics which share the same normalized query vars. * * @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 $slug Slug (hash of normalized query vars). * @param OD_URL_Metric $new_url_metric New URL metric. * @return int|WP_Error Post ID or WP_Error otherwise. */ -function od_store_url_metric( string $url, string $slug, OD_URL_Metric $new_url_metric ) { - - // TODO: What about storing a version identifier? +function od_store_url_metric( string $slug, OD_URL_Metric $new_url_metric ) { $post_data = array( - 'post_title' => $url, // TODO: Should we keep this? It can help with debugging. + // The URL is supplied as the post title in order to aid with debugging. Note that an od-url-metrics post stores + // multiple URL Metric instances, each of which also contains the URL for which the metric was captured. The URL + // appearing in the post title is therefore the most recent URL seen for the URL Metrics which have the same + // normalized query vars among them. + 'post_title' => $new_url_metric->get_url(), ); $post = od_get_url_metrics_post( $slug ); - if ( $post instanceof WP_Post ) { $post_data['ID'] = $post->ID; $post_data['post_name'] = $post->post_name; @@ -201,10 +201,11 @@ static function ( OD_URL_Metric $url_metric ): array { $post_data['post_type'] = OD_URL_METRICS_POST_TYPE; $post_data['post_status'] = 'publish'; + $slashed_post_data = wp_slash( $post_data ); if ( isset( $post_data['ID'] ) ) { - $result = wp_update_post( wp_slash( $post_data ), true ); + $result = wp_update_post( $slashed_post_data, true ); } else { - $result = wp_insert_post( wp_slash( $post_data ), true ); + $result = wp_insert_post( $slashed_post_data, true ); } if ( $has_kses ) { diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 1a74e0312b..af81e23199 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -43,8 +43,8 @@ function od_register_endpoint() { 'description' => __( 'An MD5 hash of the query args.', 'optimization-detective' ), 'required' => true, 'pattern' => '^[0-9a-f]{32}$', - // This is validated via the nonce validate_callback, as it is provided as input to create the nonce by the server - // which then is verified to match in the REST API request. + // This is further validated via the validate_callback for the nonce argument, as it is provided as input + // with the 'url' argument to create the nonce by the server. which then is verified to match in the REST API request. ), 'nonce' => array( 'type' => 'string', @@ -52,7 +52,7 @@ function od_register_endpoint() { 'required' => true, 'pattern' => '^[0-9a-f]+$', 'validate_callback' => static function ( $nonce, WP_REST_Request $request ) { - if ( ! od_verify_url_metrics_storage_nonce( $nonce, $request->get_param( 'slug' ) ) ) { + if ( ! od_verify_url_metrics_storage_nonce( $nonce, $request->get_param( 'slug' ), $request->get_param( 'url' ) ) ) { return new WP_Error( 'invalid_nonce', __( 'URL metrics nonce verification failure.', 'optimization-detective' ) ); } return true; @@ -152,7 +152,6 @@ function od_handle_rest_request( WP_REST_Request $request ) { } $result = od_store_url_metric( - $request->get_param( 'url' ), $request->get_param( 'slug' ), $url_metric ); diff --git a/tests/plugins/optimization-detective/optimization-tests.php b/tests/plugins/optimization-detective/optimization-tests.php index ed455d8ac1..840b0f5cb5 100644 --- a/tests/plugins/optimization-detective/optimization-tests.php +++ b/tests/plugins/optimization-detective/optimization-tests.php @@ -402,7 +402,6 @@ public function data_provider_test_od_optimize_template_output_buffer(): array { foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { od_store_url_metric( - home_url( '/' ), $slug, $this->get_validated_url_metric( $viewport_width, @@ -455,7 +454,6 @@ public function data_provider_test_od_optimize_template_output_buffer(): array { foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { od_store_url_metric( - home_url( '/' ), $slug, $this->get_validated_url_metric( $viewport_width, @@ -504,7 +502,6 @@ public function data_provider_test_od_optimize_template_output_buffer(): array { foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { od_store_url_metric( - home_url( '/' ), $slug, $this->get_validated_url_metric( $viewport_width, @@ -567,7 +564,6 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { foreach ( $div_index_to_viewport_width_mapping as $div_index => $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { od_store_url_metric( - home_url( '/' ), $slug, $this->get_validated_url_metric( $viewport_width, @@ -622,7 +618,6 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { od_store_url_metric( - home_url( '/' ), $slug, $this->get_validated_url_metric( $viewport_width, @@ -665,7 +660,6 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { 'url-metric-only-captured-for-one-breakpoint' => array( 'set_up' => function () { od_store_url_metric( - home_url( '/' ), od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 400, @@ -707,7 +701,6 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { 'different-lcp-elements-for-different-breakpoints' => array( 'set_up' => function () { od_store_url_metric( - home_url( '/' ), od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 400, @@ -724,7 +717,6 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ) ); od_store_url_metric( - home_url( '/' ), od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 800, @@ -780,7 +772,6 @@ static function () { ); od_store_url_metric( - home_url( '/' ), od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 400, @@ -797,7 +788,6 @@ static function () { ) ); od_store_url_metric( - home_url( '/' ), od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 500, @@ -814,7 +804,6 @@ static function () { ) ); od_store_url_metric( - home_url( '/' ), od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 700, @@ -831,7 +820,6 @@ static function () { ) ); od_store_url_metric( - home_url( '/' ), od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 800, @@ -887,7 +875,6 @@ static function () { ); od_store_url_metric( - home_url( '/' ), od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 500, @@ -904,7 +891,6 @@ static function () { ) ); od_store_url_metric( - home_url( '/' ), od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 650, @@ -921,7 +907,6 @@ static function () { ) ); od_store_url_metric( - home_url( '/' ), od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 800, @@ -938,7 +923,6 @@ static function () { ) ); od_store_url_metric( - home_url( '/' ), od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 800, diff --git a/tests/plugins/optimization-detective/storage/data-tests.php b/tests/plugins/optimization-detective/storage/data-tests.php index f9fe5eda87..e86acad7ab 100644 --- a/tests/plugins/optimization-detective/storage/data-tests.php +++ b/tests/plugins/optimization-detective/storage/data-tests.php @@ -131,6 +131,147 @@ public function test_od_get_normalized_query_vars( Closure $set_up ) { $this->assertSame( $expected, od_get_normalized_query_vars() ); } + /** + * Data provider. + * + * @return array + */ + public function data_provider_test_get_current_url(): array { + $assertions = array( + 'path' => function () { + $_SERVER['REQUEST_URI'] = wp_slash( '/foo/' ); + $this->assertEquals( + home_url( '/foo/' ), + od_get_current_url() + ); + }, + + 'query' => function () { + $_SERVER['REQUEST_URI'] = wp_slash( '/bar/?baz=1' ); + $this->assertEquals( + home_url( '/bar/?baz=1' ), + od_get_current_url() + ); + }, + + 'idn_domain' => function () { + $this->set_home_url_with_filter( 'https://⚡️.example.com' ); + $this->go_to( '/?s=lightning' ); + $this->assertEquals( 'https://⚡️.example.com/?s=lightning', od_get_current_url() ); + }, + + 'punycode_domain' => function () { + $this->set_home_url_with_filter( 'https://xn--57h.example.com' ); + $this->go_to( '/?s=thunder' ); + $this->assertEquals( 'https://xn--57h.example.com/?s=thunder', od_get_current_url() ); + }, + + 'ip_host' => function () { + $this->set_home_url_with_filter( 'http://127.0.0.1:1234' ); + $this->go_to( '/' ); + $this->assertEquals( 'http://127.0.0.1:1234/', od_get_current_url() ); + }, + + 'permalink' => function () { + global $wp_rewrite; + update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' ); + $wp_rewrite->use_trailing_slashes = true; + $wp_rewrite->init(); + $wp_rewrite->flush_rules(); + + $permalink = get_permalink( self::factory()->post->create() ); + + $this->go_to( $permalink ); + $this->assertEquals( $permalink, od_get_current_url() ); + }, + + 'unset_request_uri' => function () { + unset( $_SERVER['REQUEST_URI'] ); + $this->assertEquals( home_url( '/' ), od_get_current_url() ); + }, + + 'empty_request_uri' => function () { + $_SERVER['REQUEST_URI'] = ''; + $this->assertEquals( home_url( '/' ), od_get_current_url() ); + }, + + 'no_slash_prefix_request_uri' => function () { + $_SERVER['REQUEST_URI'] = 'foo/'; + $this->assertEquals( home_url( '/foo/' ), od_get_current_url() ); + }, + + 'reconstructed_home_url' => function () { + $_SERVER['HTTPS'] = 'on'; + $_SERVER['REQUEST_URI'] = '/about/'; + $_SERVER['HTTP_HOST'] = 'foo.example.org'; + $this->set_home_url_with_filter( '/' ); + $this->assertEquals( + 'https://foo.example.org/about/', + od_get_current_url() + ); + }, + + 'home_url_with_trimmings' => function () { + $this->set_home_url_with_filter( 'https://example.museum:8080' ); + $_SERVER['REQUEST_URI'] = '/about/'; + $this->assertEquals( + 'https://example.museum:8080/about/', + od_get_current_url() + ); + }, + + 'complete_parse_fail' => function () { + $_SERVER['HTTP_HOST'] = 'env.example.org'; + unset( $_SERVER['REQUEST_URI'] ); + $this->set_home_url_with_filter( ':' ); + $this->assertEquals( + ( is_ssl() ? 'https:' : 'http:' ) . '//env.example.org/', + od_get_current_url() + ); + }, + + 'default_to_localhost' => function () { + unset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ); + $this->set_home_url_with_filter( ':' ); + $this->assertEquals( + ( is_ssl() ? 'https:' : 'http:' ) . '//localhost/', + od_get_current_url() + ); + }, + ); + return array_map( + static function ( $assertion ) { + return array( $assertion ); + }, + $assertions + ); + } + + /** + * Set home_url with filter. + * + * @param string $home_url Home URL. + */ + private function set_home_url_with_filter( string $home_url ) { + add_filter( + 'home_url', + static function () use ( $home_url ): string { + return $home_url; + } + ); + } + + /** + * Test od_get_current_url(). + * + * @covers ::od_get_current_url + * + * @dataProvider data_provider_test_get_current_url + */ + public function test_od_get_current_url( Closure $assert ) { + call_user_func( $assert ); + } + /** * Test od_get_url_metrics_slug(). * @@ -166,27 +307,28 @@ static function ( int $life, string $action ) use ( &$nonce_life_actions ): int ); // Create first nonce for unauthenticated user. + $url = home_url( '/' ); $slug = od_get_url_metrics_slug( array() ); - $nonce1 = od_get_url_metrics_storage_nonce( $slug ); + $nonce1 = od_get_url_metrics_storage_nonce( $slug, $url ); $this->assertMatchesRegularExpression( '/^[0-9a-f]{10}$/', $nonce1 ); - $this->assertTrue( od_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); + $this->assertTrue( od_verify_url_metrics_storage_nonce( $nonce1, $slug, $url ) ); $this->assertCount( 2, $nonce_life_actions ); // Create second nonce for unauthenticated user. - $nonce2 = od_get_url_metrics_storage_nonce( $slug ); + $nonce2 = od_get_url_metrics_storage_nonce( $slug, $url ); $this->assertSame( $nonce1, $nonce2 ); $this->assertCount( 3, $nonce_life_actions ); // Create third nonce, this time for authenticated user. wp_set_current_user( $user_id ); - $nonce3 = od_get_url_metrics_storage_nonce( $slug ); + $nonce3 = od_get_url_metrics_storage_nonce( $slug, $url ); $this->assertNotEquals( $nonce3, $nonce2 ); - $this->assertFalse( od_verify_url_metrics_storage_nonce( $nonce1, $slug ) ); - $this->assertTrue( od_verify_url_metrics_storage_nonce( $nonce3, $slug ) ); + $this->assertFalse( od_verify_url_metrics_storage_nonce( $nonce1, $slug, $url ) ); + $this->assertTrue( od_verify_url_metrics_storage_nonce( $nonce3, $slug, $url ) ); $this->assertCount( 6, $nonce_life_actions ); foreach ( $nonce_life_actions as $nonce_life_action ) { - $this->assertSame( "store_url_metrics:{$slug}", $nonce_life_action ); + $this->assertSame( "store_url_metrics:{$slug}:{$url}", $nonce_life_action ); } } diff --git a/tests/plugins/optimization-detective/storage/post-type-tests.php b/tests/plugins/optimization-detective/storage/post-type-tests.php index a492882d92..1b17bcf3f6 100644 --- a/tests/plugins/optimization-detective/storage/post-type-tests.php +++ b/tests/plugins/optimization-detective/storage/post-type-tests.php @@ -120,7 +120,6 @@ static function ( OD_URL_Metric $url_metric ): array { * @covers ::od_store_url_metric */ public function test_od_store_url_metric() { - $url = home_url( '/' ); $slug = od_get_url_metrics_slug( array( 'p' => 1 ) ); $validated_url_metric = new OD_URL_Metric( @@ -142,7 +141,7 @@ public function test_od_store_url_metric() { ) ); - $post_id = od_store_url_metric( $url, $slug, $validated_url_metric ); + $post_id = od_store_url_metric( $slug, $validated_url_metric ); $this->assertIsInt( $post_id ); $post = od_get_url_metrics_post( $slug ); @@ -152,7 +151,7 @@ public function test_od_store_url_metric() { $url_metrics = od_parse_stored_url_metrics( $post ); $this->assertCount( 1, $url_metrics ); - $again_post_id = od_store_url_metric( $url, $slug, $validated_url_metric ); + $again_post_id = od_store_url_metric( $slug, $validated_url_metric ); $post = get_post( $again_post_id ); $this->assertSame( $post_id, $again_post_id ); $url_metrics = od_parse_stored_url_metrics( $post ); diff --git a/tests/plugins/optimization-detective/storage/rest-api-tests.php b/tests/plugins/optimization-detective/storage/rest-api-tests.php index 26bdc34f68..e576261abb 100644 --- a/tests/plugins/optimization-detective/storage/rest-api-tests.php +++ b/tests/plugins/optimization-detective/storage/rest-api-tests.php @@ -73,7 +73,7 @@ function ( $params ) { 'nonce' => 'not even a hash', ), 'invalid_nonce' => array( - 'nonce' => od_get_url_metrics_storage_nonce( od_get_url_metrics_slug( array( 'different' => 'query vars' ) ) ), + 'nonce' => od_get_url_metrics_storage_nonce( od_get_url_metrics_slug( array( 'different' => 'query vars' ) ), home_url( '/' ) ), ), 'invalid_viewport_type' => array( 'viewport' => '640x480', @@ -352,12 +352,13 @@ private function populate_url_metrics( int $count, array $params ) { */ private function get_valid_params( array $extras = array() ): array { $slug = od_get_url_metrics_slug( array() ); + $data = $this->get_sample_validated_url_metric(); $data = array_merge( array( 'slug' => $slug, - 'nonce' => od_get_url_metrics_storage_nonce( $slug ), + 'nonce' => od_get_url_metrics_storage_nonce( $slug, $data['url'] ), ), - $this->get_sample_validated_url_metric() + $data ); unset( $data['timestamp'] ); // Since provided by default args. if ( $extras ) { From 56f2ebea48f01dd0e01a17694be0122461a5ff13 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 Mar 2024 05:32:21 -0700 Subject: [PATCH 331/371] Remove unnecessary JSON pretty-printing --- plugins/optimization-detective/storage/post-type.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/optimization-detective/storage/post-type.php b/plugins/optimization-detective/storage/post-type.php index affb2adf9f..ba072f0a85 100644 --- a/plugins/optimization-detective/storage/post-type.php +++ b/plugins/optimization-detective/storage/post-type.php @@ -190,7 +190,7 @@ static function ( OD_URL_Metric $url_metric ): array { }, $group_collection->get_flattened_url_metrics() ), - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES // TODO: No need for pretty-printing. + JSON_UNESCAPED_SLASHES // No need for escaped slashes since not printed to frontend. ); $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); From 2106b9a4e60a8e6b9e5e469c5c7908c352c3a0fd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 11 Mar 2024 09:30:00 -0700 Subject: [PATCH 332/371] Remove obsolete can-load.php Co-authored-by: mukeshpanchal27 --- plugins/optimization-detective/can-load.php | 29 --------------------- 1 file changed, 29 deletions(-) delete mode 100644 plugins/optimization-detective/can-load.php diff --git a/plugins/optimization-detective/can-load.php b/plugins/optimization-detective/can-load.php deleted file mode 100644 index 2749a96792..0000000000 --- a/plugins/optimization-detective/can-load.php +++ /dev/null @@ -1,29 +0,0 @@ - Date: Mon, 11 Mar 2024 09:31:58 -0700 Subject: [PATCH 333/371] Update alignment in CODEOWNERS Co-authored-by: mukeshpanchal27 --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a1cf5b9f33..5912f1de94 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -61,5 +61,5 @@ /tests/plugins/dominant-color-images @pbearne @spacedmonkey # Plugin: Optimization Detective -/plugins/optimization-detective @westonruter -/tests/plugins/optimization-detective @westonruter +/plugins/optimization-detective @westonruter +/tests/plugins/optimization-detective @westonruter From 30e6b9601c4e37088589c7627bc5f40e99f62a25 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 11 Mar 2024 09:33:56 -0700 Subject: [PATCH 334/371] Replace n.e.x.t with with 0.1.0 version Co-authored-by: mukeshpanchal27 --- .../class-od-data-validation-exception.php | 4 ++-- .../class-od-html-tag-processor.php | 20 ++++++++-------- .../class-od-storage-lock.php | 12 +++++----- .../class-od-url-metric.php | 4 ++-- .../class-od-url-metrics-group-collection.php | 4 ++-- .../class-od-url-metrics-group.php | 4 ++-- plugins/optimization-detective/detection.php | 6 ++--- plugins/optimization-detective/hooks.php | 6 ++--- .../optimization-detective/optimization.php | 12 +++++----- .../optimization-detective/storage/data.php | 24 +++++++++---------- .../storage/post-type.php | 10 ++++---- .../storage/rest-api.php | 6 ++--- 12 files changed, 56 insertions(+), 56 deletions(-) diff --git a/plugins/optimization-detective/class-od-data-validation-exception.php b/plugins/optimization-detective/class-od-data-validation-exception.php index 3eea6af493..8ecde91ad3 100644 --- a/plugins/optimization-detective/class-od-data-validation-exception.php +++ b/plugins/optimization-detective/class-od-data-validation-exception.php @@ -3,7 +3,7 @@ * Optimization Detective: OD_Data_Validation_Exception class * * @package optimization-detective - * @since n.e.x.t + * @since 0.1.0 */ // Exit if accessed directly. @@ -14,7 +14,7 @@ /** * Exception thrown when failing to validate URL metrics data. * - * @since n.e.x.t + * @since 0.1.0 * @access private */ final class OD_Data_Validation_Exception extends Exception {} diff --git a/plugins/optimization-detective/class-od-html-tag-processor.php b/plugins/optimization-detective/class-od-html-tag-processor.php index 90af1bd7fe..1c21ee6bb1 100644 --- a/plugins/optimization-detective/class-od-html-tag-processor.php +++ b/plugins/optimization-detective/class-od-html-tag-processor.php @@ -3,7 +3,7 @@ * Optimization Detective: OD_HTML_Tag_Processor class * * @package optimization-detective - * @since n.e.x.t + * @since 0.1.0 */ // Exit if accessed directly. @@ -16,7 +16,7 @@ * * Eventually this class should be made largely obsolete once `WP_HTML_Processor` is fully implemented to support all HTML tags. * - * @since n.e.x.t + * @since 0.1.0 * @access private */ final class OD_HTML_Tag_Processor { @@ -169,7 +169,7 @@ 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 + * @since 0.1.0 * * @return Generator Tag name of current open tag. */ @@ -280,7 +280,7 @@ private function warn( string $message ) { * * A breadcrumb consists of a tag name and its sibling index. * - * @since n.e.x.t + * @since 0.1.0 * * @return Generator Breadcrumb. */ @@ -293,7 +293,7 @@ private function get_breadcrumbs(): Generator { /** * Determines whether currently inside a foreign element (MATH or SVG). * - * @since n.e.x.t + * @since 0.1.0 * * @return bool In foreign element. */ @@ -312,7 +312,7 @@ private function is_foreign_element(): bool { * 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 + * @since 0.1.0 * * @return string XPath. */ @@ -330,7 +330,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 + * @since 0.1.0 * @see WP_HTML_Tag_Processor::get_attribute() * * @param string $name Name of attribute whose value is requested. @@ -346,7 +346,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 + * @since 0.1.0 * @see WP_HTML_Tag_Processor::set_attribute() * * @param string $name The attribute name to target. @@ -363,7 +363,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 + * @since 0.1.0 * @see WP_HTML_Tag_Processor::remove_attribute() * * @param string $name The attribute name to remove. @@ -379,7 +379,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 + * @since 0.1.0 * @see WP_HTML_Tag_Processor::get_updated_html() * * @return string The processed HTML. diff --git a/plugins/optimization-detective/class-od-storage-lock.php b/plugins/optimization-detective/class-od-storage-lock.php index fb90020952..365354aebe 100644 --- a/plugins/optimization-detective/class-od-storage-lock.php +++ b/plugins/optimization-detective/class-od-storage-lock.php @@ -3,7 +3,7 @@ * Optimization Detective: OD_Storage_Lock class * * @package optimization-detective - * @since n.e.x.t + * @since 0.1.0 */ // Exit if accessed directly. @@ -14,7 +14,7 @@ /** * Class containing logic for locking storage for new URL metrics. * - * @since n.e.x.t + * @since 0.1.0 * @access private */ final class OD_Storage_Lock { @@ -22,7 +22,7 @@ final class OD_Storage_Lock { /** * Gets the TTL (in seconds) for the URL metric storage lock. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @return int TTL in seconds, greater than or equal to zero. A value of zero means that the storage lock should be disabled and thus that transients must not be used. @@ -39,7 +39,7 @@ public static function get_ttl(): int { * return is_user_logged_in() ? 0 : $ttl; * } ); * - * @since n.e.x.t + * @since 0.1.0 * * @param int $ttl TTL. */ @@ -64,7 +64,7 @@ public static function get_transient_key(): string { * If the storage lock TTL is greater than zero, then a transient is set with the current timestamp and expiring at TTL * seconds. Otherwise, if the current TTL is zero, then any transient is deleted. * - * @since n.e.x.t + * @since 0.1.0 * @access private */ public static function set_lock() { @@ -80,7 +80,7 @@ public static function set_lock() { /** * Checks whether URL metric storage is locked (for the current IP). * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @return bool Whether locked. diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index 21a58984c5..db305bb4ad 100644 --- a/plugins/optimization-detective/class-od-url-metric.php +++ b/plugins/optimization-detective/class-od-url-metric.php @@ -3,7 +3,7 @@ * Optimization Detective: OD_URL_Metric class * * @package optimization-detective - * @since n.e.x.t + * @since 0.1.0 */ // Exit if accessed directly. @@ -29,7 +29,7 @@ * elements: ElementData[] * } * - * @since n.e.x.t + * @since 0.1.0 * @access private */ final class OD_URL_Metric implements JsonSerializable { diff --git a/plugins/optimization-detective/class-od-url-metrics-group-collection.php b/plugins/optimization-detective/class-od-url-metrics-group-collection.php index 55b9b1f292..0ae7447f32 100644 --- a/plugins/optimization-detective/class-od-url-metrics-group-collection.php +++ b/plugins/optimization-detective/class-od-url-metrics-group-collection.php @@ -3,7 +3,7 @@ * Optimization Detective: OD_URL_Metrics_Group_Collection class * * @package optimization-detective - * @since n.e.x.t + * @since 0.1.0 */ // Exit if accessed directly. @@ -16,7 +16,7 @@ * * @implements IteratorAggregate * - * @since n.e.x.t + * @since 0.1.0 * @access private */ final class OD_URL_Metrics_Group_Collection implements Countable, IteratorAggregate { diff --git a/plugins/optimization-detective/class-od-url-metrics-group.php b/plugins/optimization-detective/class-od-url-metrics-group.php index 30fec2de87..1137fff24f 100644 --- a/plugins/optimization-detective/class-od-url-metrics-group.php +++ b/plugins/optimization-detective/class-od-url-metrics-group.php @@ -3,7 +3,7 @@ * Optimization Detective: OD_URL_Metrics_Group class * * @package optimization-detective - * @since n.e.x.t + * @since 0.1.0 */ // Exit if accessed directly. @@ -16,7 +16,7 @@ * * @implements IteratorAggregate * - * @since n.e.x.t + * @since 0.1.0 * @access private */ final class OD_URL_Metrics_Group implements IteratorAggregate, Countable { diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index 040dcefbb0..34069a501b 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -3,7 +3,7 @@ * Detection for Optimization Detective. * * @package optimization-detective - * @since n.e.x.t + * @since 0.1.0 */ if ( ! defined( 'ABSPATH' ) ) { @@ -13,7 +13,7 @@ /** * Prints the script for detecting loaded images and the LCP element. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @param string $slug URL metrics slug. @@ -28,7 +28,7 @@ function od_get_detection_script( string $slug, OD_URL_Metrics_Group_Collection * This avoids situations in which there is missing detection metrics in which case a site with page caching which * also has a lot of traffic could result in a cache stampede. * - * @since n.e.x.t + * @since 0.1.0 * @todo The value should probably be something like the 99th percentile of Time To Last Byte (TTLB) for WordPress sites in CrUX. * * @param int $detection_time_window Detection time window in milliseconds. diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index 45f4d46ee0..4b12f51109 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -3,7 +3,7 @@ * Hook callbacks used for Optimization Detective. * * @package optimization-detective - * @since n.e.x.t + * @since 0.1.0 */ if ( ! defined( 'ABSPATH' ) ) { @@ -23,7 +23,7 @@ * include $template; * } elseif ( current_user_can( 'switch_themes' ) ) { * - * @since n.e.x.t + * @since 0.1.0 * @access private * @link https://core.trac.wordpress.org/ticket/43258 * @@ -36,7 +36,7 @@ static function ( string $output ): string { /** * Filters the template output buffer prior to sending to the client. * - * @since n.e.x.t + * @since 0.1.0 * * @param string $output Output buffer. * @return string Filtered output buffer. diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php index 71b818504a..cb3d92f779 100644 --- a/plugins/optimization-detective/optimization.php +++ b/plugins/optimization-detective/optimization.php @@ -3,7 +3,7 @@ * Optimizing for Optimization Detective. * * @package optimization-detective - * @since n.e.x.t + * @since 0.1.0 */ if ( ! defined( 'ABSPATH' ) ) { @@ -13,7 +13,7 @@ /** * Adds template output buffer filter for optimization if eligible. * - * @since n.e.x.t + * @since 0.1.0 * @access private */ function od_maybe_add_template_output_buffer_filter() { @@ -31,7 +31,7 @@ function od_maybe_add_template_output_buffer_filter() { /** * Determines whether the current response can be optimized. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @return bool Whether response can be optimized. @@ -55,7 +55,7 @@ function od_can_optimize_response(): bool { /** * Filters whether the current response can be optimized. * - * @since n.e.x.t + * @since 0.1.0 * * @param bool $able Whether response can be optimized. */ @@ -65,7 +65,7 @@ function od_can_optimize_response(): bool { /** * Constructs preload links. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @param array $lcp_elements_by_minimum_viewport_widths LCP elements keyed by minimum viewport width, amended with element details. @@ -139,7 +139,7 @@ function od_construct_preload_links( array $lcp_elements_by_minimum_viewport_wid /** * Optimizes template output buffer. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @param string $buffer Template output buffer. diff --git a/plugins/optimization-detective/storage/data.php b/plugins/optimization-detective/storage/data.php index 0295f19e1c..d7e04cf58f 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -3,7 +3,7 @@ * Metrics storage data. * * @package optimization-detective - * @since n.e.x.t + * @since 0.1.0 */ if ( ! defined( 'ABSPATH' ) ) { @@ -15,7 +15,7 @@ * * When a URL metric expires it is eligible to be replaced by a newer one if its viewport lies within the same breakpoint. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @return int Expiration TTL in seconds. @@ -27,7 +27,7 @@ function od_get_url_metric_freshness_ttl(): int { * The freshness TTL must be at least zero, in which it considers URL metrics to always be stale. * In practice, the value should be at least an hour. * - * @since n.e.x.t + * @since 0.1.0 * * @param int $ttl Expiration TTL in seconds. Defaults to 1 day. */ @@ -58,7 +58,7 @@ function od_get_url_metric_freshness_ttl(): int { * * TODO: For non-singular requests, consider adding the post IDs from The Loop to ensure publishing a new post will invalidate the cache. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @return array Normalized query vars. @@ -88,7 +88,7 @@ function od_get_normalized_query_vars(): array { /** * Gets slug for URL metrics. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @see od_get_normalized_query_vars() @@ -105,7 +105,7 @@ function od_get_url_metrics_slug( array $query_vars ): string { * * This is used in the REST API to authenticate the storage of new URL metrics from a given URL. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @see wp_create_nonce() @@ -121,7 +121,7 @@ function od_get_url_metrics_storage_nonce( string $slug ): string { /** * Verifies nonce for storing URL metrics for a specific slug. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @see wp_verify_nonce() @@ -155,7 +155,7 @@ function od_verify_url_metrics_storage_nonce( string $nonce, string $slug ): boo * * These breakpoints appear to be used the most in media queries that affect frontend styles. * - * @since n.e.x.t + * @since 0.1.0 * @access private * @link https://github.com/WordPress/gutenberg/blob/093d52cbfd3e2c140843d3fb91ad3d03330320a5/packages/base-styles/_breakpoints.scss#L11-L13 * @@ -201,7 +201,7 @@ static function ( $original_breakpoint ) use ( $function_name ): int { * * A breakpoint must be greater than zero and less than PHP_INT_MAX. * - * @since n.e.x.t + * @since 0.1.0 * * @param int[] $breakpoint_max_widths Max widths for viewport breakpoints. Defaults to [480, 600, 782]. */ @@ -220,7 +220,7 @@ static function ( $original_breakpoint ) use ( $function_name ): int { * sample size of 3 and there being just a single breakpoint (480) by default, for a given URL, there would be a maximum * total of 6 URL metrics stored for a given URL: 3 for mobile and 3 for desktop. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @return int Sample size. @@ -231,7 +231,7 @@ function od_get_url_metrics_breakpoint_sample_size(): int { * * The sample size must greater than zero. * - * @since n.e.x.t + * @since 0.1.0 * * @param int $sample_size Sample size. Defaults to 3. */ @@ -264,7 +264,7 @@ function od_get_url_metrics_breakpoint_sample_size(): int { * representing that element, including its breadcrumbs. If two adjoining breakpoints have the same value, then the * latter is dropped. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @param OD_URL_Metrics_Group_Collection $group_collection URL metrics group collection. diff --git a/plugins/optimization-detective/storage/post-type.php b/plugins/optimization-detective/storage/post-type.php index e2ac986d8d..8b75aba900 100644 --- a/plugins/optimization-detective/storage/post-type.php +++ b/plugins/optimization-detective/storage/post-type.php @@ -3,7 +3,7 @@ * Metrics storage post type. * * @package optimization-detective - * @since n.e.x.t + * @since 0.1.0 */ if ( ! defined( 'ABSPATH' ) ) { @@ -17,7 +17,7 @@ * * This the configuration for this post type is similar to the oembed_cache in core. * - * @since n.e.x.t + * @since 0.1.0 * @access private */ function od_register_url_metrics_post_type() { @@ -43,7 +43,7 @@ function od_register_url_metrics_post_type() { /** * Gets URL metrics post. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @param string $slug URL metrics slug. @@ -75,7 +75,7 @@ function od_get_url_metrics_post( string $slug ) { /** * Parses post content in URL metrics post. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @param WP_Post $post URL metrics post. @@ -143,7 +143,7 @@ static function ( $url_metric_data ) use ( $trigger_error ) { /** * Stores URL metric by merging it with the other URL metrics for a given URL. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @param string $url URL for the URL metrics. This is used purely as metadata. diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 98a2e433ad..b211e50a1c 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -3,7 +3,7 @@ * REST API integration for the module. * * @package optimization-detective - * @since n.e.x.t + * @since 0.1.0 */ if ( ! defined( 'ABSPATH' ) ) { @@ -32,7 +32,7 @@ /** * Registers endpoint for storage of URL metric. * - * @since n.e.x.t + * @since 0.1.0 * @access private */ function od_register_endpoint() { @@ -104,7 +104,7 @@ function od_register_endpoint() { /** * Handles REST API request to store metrics. * - * @since n.e.x.t + * @since 0.1.0 * @access private * * @param WP_REST_Request $request Request. From 4a16d5acdb2d75bfd079dbb52bd16e398abb3427 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 14 Mar 2024 15:51:11 -0700 Subject: [PATCH 335/371] Add missing space after package tag --- .../class-od-html-tag-processor-tests.php | 2 +- .../optimization-detective/class-od-storage-lock-tests.php | 2 +- .../optimization-detective/class-od-url-metric-tests.php | 2 +- .../class-od-url-metrics-group-collection-tests.php | 2 +- .../optimization-detective/class-od-url-metrics-group-tests.php | 2 +- tests/plugins/optimization-detective/detection-tests.php | 2 +- tests/plugins/optimization-detective/hooks-tests.php | 2 +- tests/plugins/optimization-detective/optimization-tests.php | 2 +- tests/plugins/optimization-detective/storage/data-tests.php | 2 +- .../plugins/optimization-detective/storage/post-type-tests.php | 2 +- tests/plugins/optimization-detective/storage/rest-api-tests.php | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/plugins/optimization-detective/class-od-html-tag-processor-tests.php b/tests/plugins/optimization-detective/class-od-html-tag-processor-tests.php index 741544b3da..f974828ebf 100644 --- a/tests/plugins/optimization-detective/class-od-html-tag-processor-tests.php +++ b/tests/plugins/optimization-detective/class-od-html-tag-processor-tests.php @@ -2,7 +2,7 @@ /** * Tests for optimization-detective class OD_HTML_Tag_Processor. * - * @packageoptimization-detective + * @package optimization-detective * * @coversDefaultClass OD_HTML_Tag_Processor * diff --git a/tests/plugins/optimization-detective/class-od-storage-lock-tests.php b/tests/plugins/optimization-detective/class-od-storage-lock-tests.php index 855cf767ac..384a97e754 100644 --- a/tests/plugins/optimization-detective/class-od-storage-lock-tests.php +++ b/tests/plugins/optimization-detective/class-od-storage-lock-tests.php @@ -2,7 +2,7 @@ /** * Tests for OD_Storage_Lock. * - * @packageoptimization-detective + * @package optimization-detective * * @coversDefaultClass OD_Storage_Lock */ diff --git a/tests/plugins/optimization-detective/class-od-url-metric-tests.php b/tests/plugins/optimization-detective/class-od-url-metric-tests.php index 2240c1d7d0..5c27ed7449 100644 --- a/tests/plugins/optimization-detective/class-od-url-metric-tests.php +++ b/tests/plugins/optimization-detective/class-od-url-metric-tests.php @@ -2,7 +2,7 @@ /** * Tests for optimization-detective class OD_URL_Metric. * - * @packageoptimization-detective + * @package optimization-detective * * @coversDefaultClass OD_URL_Metric */ diff --git a/tests/plugins/optimization-detective/class-od-url-metrics-group-collection-tests.php b/tests/plugins/optimization-detective/class-od-url-metrics-group-collection-tests.php index 08844ab988..401ea854a9 100644 --- a/tests/plugins/optimization-detective/class-od-url-metrics-group-collection-tests.php +++ b/tests/plugins/optimization-detective/class-od-url-metrics-group-collection-tests.php @@ -2,7 +2,7 @@ /** * Tests for OD_URL_Metrics_Group_Collection. * - * @packageoptimization-detective + * @package optimization-detective * * @noinspection PhpUnhandledExceptionInspection * diff --git a/tests/plugins/optimization-detective/class-od-url-metrics-group-tests.php b/tests/plugins/optimization-detective/class-od-url-metrics-group-tests.php index ac8df0f661..0127f665ee 100644 --- a/tests/plugins/optimization-detective/class-od-url-metrics-group-tests.php +++ b/tests/plugins/optimization-detective/class-od-url-metrics-group-tests.php @@ -2,7 +2,7 @@ /** * Tests for OD_URL_Metrics_Group. * - * @packageoptimization-detective + * @package optimization-detective * * @noinspection PhpUnhandledExceptionInspection * diff --git a/tests/plugins/optimization-detective/detection-tests.php b/tests/plugins/optimization-detective/detection-tests.php index 532a1aff2b..3179286d93 100644 --- a/tests/plugins/optimization-detective/detection-tests.php +++ b/tests/plugins/optimization-detective/detection-tests.php @@ -2,7 +2,7 @@ /** * Tests for optimization-detective module detection.php. * - * @packageoptimization-detective + * @package optimization-detective */ class OD_Detection_Tests extends WP_UnitTestCase { diff --git a/tests/plugins/optimization-detective/hooks-tests.php b/tests/plugins/optimization-detective/hooks-tests.php index 124c5d763a..ab1701ebc6 100644 --- a/tests/plugins/optimization-detective/hooks-tests.php +++ b/tests/plugins/optimization-detective/hooks-tests.php @@ -2,7 +2,7 @@ /** * Tests for optimization-detective module hooks.php. * - * @packageoptimization-detective + * @package optimization-detective */ class OD_Hooks_Tests extends WP_UnitTestCase { diff --git a/tests/plugins/optimization-detective/optimization-tests.php b/tests/plugins/optimization-detective/optimization-tests.php index b17a6f25b2..34cb222f4f 100644 --- a/tests/plugins/optimization-detective/optimization-tests.php +++ b/tests/plugins/optimization-detective/optimization-tests.php @@ -2,7 +2,7 @@ /** * Tests for optimization-detective module optimization.php. * - * @packageoptimization-detective + * @package optimization-detective * * @todo There are "Cannot resolve ..." errors and "Element img doesn't have a required attribute src" warnings that should be excluded from inspection. */ diff --git a/tests/plugins/optimization-detective/storage/data-tests.php b/tests/plugins/optimization-detective/storage/data-tests.php index 0e3294dc86..0bb50a522f 100644 --- a/tests/plugins/optimization-detective/storage/data-tests.php +++ b/tests/plugins/optimization-detective/storage/data-tests.php @@ -2,7 +2,7 @@ /** * Tests for optimization-detective module storage/data.php. * - * @packageoptimization-detective + * @package optimization-detective * * @noinspection PhpUnhandledExceptionInspection */ diff --git a/tests/plugins/optimization-detective/storage/post-type-tests.php b/tests/plugins/optimization-detective/storage/post-type-tests.php index 6155f75e03..49ee056d5e 100644 --- a/tests/plugins/optimization-detective/storage/post-type-tests.php +++ b/tests/plugins/optimization-detective/storage/post-type-tests.php @@ -2,7 +2,7 @@ /** * Tests for optimization-detective module storage/post-type.php. * - * @packageoptimization-detective + * @package optimization-detective * * @noinspection PhpUnhandledExceptionInspection */ diff --git a/tests/plugins/optimization-detective/storage/rest-api-tests.php b/tests/plugins/optimization-detective/storage/rest-api-tests.php index 8948b77028..50dae3722b 100644 --- a/tests/plugins/optimization-detective/storage/rest-api-tests.php +++ b/tests/plugins/optimization-detective/storage/rest-api-tests.php @@ -2,7 +2,7 @@ /** * Tests for optimization-detective module storage/rest-api.php. * - * @packageoptimization-detective + * @package optimization-detective */ class OD_Storage_REST_API_Tests extends WP_UnitTestCase { From f70986e96129c2ef9e16eb8fb6d3eb55942d6fee Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 18 Mar 2024 13:36:54 -0700 Subject: [PATCH 336/371] Add Felix to CODEOWNERS --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5912f1de94..d9504eae91 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -61,5 +61,5 @@ /tests/plugins/dominant-color-images @pbearne @spacedmonkey # Plugin: Optimization Detective -/plugins/optimization-detective @westonruter -/tests/plugins/optimization-detective @westonruter +/plugins/optimization-detective @westonruter @felixarntz +/tests/plugins/optimization-detective @westonruter @felixarntz From 4ba14655e69e6d948b6694bbb0f51e845e697627 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 18 Mar 2024 14:49:46 -0700 Subject: [PATCH 337/371] Add todo to add optimization-detective to perflab_get_standalone_plugin_version_constants() --- load.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/load.php b/load.php index af44340084..5e78ae32a3 100644 --- a/load.php +++ b/load.php @@ -332,7 +332,7 @@ function perflab_get_standalone_plugin_version_constants( $source = 'plugins' ) return array( 'webp-uploads' => 'WEBP_UPLOADS_VERSION', 'dominant-color-images' => 'DOMINANT_COLOR_IMAGES_VERSION', - 'optimization-detective' => 'OPTIMIZATION_DETECTIVE_VERSION', + // TODO: Add image loading optimization plugin, dependent of Optimization Detective, once ready for end users. 'performant-translations' => 'PERFORMANT_TRANSLATIONS_VERSION', 'auto-sizes' => 'IMAGE_AUTO_SIZES_VERSION', 'speculation-rules' => 'SPECULATION_RULES_VERSION', From 21a5cec031fd5862a85dac9974aeae4f045d0abc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 14 Mar 2024 15:49:44 -0700 Subject: [PATCH 338/371] Move post type functions into OD_URL_Metrics_Post_Type class --- .../class-od-url-metrics-post-type.php | 226 ++++++++++++++++++ plugins/optimization-detective/load.php | 4 +- .../optimization-detective/optimization.php | 4 +- .../storage/post-type.php | 216 ----------------- .../storage/rest-api.php | 6 +- ... class-od-url-metrics-post-type-tests.php} | 70 +++--- .../optimization-tests.php | 32 +-- .../storage/rest-api-tests.php | 16 +- 8 files changed, 298 insertions(+), 276 deletions(-) create mode 100644 plugins/optimization-detective/class-od-url-metrics-post-type.php delete mode 100644 plugins/optimization-detective/storage/post-type.php rename tests/plugins/optimization-detective/{storage/post-type-tests.php => class-od-url-metrics-post-type-tests.php} (62%) diff --git a/plugins/optimization-detective/class-od-url-metrics-post-type.php b/plugins/optimization-detective/class-od-url-metrics-post-type.php new file mode 100644 index 0000000000..a0e4ae81d0 --- /dev/null +++ b/plugins/optimization-detective/class-od-url-metrics-post-type.php @@ -0,0 +1,226 @@ + array( + 'name' => __( 'URL Metrics', 'optimization-detective' ), + 'singular_name' => __( 'URL Metrics', 'optimization-detective' ), + ), + 'public' => false, + 'hierarchical' => false, + 'rewrite' => false, + 'query_var' => false, + 'delete_with_user' => false, + 'can_export' => false, + 'supports' => array( 'title' ), + // The original URL is stored in the post_title, and the post_name is a hash of the query vars. + ) + ); + } + + /** + * Gets URL metrics post. + * + * @since 0.1.0 + * @access private + * + * @param string $slug URL metrics slug. + * @return WP_Post|null Post object if exists. + */ + public static function get_post( string $slug ) { + $post_query = new WP_Query( + array( + 'post_type' => self::SLUG, + 'post_status' => 'publish', + 'name' => $slug, + 'posts_per_page' => 1, + 'no_found_rows' => true, + 'cache_results' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'lazy_load_term_meta' => false, + ) + ); + + $post = current( $post_query->posts ); + if ( $post instanceof WP_Post ) { + return $post; + } else { + return null; + } + } + + /** + * Parses post content in URL metrics post. + * + * @since 0.1.0 + * @access private + * + * @param WP_Post $post URL metrics post. + * @return OD_URL_Metric[] URL metrics. + */ + public static function parse_post_content( WP_Post $post ): array { + $this_function = __FUNCTION__; + $trigger_error = static function ( $error ) use ( $this_function ) { + if ( function_exists( 'wp_trigger_error' ) ) { + wp_trigger_error( $this_function, esc_html( $error ), E_USER_WARNING ); + } + }; + + $url_metrics_data = json_decode( $post->post_content, true ); + if ( json_last_error() ) { + $trigger_error( + sprintf( + /* translators: 1: Post type slug, 2: Post ID, 3: JSON error message */ + __( 'Contents of %1$s post type (ID: %2$s) not valid JSON: %3$s', 'optimization-detective' ), + self::SLUG, + $post->ID, + json_last_error_msg() + ) + ); + $url_metrics_data = array(); + } elseif ( ! is_array( $url_metrics_data ) ) { + $trigger_error( + sprintf( + /* translators: %s is post type slug */ + __( 'Contents of %s post type was not a JSON array.', 'optimization-detective' ), + self::SLUG + ) + ); + $url_metrics_data = array(); + } + + return array_values( + array_filter( + array_map( + static function ( $url_metric_data ) use ( $trigger_error ) { + if ( ! is_array( $url_metric_data ) ) { + return null; + } + + try { + return new OD_URL_Metric( $url_metric_data ); + } catch ( OD_Data_Validation_Exception $e ) { + $trigger_error( + sprintf( + /* translators: 1: Post type slug. 2: Exception message. */ + __( 'Unexpected shape to JSON array in post_content of %1$s post type: %2$s', 'optimization-detective' ), + OD_URL_Metrics_Post_Type::SLUG, + $e->getMessage() + ) + ); + + return null; + } + }, + $url_metrics_data + ) + ) + ); + } + + /** + * Stores URL metric by merging it with the other URL metrics which share the same normalized query vars. + * + * @since 0.1.0 + * @access private + * + * @param string $slug Slug (hash of normalized query vars). + * @param OD_URL_Metric $new_url_metric New URL metric. + * @return int|WP_Error Post ID or WP_Error otherwise. + */ + public static function store_url_metric( string $slug, OD_URL_Metric $new_url_metric ) { + $post_data = array( + // The URL is supplied as the post title in order to aid with debugging. Note that an od-url-metrics post stores + // multiple URL Metric instances, each of which also contains the URL for which the metric was captured. The URL + // appearing in the post title is therefore the most recent URL seen for the URL Metrics which have the same + // normalized query vars among them. + 'post_title' => $new_url_metric->get_url(), + ); + + $post = self::get_post( $slug ); + if ( $post instanceof WP_Post ) { + $post_data['ID'] = $post->ID; + $post_data['post_name'] = $post->post_name; + $url_metrics = self::parse_post_content( $post ); + } else { + $post_data['post_name'] = $slug; + $url_metrics = array(); + } + + $group_collection = new OD_URL_Metrics_Group_Collection( + $url_metrics, + od_get_breakpoint_max_widths(), + od_get_url_metrics_breakpoint_sample_size(), + od_get_url_metric_freshness_ttl() + ); + + try { + $group = $group_collection->get_group_for_viewport_width( $new_url_metric->get_viewport_width() ); + $group->add_url_metric( $new_url_metric ); + } catch ( InvalidArgumentException $e ) { + return new WP_Error( 'invalid_url_metric', $e->getMessage() ); + } + + $post_data['post_content'] = wp_json_encode( + array_map( + static function ( OD_URL_Metric $url_metric ): array { + return $url_metric->jsonSerialize(); + }, + $group_collection->get_flattened_url_metrics() + ), + JSON_UNESCAPED_SLASHES // No need for escaped slashes since not printed to frontend. + ); + + $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); + if ( $has_kses ) { + // Prevent KSES from corrupting JSON in post_content. + kses_remove_filters(); + } + + $post_data['post_type'] = self::SLUG; + $post_data['post_status'] = 'publish'; + $slashed_post_data = wp_slash( $post_data ); + if ( isset( $post_data['ID'] ) ) { + $result = wp_update_post( $slashed_post_data, true ); + } else { + $result = wp_insert_post( $slashed_post_data, true ); + } + + if ( $has_kses ) { + kses_init_filters(); + } + + return $result; + } +} diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index f9c65bc302..ef4002dcc0 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -35,7 +35,7 @@ require_once __DIR__ . '/class-od-url-metrics-group.php'; require_once __DIR__ . '/class-od-url-metrics-group-collection.php'; require_once __DIR__ . '/class-od-storage-lock.php'; -require_once __DIR__ . '/storage/post-type.php'; +require_once __DIR__ . '/class-od-url-metrics-post-type.php'; require_once __DIR__ . '/storage/data.php'; require_once __DIR__ . '/storage/rest-api.php'; @@ -43,3 +43,5 @@ require_once __DIR__ . '/class-od-html-tag-processor.php'; require_once __DIR__ . '/optimization.php'; + +add_action( 'init', array( OD_URL_Metrics_Post_Type::class, 'register' ) ); diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php index cb3d92f779..84b81f440e 100644 --- a/plugins/optimization-detective/optimization.php +++ b/plugins/optimization-detective/optimization.php @@ -147,10 +147,10 @@ function od_construct_preload_links( array $lcp_elements_by_minimum_viewport_wid */ function od_optimize_template_output_buffer( string $buffer ): string { $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); - $post = od_get_url_metrics_post( $slug ); + $post = OD_URL_Metrics_Post_Type::get_post( $slug ); $group_collection = new OD_URL_Metrics_Group_Collection( - $post ? od_parse_stored_url_metrics( $post ) : array(), + $post ? OD_URL_Metrics_Post_Type::parse_post_content( $post ) : array(), od_get_breakpoint_max_widths(), od_get_url_metrics_breakpoint_sample_size(), od_get_url_metric_freshness_ttl() diff --git a/plugins/optimization-detective/storage/post-type.php b/plugins/optimization-detective/storage/post-type.php deleted file mode 100644 index 714246a151..0000000000 --- a/plugins/optimization-detective/storage/post-type.php +++ /dev/null @@ -1,216 +0,0 @@ - array( - 'name' => __( 'URL Metrics', 'optimization-detective' ), - 'singular_name' => __( 'URL Metrics', 'optimization-detective' ), - ), - 'public' => false, - 'hierarchical' => false, - 'rewrite' => false, - 'query_var' => false, - 'delete_with_user' => false, - 'can_export' => false, - 'supports' => array( 'title' ), // The original URL is stored in the post_title, and the post_name is a hash of the query vars. - ) - ); -} -add_action( 'init', 'od_register_url_metrics_post_type' ); - -/** - * Gets URL metrics post. - * - * @since 0.1.0 - * @access private - * - * @param string $slug URL metrics slug. - * @return WP_Post|null Post object if exists. - */ -function od_get_url_metrics_post( string $slug ) { - $post_query = new WP_Query( - array( - 'post_type' => OD_URL_METRICS_POST_TYPE, - 'post_status' => 'publish', - 'name' => $slug, - 'posts_per_page' => 1, - 'no_found_rows' => true, - 'cache_results' => true, - 'update_post_meta_cache' => false, - 'update_post_term_cache' => false, - 'lazy_load_term_meta' => false, - ) - ); - - $post = current( $post_query->posts ); - if ( $post instanceof WP_Post ) { - return $post; - } else { - return null; - } -} - -/** - * Parses post content in URL metrics post. - * - * @since 0.1.0 - * @access private - * - * @param WP_Post $post URL metrics post. - * @return OD_URL_Metric[] URL metrics. - */ -function od_parse_stored_url_metrics( WP_Post $post ): array { - $this_function = __FUNCTION__; - $trigger_error = static function ( $error ) use ( $this_function ) { - if ( function_exists( 'wp_trigger_error' ) ) { - wp_trigger_error( $this_function, esc_html( $error ), E_USER_WARNING ); - } - }; - - $url_metrics_data = json_decode( $post->post_content, true ); - if ( json_last_error() ) { - $trigger_error( - sprintf( - /* translators: 1: Post type slug, 2: Post ID, 3: JSON error message */ - __( 'Contents of %1$s post type (ID: %2$s) not valid JSON: %3$s', 'optimization-detective' ), - OD_URL_METRICS_POST_TYPE, - $post->ID, - json_last_error_msg() - ) - ); - $url_metrics_data = array(); - } elseif ( ! is_array( $url_metrics_data ) ) { - $trigger_error( - sprintf( - /* translators: %s is post type slug */ - __( 'Contents of %s post type was not a JSON array.', 'optimization-detective' ), - OD_URL_METRICS_POST_TYPE - ) - ); - $url_metrics_data = array(); - } - - return array_values( - array_filter( - array_map( - static function ( $url_metric_data ) use ( $trigger_error ) { - if ( ! is_array( $url_metric_data ) ) { - return null; - } - - try { - return new OD_URL_Metric( $url_metric_data ); - } catch ( OD_Data_Validation_Exception $e ) { - $trigger_error( - sprintf( - /* translators: 1: Post type slug. 2: Exception message. */ - __( 'Unexpected shape to JSON array in post_content of %1$s post type: %2$s', 'optimization-detective' ), - OD_URL_METRICS_POST_TYPE, - $e->getMessage() - ) - ); - return null; - } - }, - $url_metrics_data - ) - ) - ); -} - -/** - * Stores URL metric by merging it with the other URL metrics which share the same normalized query vars. - * - * @since 0.1.0 - * @access private - * - * @param string $slug Slug (hash of normalized query vars). - * @param OD_URL_Metric $new_url_metric New URL metric. - * @return int|WP_Error Post ID or WP_Error otherwise. - */ -function od_store_url_metric( string $slug, OD_URL_Metric $new_url_metric ) { - $post_data = array( - // The URL is supplied as the post title in order to aid with debugging. Note that an od-url-metrics post stores - // multiple URL Metric instances, each of which also contains the URL for which the metric was captured. The URL - // appearing in the post title is therefore the most recent URL seen for the URL Metrics which have the same - // normalized query vars among them. - 'post_title' => $new_url_metric->get_url(), - ); - - $post = od_get_url_metrics_post( $slug ); - if ( $post instanceof WP_Post ) { - $post_data['ID'] = $post->ID; - $post_data['post_name'] = $post->post_name; - $url_metrics = od_parse_stored_url_metrics( $post ); - } else { - $post_data['post_name'] = $slug; - $url_metrics = array(); - } - - $group_collection = new OD_URL_Metrics_Group_Collection( - $url_metrics, - od_get_breakpoint_max_widths(), - od_get_url_metrics_breakpoint_sample_size(), - od_get_url_metric_freshness_ttl() - ); - - try { - $group = $group_collection->get_group_for_viewport_width( $new_url_metric->get_viewport_width() ); - $group->add_url_metric( $new_url_metric ); - } catch ( InvalidArgumentException $e ) { - return new WP_Error( 'invalid_url_metric', $e->getMessage() ); - } - - $post_data['post_content'] = wp_json_encode( - array_map( - static function ( OD_URL_Metric $url_metric ): array { - return $url_metric->jsonSerialize(); - }, - $group_collection->get_flattened_url_metrics() - ), - JSON_UNESCAPED_SLASHES // No need for escaped slashes since not printed to frontend. - ); - - $has_kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); - if ( $has_kses ) { - // Prevent KSES from corrupting JSON in post_content. - kses_remove_filters(); - } - - $post_data['post_type'] = OD_URL_METRICS_POST_TYPE; - $post_data['post_status'] = 'publish'; - $slashed_post_data = wp_slash( $post_data ); - if ( isset( $post_data['ID'] ) ) { - $result = wp_update_post( $slashed_post_data, true ); - } else { - $result = wp_insert_post( $slashed_post_data, true ); - } - - if ( $has_kses ) { - kses_init_filters(); - } - - return $result; -} diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index a9e4c8db48..a9350feae6 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -98,10 +98,10 @@ function od_register_endpoint() { * @return WP_REST_Response|WP_Error Response. */ function od_handle_rest_request( WP_REST_Request $request ) { - $post = od_get_url_metrics_post( $request->get_param( 'slug' ) ); + $post = OD_URL_Metrics_Post_Type::get_post( $request->get_param( 'slug' ) ); $group_collection = new OD_URL_Metrics_Group_Collection( - $post ? od_parse_stored_url_metrics( $post ) : array(), + $post ? OD_URL_Metrics_Post_Type::parse_post_content( $post ) : array(), od_get_breakpoint_max_widths(), od_get_url_metrics_breakpoint_sample_size(), od_get_url_metric_freshness_ttl() @@ -151,7 +151,7 @@ function od_handle_rest_request( WP_REST_Request $request ) { ); } - $result = od_store_url_metric( + $result = OD_URL_Metrics_Post_Type::store_url_metric( $request->get_param( 'slug' ), $url_metric ); diff --git a/tests/plugins/optimization-detective/storage/post-type-tests.php b/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php similarity index 62% rename from tests/plugins/optimization-detective/storage/post-type-tests.php rename to tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php index 412853805f..7462a68c08 100644 --- a/tests/plugins/optimization-detective/storage/post-type-tests.php +++ b/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php @@ -4,59 +4,69 @@ * * @package optimization-detective * + * @coversDefaultClass OD_URL_Metrics_Post_Type * @noinspection PhpUnhandledExceptionInspection */ class OD_Storage_Post_Type_Tests extends WP_UnitTestCase { /** - * Test od_register_url_metrics_post_type(). + * Test register(). * - * @covers ::od_register_url_metrics_post_type + * @covers ::register */ - public function test_od_register_url_metrics_post_type() { - $this->assertSame( 10, has_action( 'init', 'od_register_url_metrics_post_type' ) ); - $post_type_object = get_post_type_object( OD_URL_METRICS_POST_TYPE ); + public function test_register() { + $this->assertSame( + 10, + has_action( + 'init', + array( + OD_URL_Metrics_Post_Type::class, + 'register', + ) + ) + ); + $post_type_object = get_post_type_object( OD_URL_Metrics_Post_Type::SLUG ); $this->assertInstanceOf( WP_Post_Type::class, $post_type_object ); $this->assertFalse( $post_type_object->public ); } /** - * Test od_get_url_metrics_post() when there is no post. + * Test get_post() when there is no post. * - * @covers ::od_get_url_metrics_post + * @covers ::get_post */ - public function test_od_get_url_metrics_post_when_absent() { + public function test_od_post_when_absent() { $slug = od_get_url_metrics_slug( array( 'p' => '1' ) ); - $this->assertNull( od_get_url_metrics_post( $slug ) ); + $this->assertNull( OD_URL_Metrics_Post_Type::get_post( $slug ) ); } /** - * Test od_get_url_metrics_post() when there is a post. + * Test get_post() when there is a post. * - * @covers ::od_get_url_metrics_post + * @covers ::get_post */ - public function test_od_get_url_metrics_post_when_present() { + public function test_od_post_when_present() { $slug = od_get_url_metrics_slug( array( 'p' => '1' ) ); $post_id = self::factory()->post->create( array( - 'post_type' => OD_URL_METRICS_POST_TYPE, + 'post_type' => OD_URL_Metrics_Post_Type::SLUG, 'post_name' => $slug, ) ); - $post = od_get_url_metrics_post( $slug ); + $post = OD_URL_Metrics_Post_Type::get_post( $slug ); $this->assertInstanceOf( WP_Post::class, $post ); $this->assertSame( $post_id, $post->ID ); } /** - * Data provider for test_od_parse_stored_url_metrics. + * Data provider for test_parse_post_content. * * @return array */ - public function data_provider_test_od_parse_stored_url_metrics(): array { + public function data_provider_test_parse_post_content(): array { $valid_content = array( array( 'url' => home_url( '/' ), @@ -90,16 +100,16 @@ public function data_provider_test_od_parse_stored_url_metrics(): array { } /** - * Test od_parse_stored_url_metrics(). + * Test parse_post_content(). * - * @covers ::od_parse_stored_url_metrics + * @covers ::parse_post_content * - * @dataProvider data_provider_test_od_parse_stored_url_metrics + * @dataProvider data_provider_test_parse_post_content */ - public function test_od_parse_stored_url_metrics( string $post_content, array $expected_value ) { + public function test_parse_post_content( string $post_content, array $expected_value ) { $post = self::factory()->post->create_and_get( array( - 'post_type' => OD_URL_METRICS_POST_TYPE, + 'post_type' => OD_URL_Metrics_Post_Type::SLUG, 'post_content' => $post_content, ) ); @@ -108,18 +118,18 @@ public function test_od_parse_stored_url_metrics( string $post_content, array $e static function ( OD_URL_Metric $url_metric ): array { return $url_metric->jsonSerialize(); }, - od_parse_stored_url_metrics( $post ) + OD_URL_Metrics_Post_Type::parse_post_content( $post ) ); $this->assertSame( $expected_value, $url_metrics ); } /** - * Test od_store_url_metric(). + * Test store_url_metric(). * - * @covers ::od_store_url_metric + * @covers ::store_url_metric */ - public function test_od_store_url_metric() { + public function test_store_url_metric() { $slug = od_get_url_metrics_slug( array( 'p' => 1 ) ); $validated_url_metric = new OD_URL_Metric( @@ -141,20 +151,20 @@ public function test_od_store_url_metric() { ) ); - $post_id = od_store_url_metric( $slug, $validated_url_metric ); + $post_id = OD_URL_Metrics_Post_Type::store_url_metric( $slug, $validated_url_metric ); $this->assertIsInt( $post_id ); - $post = od_get_url_metrics_post( $slug ); + $post = OD_URL_Metrics_Post_Type::get_post( $slug ); $this->assertInstanceOf( WP_Post::class, $post ); $this->assertSame( $post_id, $post->ID ); - $url_metrics = od_parse_stored_url_metrics( $post ); + $url_metrics = OD_URL_Metrics_Post_Type::parse_post_content( $post ); $this->assertCount( 1, $url_metrics ); - $again_post_id = od_store_url_metric( $slug, $validated_url_metric ); + $again_post_id = OD_URL_Metrics_Post_Type::store_url_metric( $slug, $validated_url_metric ); $post = get_post( $again_post_id ); $this->assertSame( $post_id, $again_post_id ); - $url_metrics = od_parse_stored_url_metrics( $post ); + $url_metrics = OD_URL_Metrics_Post_Type::parse_post_content( $post ); $this->assertCount( 2, $url_metrics ); } } diff --git a/tests/plugins/optimization-detective/optimization-tests.php b/tests/plugins/optimization-detective/optimization-tests.php index 073e658dcb..f0d6e76be4 100644 --- a/tests/plugins/optimization-detective/optimization-tests.php +++ b/tests/plugins/optimization-detective/optimization-tests.php @@ -401,7 +401,7 @@ public function data_provider_test_od_optimize_template_output_buffer(): array { $sample_size = od_get_url_metrics_breakpoint_sample_size(); foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( $slug, $this->get_validated_url_metric( $viewport_width, @@ -453,7 +453,7 @@ public function data_provider_test_od_optimize_template_output_buffer(): array { $sample_size = od_get_url_metrics_breakpoint_sample_size(); foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( $slug, $this->get_validated_url_metric( $viewport_width, @@ -501,7 +501,7 @@ public function data_provider_test_od_optimize_template_output_buffer(): array { $sample_size = od_get_url_metrics_breakpoint_sample_size(); foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( $slug, $this->get_validated_url_metric( $viewport_width, @@ -563,7 +563,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { foreach ( $div_index_to_viewport_width_mapping as $div_index => $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( $slug, $this->get_validated_url_metric( $viewport_width, @@ -617,7 +617,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { $sample_size = od_get_url_metrics_breakpoint_sample_size(); foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( $slug, $this->get_validated_url_metric( $viewport_width, @@ -659,7 +659,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { 'url-metric-only-captured-for-one-breakpoint' => array( 'set_up' => function () { - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 400, @@ -700,7 +700,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { 'different-lcp-elements-for-different-breakpoints' => array( 'set_up' => function () { - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 400, @@ -716,7 +716,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ) ) ); - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 800, @@ -771,7 +771,7 @@ static function () { } ); - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 400, @@ -787,7 +787,7 @@ static function () { ) ) ); - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 500, @@ -803,7 +803,7 @@ static function () { ) ) ); - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 700, @@ -819,7 +819,7 @@ static function () { ) ) ); - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 800, @@ -874,7 +874,7 @@ static function () { } ); - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 500, @@ -890,7 +890,7 @@ static function () { ) ) ); - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 650, @@ -906,7 +906,7 @@ static function () { ) ) ); - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 800, @@ -922,7 +922,7 @@ static function () { ) ) ); - od_store_url_metric( + OD_URL_Metrics_Post_Type::store_url_metric( od_get_url_metrics_slug( od_get_normalized_query_vars() ), $this->get_validated_url_metric( 800, diff --git a/tests/plugins/optimization-detective/storage/rest-api-tests.php b/tests/plugins/optimization-detective/storage/rest-api-tests.php index 6a63d3d9ea..4a48b4facd 100644 --- a/tests/plugins/optimization-detective/storage/rest-api-tests.php +++ b/tests/plugins/optimization-detective/storage/rest-api-tests.php @@ -30,7 +30,7 @@ public function test_od_register_endpoint_hooked() { public function test_rest_request_good_params() { $request = new WP_REST_Request( 'POST', self::ROUTE ); $valid_params = $this->get_valid_params(); - $this->assertCount( 0, get_posts( array( 'post_type' => OD_URL_METRICS_POST_TYPE ) ) ); + $this->assertCount( 0, get_posts( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG ) ) ); $request->set_body_params( $valid_params ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); @@ -38,11 +38,11 @@ public function test_rest_request_good_params() { $data = $response->get_data(); $this->assertTrue( $data['success'] ); - $this->assertCount( 1, get_posts( array( 'post_type' => OD_URL_METRICS_POST_TYPE ) ) ); - $post = od_get_url_metrics_post( $valid_params['slug'] ); + $this->assertCount( 1, get_posts( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG ) ) ); + $post = OD_URL_Metrics_Post_Type::get_post( $valid_params['slug'] ); $this->assertInstanceOf( WP_Post::class, $post ); - $url_metrics = od_parse_stored_url_metrics( $post ); + $url_metrics = OD_URL_Metrics_Post_Type::parse_post_content( $post ); $this->assertCount( 1, $url_metrics, 'Expected number of URL metrics stored.' ); $this->assertSame( $valid_params['elements'], $url_metrics[0]->get_elements() ); $this->assertSame( $valid_params['viewport']['width'], $url_metrics[0]->get_viewport_width() ); @@ -136,7 +136,7 @@ public function test_rest_request_bad_params( array $params ) { $this->assertSame( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); $this->assertSame( 'rest_invalid_param', $response->get_data()['code'], 'Response: ' . wp_json_encode( $response ) ); - $this->assertNull( od_get_url_metrics_post( $params['slug'] ) ); + $this->assertNull( OD_URL_Metrics_Post_Type::get_post( $params['slug'] ) ); } /** @@ -160,10 +160,10 @@ public function test_rest_request_timestamp_ignored() { $this->assertSame( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); - $post = od_get_url_metrics_post( $params['slug'] ); + $post = OD_URL_Metrics_Post_Type::get_post( $params['slug'] ); $this->assertInstanceOf( WP_Post::class, $post ); - $url_metrics = od_parse_stored_url_metrics( $post ); + $url_metrics = OD_URL_Metrics_Post_Type::parse_post_content( $post ); $this->assertCount( 1, $url_metrics ); $url_metric = $url_metrics[0]; $this->assertNotEquals( $params['timestamp'], $url_metric->get_timestamp() ); @@ -268,7 +268,7 @@ static function () use ( $breakpoint_width ): array { // Sanity check that the groups were constructed as expected. $group_collection = new OD_URL_Metrics_Group_Collection( - od_parse_stored_url_metrics( od_get_url_metrics_post( od_get_url_metrics_slug( array() ) ) ), + OD_URL_Metrics_Post_Type::parse_post_content( OD_URL_Metrics_Post_Type::get_post( od_get_url_metrics_slug( array() ) ) ), od_get_breakpoint_max_widths(), od_get_url_metrics_breakpoint_sample_size(), HOUR_IN_SECONDS From a52e0ce2ee879d25fd4c27b19a90a9ce2a8d68a1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 18 Mar 2024 15:10:02 -0700 Subject: [PATCH 339/371] Add uninstall.php which deletes all URL Metrics posts --- .../class-od-url-metrics-post-type.php | 38 ++++++ plugins/optimization-detective/uninstall.php | 45 +++++++ .../class-od-url-metrics-post-type-tests.php | 118 +++++++++++++++--- .../uninstall-tests.php | 29 +++++ 4 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 plugins/optimization-detective/uninstall.php create mode 100644 tests/plugins/optimization-detective/uninstall-tests.php diff --git a/plugins/optimization-detective/class-od-url-metrics-post-type.php b/plugins/optimization-detective/class-od-url-metrics-post-type.php index a0e4ae81d0..a55c50495e 100644 --- a/plugins/optimization-detective/class-od-url-metrics-post-type.php +++ b/plugins/optimization-detective/class-od-url-metrics-post-type.php @@ -223,4 +223,42 @@ static function ( OD_URL_Metric $url_metric ): array { return $result; } + + /** + * Deletes all URL Metrics posts. + * + * This is used during uninstallation. + * + * @since 0.1.0 + * @access private + */ + public static function delete_all_posts() { + global $wpdb; + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + // Delete all related post meta for URL Metrics posts. + $wpdb->query( + $wpdb->prepare( + " + DELETE meta + FROM $wpdb->postmeta AS meta + INNER JOIN $wpdb->posts AS posts + ON posts.ID = meta.post_id + WHERE posts.post_type = %s; + ", + self::SLUG + ) + ); + + // Delete all URL Metrics posts. + $wpdb->delete( + $wpdb->posts, + array( + 'post_type' => self::SLUG, + ) + ); + + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + } } diff --git a/plugins/optimization-detective/uninstall.php b/plugins/optimization-detective/uninstall.php new file mode 100644 index 0000000000..f1552c981c --- /dev/null +++ b/plugins/optimization-detective/uninstall.php @@ -0,0 +1,45 @@ + 'ids', + 'number' => 100, + 'update_site_cache' => false, + 'update_site_meta_cache' => false, + ) + ); + + // Skip iterating over self. + $site_ids = array_diff( + $site_ids, + array( get_current_blog_id() ) + ); + + // Delete all other blogs' URL Metrics posts. + foreach ( $site_ids as $site_id ) { + switch_to_blog( $site_id ); + OD_URL_Metrics_Post_Type::delete_all_posts(); + restore_current_blog(); + } +} diff --git a/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php b/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php index 7462a68c08..4759c96401 100644 --- a/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php +++ b/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php @@ -132,24 +132,7 @@ static function ( OD_URL_Metric $url_metric ): array { public function test_store_url_metric() { $slug = od_get_url_metrics_slug( array( 'p' => 1 ) ); - $validated_url_metric = new OD_URL_Metric( - array( - 'url' => home_url( '/' ), - 'viewport' => array( - 'width' => 480, - 'height' => 640, - ), - 'timestamp' => microtime( true ), - 'elements' => array( - array( - 'isLCP' => true, - 'isLCPCandidate' => true, - 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]/*[1][self::MAIN]/*[0][self::DIV]/*[0][self::FIGURE]/*[0][self::IMG]', - 'intersectionRatio' => 1, - ), - ), - ) - ); + $validated_url_metric = $this->get_sample_url_metric( home_url( '/' ) ); $post_id = OD_URL_Metrics_Post_Type::store_url_metric( $slug, $validated_url_metric ); $this->assertIsInt( $post_id ); @@ -167,4 +150,103 @@ public function test_store_url_metric() { $url_metrics = OD_URL_Metrics_Post_Type::parse_post_content( $post ); $this->assertCount( 2, $url_metrics ); } + + /** + * Test delete_all_posts() + * + * @covers ::delete_all_posts + */ + public function test_delete_all_posts() { + global $wpdb; + + $other_post_meta_key = 'foo'; + $other_post_meta_value = 'bar'; + $url_metrics_post_meta_key = 'baz'; + + // Create sample posts of all post types other than URL Metrics. + $other_post_ids = array(); + foreach ( array_diff( get_post_types(), array( OD_URL_Metrics_Post_Type::SLUG ) ) as $post_type ) { + $other_post_ids = array_merge( + $other_post_ids, + self::factory()->post->create_many( 10, compact( 'post_type' ) ) + ); + } + foreach ( $other_post_ids as $post_id ) { + update_post_meta( $post_id, $other_post_meta_key, $other_post_meta_value ); + } + + // Now create sample URL Metrics posts. + for ( $i = 1; $i <= 101; $i++ ) { + $slug = od_get_url_metrics_slug( array( 'p' => $i ) ); + $post_id = OD_URL_Metrics_Post_Type::store_url_metric( $slug, $this->get_sample_url_metric( home_url( "/?p=$i" ) ) ); + update_post_meta( $post_id, $url_metrics_post_meta_key, '' ); + } + + $get_post_type_counts = static function (): array { + $post_type_counts = array(); + foreach ( get_post_types() as $post_type ) { + $post_type_counts[ $post_type ] = (array) wp_count_posts( $post_type ); + } + return $post_type_counts; + }; + + // Capture the initial post type counts. + $initial_post_counts = $get_post_type_counts(); + $this->assertEquals( 10, $initial_post_counts['post']['publish'] ); + $this->assertEquals( 10, $initial_post_counts['page']['publish'] ); + $this->assertEquals( 101, $initial_post_counts[ OD_URL_Metrics_Post_Type::SLUG ]['publish'] ); + $other_post_meta_count = $wpdb->get_var( $wpdb->prepare( "SELECT count(*) FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s", $other_post_meta_key, $other_post_meta_value ) ); + $this->assertGreaterThan( 0, $other_post_meta_count ); + $this->assertEquals( 101, $wpdb->get_var( $wpdb->prepare( "SELECT count(*) FROM $wpdb->postmeta WHERE meta_key = %s", $url_metrics_post_meta_key ) ) ); + + // Delete the URL Metrics posts. + OD_URL_Metrics_Post_Type::delete_all_posts(); + + wp_cache_flush(); + + // Make sure that the counts are as expected. + $final_post_counts = $get_post_type_counts(); + $this->assertEquals( 10, $final_post_counts['post']['publish'] ); + $this->assertEquals( 10, $final_post_counts['page']['publish'] ); + $this->assertEquals( 0, $final_post_counts[ OD_URL_Metrics_Post_Type::SLUG ]['publish'] ); + $initial_post_counts[ OD_URL_Metrics_Post_Type::SLUG ]['publish'] = 0; + $this->assertEquals( $initial_post_counts, $final_post_counts ); + + // Make sure post meta is intact. + foreach ( $other_post_ids as $post_id ) { + $this->assertInstanceOf( WP_Post::class, get_post( $post_id ) ); + $this->assertSame( $other_post_meta_value, get_post_meta( $post_id, $other_post_meta_key, true ) ); + } + $this->assertEquals( 0, $wpdb->get_var( $wpdb->prepare( "SELECT count(*) FROM $wpdb->postmeta WHERE meta_key = %s", $url_metrics_post_meta_key ) ) ); + $this->assertEquals( $other_post_meta_count, $wpdb->get_var( $wpdb->prepare( "SELECT count(*) FROM $wpdb->postmeta WHERE meta_key = %s and meta_value = %s", $other_post_meta_key, $other_post_meta_value ) ) ); + } + + /** + * Gets a sample URL Metric. + * + * @param string $url URL. + * + * @return OD_URL_Metric + * @throws OD_Data_Validation_Exception When invalid data (but there won't be). + */ + private function get_sample_url_metric( string $url ): OD_URL_Metric { + return new OD_URL_Metric( + array( + 'url' => $url, + 'viewport' => array( + 'width' => 480, + 'height' => 640, + ), + 'timestamp' => microtime( true ), + 'elements' => array( + array( + 'isLCP' => true, + 'isLCPCandidate' => true, + 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]/*[1][self::MAIN]/*[0][self::DIV]/*[0][self::FIGURE]/*[0][self::IMG]', + 'intersectionRatio' => 1, + ), + ), + ) + ); + } } diff --git a/tests/plugins/optimization-detective/uninstall-tests.php b/tests/plugins/optimization-detective/uninstall-tests.php new file mode 100644 index 0000000000..e27143a840 --- /dev/null +++ b/tests/plugins/optimization-detective/uninstall-tests.php @@ -0,0 +1,29 @@ +post->create(); + $url_metrics_post_id = self::factory()->post->create( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG ) ); + + require __DIR__ . '/../../../plugins/optimization-detective/uninstall.php'; + wp_cache_flush(); + + $this->assertInstanceOf( WP_Post::class, get_post( $post_id ) ); + $this->assertNull( get_post( $url_metrics_post_id ) ); + } +} From 1a9532c3aed568900c01355cd7c454bd3ed02f4f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 18 Mar 2024 17:15:09 -0700 Subject: [PATCH 340/371] Add garbage collection of URL Metrics not modified in the past month --- .../class-od-url-metrics-post-type.php | 66 ++++++++++++++ plugins/optimization-detective/uninstall.php | 11 ++- .../class-od-url-metrics-post-type-tests.php | 87 ++++++++++++++++++- .../uninstall-tests.php | 36 +++++++- 4 files changed, 193 insertions(+), 7 deletions(-) diff --git a/plugins/optimization-detective/class-od-url-metrics-post-type.php b/plugins/optimization-detective/class-od-url-metrics-post-type.php index a55c50495e..3be897a27d 100644 --- a/plugins/optimization-detective/class-od-url-metrics-post-type.php +++ b/plugins/optimization-detective/class-od-url-metrics-post-type.php @@ -18,8 +18,27 @@ */ class OD_URL_Metrics_Post_Type { + /** + * Post type slug. + * + * @var string + */ const SLUG = 'od_url_metrics'; + /** + * Event name (hook) for garbage collection of stale URL Metrics posts. + * + * @var string + */ + const GC_CRON_EVENT_NAME = 'od_url_metrics_gc'; + + /** + * Recurrence for garbage collection of stale URL Metrics posts. + * + * @var string + */ + const GC_CRON_RECURRENCE = 'daily'; + /** * Registers post type for URL metrics storage. * @@ -46,6 +65,9 @@ public static function register() { // The original URL is stored in the post_title, and the post_name is a hash of the query vars. ) ); + + add_action( 'admin_init', array( __CLASS__, 'schedule_garbage_collection' ) ); + add_action( self::GC_CRON_EVENT_NAME, array( __CLASS__, 'delete_stale_posts' ) ); } /** @@ -224,6 +246,50 @@ static function ( OD_URL_Metric $url_metric ): array { return $result; } + /** + * Schedules garbage collection of stale URL Metrics. + */ + public static function schedule_garbage_collection() { + if ( ! is_user_logged_in() ) { + return; + } + + // Unschedule any existing event which had a differing recurrence. + $scheduled_event = wp_get_scheduled_event( self::GC_CRON_EVENT_NAME ); + if ( $scheduled_event && self::GC_CRON_RECURRENCE !== $scheduled_event->schedule ) { + wp_unschedule_event( $scheduled_event->timestamp, self::GC_CRON_EVENT_NAME ); + $scheduled_event = null; + } + + if ( ! $scheduled_event ) { + wp_schedule_event( time(), self::GC_CRON_RECURRENCE, self::GC_CRON_EVENT_NAME ); + } + } + + /** + * Deletes posts that have not been modified in the past month. + */ + public static function delete_stale_posts() { + $one_month_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-1 month' ) ); + + $query = new WP_Query( + array( + 'post_type' => self::SLUG, + 'posts_per_page' => 100, + 'date_query' => array( + 'column' => 'post_modified_gmt', + 'before' => $one_month_ago, + ), + ) + ); + + foreach ( $query->posts as $post ) { + if ( self::SLUG === $post->post_type ) { // Sanity check. + wp_delete_post( $post->ID, true ); + } + } + } + /** * Deletes all URL Metrics posts. * diff --git a/plugins/optimization-detective/uninstall.php b/plugins/optimization-detective/uninstall.php index f1552c981c..c74d015afe 100644 --- a/plugins/optimization-detective/uninstall.php +++ b/plugins/optimization-detective/uninstall.php @@ -13,8 +13,13 @@ require_once __DIR__ . '/class-od-url-metrics-post-type.php'; -// Delete all URL Metrics posts for the current site. -OD_URL_Metrics_Post_Type::delete_all_posts(); +$od_delete_site_data = static function () { + // Delete all URL Metrics posts for the current site. + OD_URL_Metrics_Post_Type::delete_all_posts(); + wp_unschedule_hook( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME ); +}; + +$od_delete_site_data(); /* * For a multisite, delete the URL Metrics for all other sites (however limited to 100 sites to avoid memory limit or @@ -39,7 +44,7 @@ // Delete all other blogs' URL Metrics posts. foreach ( $site_ids as $site_id ) { switch_to_blog( $site_id ); - OD_URL_Metrics_Post_Type::delete_all_posts(); + $od_delete_site_data(); restore_current_blog(); } } diff --git a/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php b/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php index 4759c96401..fce52a039e 100644 --- a/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php +++ b/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php @@ -29,6 +29,9 @@ public function test_register() { $post_type_object = get_post_type_object( OD_URL_Metrics_Post_Type::SLUG ); $this->assertInstanceOf( WP_Post_Type::class, $post_type_object ); $this->assertFalse( $post_type_object->public ); + + $this->assertSame( 10, has_action( 'admin_init', array( OD_URL_Metrics_Post_Type::class, 'schedule_garbage_collection' ) ) ); + $this->assertSame( 10, has_action( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME, array( OD_URL_Metrics_Post_Type::class, 'delete_stale_posts' ) ) ); } /** @@ -152,7 +155,89 @@ public function test_store_url_metric() { } /** - * Test delete_all_posts() + * Test schedule_garbage_collection() when the user has not logged-in to the admin yet. + * + * @covers ::schedule_garbage_collection + */ + public function test_schedule_garbage_collection_logged_out() { + OD_URL_Metrics_Post_Type::schedule_garbage_collection(); + $this->assertFalse( wp_get_scheduled_event( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME ), 'Expected scheduling to be skipped because user is not logged-in.' ); + } + + /** + * Test schedule_garbage_collection() the first time the user logs in to the admin. + * + * @covers ::schedule_garbage_collection + */ + public function test_schedule_garbage_collection_first_log_in() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + OD_URL_Metrics_Post_Type::schedule_garbage_collection(); + $scheduled_event = wp_get_scheduled_event( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME ); + $this->assertIsObject( $scheduled_event ); + $this->assertEquals( OD_URL_Metrics_Post_Type::GC_CRON_RECURRENCE, $scheduled_event->schedule ); + } + + /** + * Test schedule_garbage_collection() when the schedule has changed. + * + * @covers ::schedule_garbage_collection + */ + public function test_schedule_garbage_collection_reschedule() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + wp_schedule_event( time(), 'hourly', OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME ); + OD_URL_Metrics_Post_Type::schedule_garbage_collection(); + $scheduled_event = wp_get_scheduled_event( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME ); + $this->assertIsObject( $scheduled_event ); + $this->assertEquals( OD_URL_Metrics_Post_Type::GC_CRON_RECURRENCE, $scheduled_event->schedule ); + } + + /** + * Test delete_stale_posts(). + * + * @covers ::delete_stale_posts + */ + public function test_delete_stale_posts() { + global $wpdb; + + $stale_timestamp_gmt = gmdate( 'Y-m-d H:i:s', strtotime( '-1 month' ) - HOUR_IN_SECONDS ); + + $new_generic_post = self::factory()->post->create(); + $old_generic_post = self::factory()->post->create(); + $wpdb->update( + $wpdb->posts, + array( + 'post_modified' => get_date_from_gmt( $stale_timestamp_gmt ), + 'post_modified_gmt' => $stale_timestamp_gmt, + ), + array( 'ID' => $old_generic_post ) + ); + clean_post_cache( $old_generic_post ); + + $new_url_metrics_slug = od_get_url_metrics_slug( array( 'p' => $new_generic_post ) ); + $new_url_metrics_post = OD_URL_Metrics_Post_Type::store_url_metric( $new_url_metrics_slug, $this->get_sample_url_metric( get_permalink( $new_generic_post ) ) ); + $old_url_metrics_slug = od_get_url_metrics_slug( array( 'p' => $old_generic_post ) ); + $old_url_metrics_post = OD_URL_Metrics_Post_Type::store_url_metric( $old_url_metrics_slug, $this->get_sample_url_metric( get_permalink( $old_generic_post ) ) ); + $wpdb->update( + $wpdb->posts, + array( + 'post_modified' => get_date_from_gmt( $stale_timestamp_gmt ), + 'post_modified_gmt' => $stale_timestamp_gmt, + ), + array( 'ID' => $old_url_metrics_post ) + ); + clean_post_cache( $old_url_metrics_post ); + + // Now we delete the stale URL Metrics. + OD_URL_Metrics_Post_Type::delete_stale_posts(); + + $this->assertInstanceOf( WP_Post::class, get_post( $new_generic_post ), 'Expected new generic post to not have been deleted.' ); + $this->assertInstanceOf( WP_Post::class, get_post( $old_generic_post ), 'Expected old generic post to not have been deleted.' ); + $this->assertInstanceOf( WP_Post::class, get_post( $new_url_metrics_post ), 'Expected new URL Metrics post to not have been deleted.' ); + $this->assertNull( get_post( $old_url_metrics_post ), 'Expected old URL Metrics post to have been deleted.' ); + } + + /** + * Test delete_all_posts(). * * @covers ::delete_all_posts */ diff --git a/tests/plugins/optimization-detective/uninstall-tests.php b/tests/plugins/optimization-detective/uninstall-tests.php index e27143a840..47edd67f34 100644 --- a/tests/plugins/optimization-detective/uninstall-tests.php +++ b/tests/plugins/optimization-detective/uninstall-tests.php @@ -9,21 +9,51 @@ class OD_Uninstall_Tests extends WP_UnitTestCase { /** - * Make sure post deletion is happening. + * Runs the routine before setting up all tests. */ - public function test_post_deletion() { + public static function set_up_before_class() { + parent::set_up_before_class(); + // Mock uninstall const. if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { define( 'WP_UNINSTALL_PLUGIN', 'Yes' ); } + } + + /** + * Load uninstall.php. + */ + private function require_uninstall() { + require __DIR__ . '/../../../plugins/optimization-detective/uninstall.php'; + } + + /** + * Make sure post deletion is happening. + */ + public function test_post_deletion() { $post_id = self::factory()->post->create(); $url_metrics_post_id = self::factory()->post->create( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG ) ); - require __DIR__ . '/../../../plugins/optimization-detective/uninstall.php'; + $this->require_uninstall(); wp_cache_flush(); $this->assertInstanceOf( WP_Post::class, get_post( $post_id ) ); $this->assertNull( get_post( $url_metrics_post_id ) ); } + + /** + * Test scheduled event removal. + */ + public function test_event_removal() { + $user = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $user ); + OD_URL_Metrics_Post_Type::schedule_garbage_collection(); + $scheduled_event = wp_get_scheduled_event( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME ); + $this->assertIsObject( $scheduled_event ); + + $this->require_uninstall(); + + $this->assertFalse( wp_get_scheduled_event( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME ) ); + } } From 21b92c3cf53f5532cb980bd4b277678a0830f67f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 18 Mar 2024 17:36:53 -0700 Subject: [PATCH 341/371] Remove extraneous access tags and add since tags --- .../class-od-url-metrics-post-type.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/plugins/optimization-detective/class-od-url-metrics-post-type.php b/plugins/optimization-detective/class-od-url-metrics-post-type.php index 3be897a27d..3afba5dd05 100644 --- a/plugins/optimization-detective/class-od-url-metrics-post-type.php +++ b/plugins/optimization-detective/class-od-url-metrics-post-type.php @@ -45,7 +45,6 @@ class OD_URL_Metrics_Post_Type { * This the configuration for this post type is similar to the oembed_cache in core. * * @since 0.1.0 - * @access private */ public static function register() { register_post_type( @@ -74,7 +73,6 @@ public static function register() { * Gets URL metrics post. * * @since 0.1.0 - * @access private * * @param string $slug URL metrics slug. * @return WP_Post|null Post object if exists. @@ -106,7 +104,6 @@ public static function get_post( string $slug ) { * Parses post content in URL metrics post. * * @since 0.1.0 - * @access private * * @param WP_Post $post URL metrics post. * @return OD_URL_Metric[] URL metrics. @@ -175,7 +172,6 @@ static function ( $url_metric_data ) use ( $trigger_error ) { * Stores URL metric by merging it with the other URL metrics which share the same normalized query vars. * * @since 0.1.0 - * @access private * * @param string $slug Slug (hash of normalized query vars). * @param OD_URL_Metric $new_url_metric New URL metric. @@ -248,6 +244,8 @@ static function ( OD_URL_Metric $url_metric ): array { /** * Schedules garbage collection of stale URL Metrics. + * + * @since 0.1.0 */ public static function schedule_garbage_collection() { if ( ! is_user_logged_in() ) { @@ -268,6 +266,8 @@ public static function schedule_garbage_collection() { /** * Deletes posts that have not been modified in the past month. + * + * @since 0.1.0 */ public static function delete_stale_posts() { $one_month_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-1 month' ) ); @@ -296,7 +296,6 @@ public static function delete_stale_posts() { * This is used during uninstallation. * * @since 0.1.0 - * @access private */ public static function delete_all_posts() { global $wpdb; From 4749b59420dc1f5acc6b3a6d19589a966bf32b45 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 19 Mar 2024 09:33:06 -0700 Subject: [PATCH 342/371] Add build:plugin:speculation-rules and fix grammar typo --- package.json | 1 + webpack.config.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 79b0861aba..d93eb52192 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "build:plugin:performance-lab": "rm -rf build/performance-lab && mkdir -p build/performance-lab && git archive HEAD | tar -x -C build/performance-lab", "build:plugin:auto-sizes": "webpack --mode production --env plugin=auto-sizes", "build:plugin:dominant-color-images": "webpack --mode production --env plugin=dominant-color-images", + "build:plugin:optimization-detective": "webpack --mode production --env plugin=optimization-detective", "build:plugin:speculation-rules": "webpack --mode production --env plugin=speculation-rules", "build:plugin:webp-uploads": "webpack --mode production --env plugin=webp-uploads", "test-plugins": "./bin/plugin/cli.js test-plugins", diff --git a/webpack.config.js b/webpack.config.js index e87ef4871d..afaa1fd3a8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -146,7 +146,7 @@ const buildPlugin = ( env ) => { } ), ], dependencies: [ - // Add any dependencies here which should be build before the plugin. + // Add any dependencies here which should be built before the plugin. ], }; }; From 368112d1bd65a7443811534eaac2b17f29f96540 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 19 Mar 2024 14:56:37 -0700 Subject: [PATCH 343/371] Move post type class to storage directory --- plugins/optimization-detective/load.php | 2 +- .../{ => storage}/class-od-url-metrics-post-type.php | 0 plugins/optimization-detective/uninstall.php | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename plugins/optimization-detective/{ => storage}/class-od-url-metrics-post-type.php (100%) diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index ef4002dcc0..75daba1709 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -35,7 +35,7 @@ require_once __DIR__ . '/class-od-url-metrics-group.php'; require_once __DIR__ . '/class-od-url-metrics-group-collection.php'; require_once __DIR__ . '/class-od-storage-lock.php'; -require_once __DIR__ . '/class-od-url-metrics-post-type.php'; +require_once __DIR__ . '/storage/class-od-url-metrics-post-type.php'; require_once __DIR__ . '/storage/data.php'; require_once __DIR__ . '/storage/rest-api.php'; diff --git a/plugins/optimization-detective/class-od-url-metrics-post-type.php b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php similarity index 100% rename from plugins/optimization-detective/class-od-url-metrics-post-type.php rename to plugins/optimization-detective/storage/class-od-url-metrics-post-type.php diff --git a/plugins/optimization-detective/uninstall.php b/plugins/optimization-detective/uninstall.php index c74d015afe..ed2198bf7a 100644 --- a/plugins/optimization-detective/uninstall.php +++ b/plugins/optimization-detective/uninstall.php @@ -11,7 +11,7 @@ exit; } -require_once __DIR__ . '/class-od-url-metrics-post-type.php'; +require_once __DIR__ . '/storage/class-od-url-metrics-post-type.php'; $od_delete_site_data = static function () { // Delete all URL Metrics posts for the current site. From a620bd0acd696bc34151119f9c3089d4a18f30c7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 19 Mar 2024 14:58:08 -0700 Subject: [PATCH 344/371] Do wp_cache_set_posts_last_changed() when batch deleting posts --- .../storage/class-od-url-metrics-post-type.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php index 3afba5dd05..8c9224c8a3 100644 --- a/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php +++ b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php @@ -324,6 +324,8 @@ public static function delete_all_posts() { ) ); + wp_cache_set_posts_last_changed(); + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } } From a301c9380889c4c6a8d46fa7920075dc065eeb9e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 19 Mar 2024 14:59:21 -0700 Subject: [PATCH 345/371] Rename parse_post_content to get_url_metrics_from_post --- .../optimization-detective/optimization.php | 2 +- .../storage/class-od-url-metrics-post-type.php | 4 ++-- .../storage/rest-api.php | 2 +- .../class-od-url-metrics-post-type-tests.php | 18 +++++++++--------- .../storage/rest-api-tests.php | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php index 84b81f440e..02435d092a 100644 --- a/plugins/optimization-detective/optimization.php +++ b/plugins/optimization-detective/optimization.php @@ -150,7 +150,7 @@ function od_optimize_template_output_buffer( string $buffer ): string { $post = OD_URL_Metrics_Post_Type::get_post( $slug ); $group_collection = new OD_URL_Metrics_Group_Collection( - $post ? OD_URL_Metrics_Post_Type::parse_post_content( $post ) : array(), + $post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(), od_get_breakpoint_max_widths(), od_get_url_metrics_breakpoint_sample_size(), od_get_url_metric_freshness_ttl() diff --git a/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php index 8c9224c8a3..4db03e677e 100644 --- a/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php +++ b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php @@ -108,7 +108,7 @@ public static function get_post( string $slug ) { * @param WP_Post $post URL metrics post. * @return OD_URL_Metric[] URL metrics. */ - public static function parse_post_content( WP_Post $post ): array { + public static function get_url_metrics_from_post( WP_Post $post ): array { $this_function = __FUNCTION__; $trigger_error = static function ( $error ) use ( $this_function ) { if ( function_exists( 'wp_trigger_error' ) ) { @@ -190,7 +190,7 @@ public static function store_url_metric( string $slug, OD_URL_Metric $new_url_me if ( $post instanceof WP_Post ) { $post_data['ID'] = $post->ID; $post_data['post_name'] = $post->post_name; - $url_metrics = self::parse_post_content( $post ); + $url_metrics = self::get_url_metrics_from_post( $post ); } else { $post_data['post_name'] = $slug; $url_metrics = array(); diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index a9350feae6..3ad1e21c87 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -101,7 +101,7 @@ function od_handle_rest_request( WP_REST_Request $request ) { $post = OD_URL_Metrics_Post_Type::get_post( $request->get_param( 'slug' ) ); $group_collection = new OD_URL_Metrics_Group_Collection( - $post ? OD_URL_Metrics_Post_Type::parse_post_content( $post ) : array(), + $post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(), od_get_breakpoint_max_widths(), od_get_url_metrics_breakpoint_sample_size(), od_get_url_metric_freshness_ttl() diff --git a/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php b/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php index fce52a039e..898782320d 100644 --- a/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php +++ b/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php @@ -65,11 +65,11 @@ public function test_od_post_when_present() { } /** - * Data provider for test_parse_post_content. + * Data provider for test_get_url_metrics_from_post. * * @return array */ - public function data_provider_test_parse_post_content(): array { + public function data_provider_test_get_url_metrics_from_post(): array { $valid_content = array( array( 'url' => home_url( '/' ), @@ -103,13 +103,13 @@ public function data_provider_test_parse_post_content(): array { } /** - * Test parse_post_content(). + * Test get_url_metrics_from_post(). * - * @covers ::parse_post_content + * @covers ::get_url_metrics_from_post * - * @dataProvider data_provider_test_parse_post_content + * @dataProvider data_provider_test_get_url_metrics_from_post */ - public function test_parse_post_content( string $post_content, array $expected_value ) { + public function test_get_url_metrics_from_post( string $post_content, array $expected_value ) { $post = self::factory()->post->create_and_get( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG, @@ -121,7 +121,7 @@ public function test_parse_post_content( string $post_content, array $expected_v static function ( OD_URL_Metric $url_metric ): array { return $url_metric->jsonSerialize(); }, - OD_URL_Metrics_Post_Type::parse_post_content( $post ) + OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) ); $this->assertSame( $expected_value, $url_metrics ); @@ -144,13 +144,13 @@ public function test_store_url_metric() { $this->assertInstanceOf( WP_Post::class, $post ); $this->assertSame( $post_id, $post->ID ); - $url_metrics = OD_URL_Metrics_Post_Type::parse_post_content( $post ); + $url_metrics = OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ); $this->assertCount( 1, $url_metrics ); $again_post_id = OD_URL_Metrics_Post_Type::store_url_metric( $slug, $validated_url_metric ); $post = get_post( $again_post_id ); $this->assertSame( $post_id, $again_post_id ); - $url_metrics = OD_URL_Metrics_Post_Type::parse_post_content( $post ); + $url_metrics = OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ); $this->assertCount( 2, $url_metrics ); } diff --git a/tests/plugins/optimization-detective/storage/rest-api-tests.php b/tests/plugins/optimization-detective/storage/rest-api-tests.php index 4a48b4facd..bb2a1a6c4c 100644 --- a/tests/plugins/optimization-detective/storage/rest-api-tests.php +++ b/tests/plugins/optimization-detective/storage/rest-api-tests.php @@ -42,7 +42,7 @@ public function test_rest_request_good_params() { $post = OD_URL_Metrics_Post_Type::get_post( $valid_params['slug'] ); $this->assertInstanceOf( WP_Post::class, $post ); - $url_metrics = OD_URL_Metrics_Post_Type::parse_post_content( $post ); + $url_metrics = OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ); $this->assertCount( 1, $url_metrics, 'Expected number of URL metrics stored.' ); $this->assertSame( $valid_params['elements'], $url_metrics[0]->get_elements() ); $this->assertSame( $valid_params['viewport']['width'], $url_metrics[0]->get_viewport_width() ); @@ -163,7 +163,7 @@ public function test_rest_request_timestamp_ignored() { $post = OD_URL_Metrics_Post_Type::get_post( $params['slug'] ); $this->assertInstanceOf( WP_Post::class, $post ); - $url_metrics = OD_URL_Metrics_Post_Type::parse_post_content( $post ); + $url_metrics = OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ); $this->assertCount( 1, $url_metrics ); $url_metric = $url_metrics[0]; $this->assertNotEquals( $params['timestamp'], $url_metric->get_timestamp() ); @@ -268,7 +268,7 @@ static function () use ( $breakpoint_width ): array { // Sanity check that the groups were constructed as expected. $group_collection = new OD_URL_Metrics_Group_Collection( - OD_URL_Metrics_Post_Type::parse_post_content( OD_URL_Metrics_Post_Type::get_post( od_get_url_metrics_slug( array() ) ) ), + OD_URL_Metrics_Post_Type::get_url_metrics_from_post( OD_URL_Metrics_Post_Type::get_post( od_get_url_metrics_slug( array() ) ) ), od_get_breakpoint_max_widths(), od_get_url_metrics_breakpoint_sample_size(), HOUR_IN_SECONDS From f6e068348542f02d9f1307a7bc301afe9e37614a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 19 Mar 2024 16:26:09 -0700 Subject: [PATCH 346/371] Refactor register method into add_hooks and register_post_type --- plugins/optimization-detective/load.php | 3 +- .../class-od-url-metrics-post-type.php | 16 +++++++--- .../class-od-url-metrics-post-type-tests.php | 29 ++++++++++++++----- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 75daba1709..62e9af2fe8 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -38,10 +38,9 @@ require_once __DIR__ . '/storage/class-od-url-metrics-post-type.php'; require_once __DIR__ . '/storage/data.php'; require_once __DIR__ . '/storage/rest-api.php'; +OD_URL_Metrics_Post_Type::add_hooks(); require_once __DIR__ . '/detection.php'; require_once __DIR__ . '/class-od-html-tag-processor.php'; require_once __DIR__ . '/optimization.php'; - -add_action( 'init', array( OD_URL_Metrics_Post_Type::class, 'register' ) ); diff --git a/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php index 4db03e677e..a883187523 100644 --- a/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php +++ b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php @@ -39,6 +39,17 @@ class OD_URL_Metrics_Post_Type { */ const GC_CRON_RECURRENCE = 'daily'; + /** + * Adds hooks. + * + * @since 0.1.0 + */ + public static function add_hooks() { + add_action( 'init', array( __CLASS__, 'register_post_type' ) ); + add_action( 'admin_init', array( __CLASS__, 'schedule_garbage_collection' ) ); + add_action( self::GC_CRON_EVENT_NAME, array( __CLASS__, 'delete_stale_posts' ) ); + } + /** * Registers post type for URL metrics storage. * @@ -46,7 +57,7 @@ class OD_URL_Metrics_Post_Type { * * @since 0.1.0 */ - public static function register() { + public static function register_post_type() { register_post_type( self::SLUG, array( @@ -64,9 +75,6 @@ public static function register() { // The original URL is stored in the post_title, and the post_name is a hash of the query vars. ) ); - - add_action( 'admin_init', array( __CLASS__, 'schedule_garbage_collection' ) ); - add_action( self::GC_CRON_EVENT_NAME, array( __CLASS__, 'delete_stale_posts' ) ); } /** diff --git a/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php b/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php index 898782320d..9f965132d2 100644 --- a/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php +++ b/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php @@ -11,27 +11,42 @@ class OD_Storage_Post_Type_Tests extends WP_UnitTestCase { /** - * Test register(). + * Test add_hooks(). * - * @covers ::register + * @covers ::add_hooks */ - public function test_register() { + public function test_add_hooks() { + remove_all_actions( 'init' ); + remove_all_actions( 'admin_init' ); + remove_all_actions( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME ); + + OD_URL_Metrics_Post_Type::add_hooks(); + $this->assertSame( 10, has_action( 'init', array( OD_URL_Metrics_Post_Type::class, - 'register', + 'register_post_type', ) ) ); + $this->assertSame( 10, has_action( 'admin_init', array( OD_URL_Metrics_Post_Type::class, 'schedule_garbage_collection' ) ) ); + $this->assertSame( 10, has_action( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME, array( OD_URL_Metrics_Post_Type::class, 'delete_stale_posts' ) ) ); + } + + /** + * Test register_post_type(). + * + * @covers ::register_post_type + */ + public function test_register_post_type() { + unregister_post_type( OD_URL_Metrics_Post_Type::SLUG ); + OD_URL_Metrics_Post_Type::register_post_type(); $post_type_object = get_post_type_object( OD_URL_Metrics_Post_Type::SLUG ); $this->assertInstanceOf( WP_Post_Type::class, $post_type_object ); $this->assertFalse( $post_type_object->public ); - - $this->assertSame( 10, has_action( 'admin_init', array( OD_URL_Metrics_Post_Type::class, 'schedule_garbage_collection' ) ) ); - $this->assertSame( 10, has_action( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME, array( OD_URL_Metrics_Post_Type::class, 'delete_stale_posts' ) ) ); } /** From b74f7ebe180ec7bbe1062cfd57894970d9d21351 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 19 Mar 2024 16:48:01 -0700 Subject: [PATCH 347/371] Consolidate all hooks to hooks.php --- plugins/optimization-detective/hooks.php | 38 +-------------- plugins/optimization-detective/load.php | 8 ++-- .../optimization-detective/optimization.php | 38 ++++++++++++++- .../optimization-detective/hooks-tests.php | 47 ++++++------------- .../optimization-tests.php | 32 +++++++++++++ 5 files changed, 90 insertions(+), 73 deletions(-) diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index 4b12f51109..829dcc4ac0 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -10,40 +10,6 @@ exit; // Exit if accessed directly. } -/** - * Starts output buffering at the end of the 'template_include' filter. - * - * This is to implement #43258 in core. - * - * This is a hack which would eventually be replaced with something like this in wp-includes/template-loader.php: - * - * $template = apply_filters( 'template_include', $template ); - * + ob_start( 'wp_template_output_buffer_callback' ); - * if ( $template ) { - * include $template; - * } elseif ( current_user_can( 'switch_themes' ) ) { - * - * @since 0.1.0 - * @access private - * @link https://core.trac.wordpress.org/ticket/43258 - * - * @param string $passthrough Optional. Filter value. Default null. - * @return string Unmodified value of $passthrough. - */ -function od_buffer_output( string $passthrough ): string { - ob_start( - static function ( string $output ): string { - /** - * Filters the template output buffer prior to sending to the client. - * - * @since 0.1.0 - * - * @param string $output Output buffer. - * @return string Filtered output buffer. - */ - return (string) apply_filters( 'od_template_output_buffer', $output ); - } - ); - return $passthrough; -} add_filter( 'template_include', 'od_buffer_output', PHP_INT_MAX ); +OD_URL_Metrics_Post_Type::add_hooks(); +add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' ); diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 62e9af2fe8..bdc68fba6e 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -27,8 +27,6 @@ define( 'OPTIMIZATION_DETECTIVE_VERSION', '0.1.0' ); -require_once __DIR__ . '/hooks.php'; - // Storage logic. require_once __DIR__ . '/class-od-data-validation-exception.php'; require_once __DIR__ . '/class-od-url-metric.php'; @@ -38,9 +36,13 @@ require_once __DIR__ . '/storage/class-od-url-metrics-post-type.php'; require_once __DIR__ . '/storage/data.php'; require_once __DIR__ . '/storage/rest-api.php'; -OD_URL_Metrics_Post_Type::add_hooks(); +// Detection logic. require_once __DIR__ . '/detection.php'; +// Optimization logic. require_once __DIR__ . '/class-od-html-tag-processor.php'; require_once __DIR__ . '/optimization.php'; + +// Add hooks for the above requires. +require_once __DIR__ . '/hooks.php'; diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php index 02435d092a..51dec58ffa 100644 --- a/plugins/optimization-detective/optimization.php +++ b/plugins/optimization-detective/optimization.php @@ -10,6 +10,43 @@ exit; // Exit if accessed directly. } +/** + * Starts output buffering at the end of the 'template_include' filter. + * + * This is to implement #43258 in core. + * + * This is a hack which would eventually be replaced with something like this in wp-includes/template-loader.php: + * + * $template = apply_filters( 'template_include', $template ); + * + ob_start( 'wp_template_output_buffer_callback' ); + * if ( $template ) { + * include $template; + * } elseif ( current_user_can( 'switch_themes' ) ) { + * + * @since 0.1.0 + * @access private + * @link https://core.trac.wordpress.org/ticket/43258 + * + * @param string $passthrough Optional. Filter value. Default null. + * @return string Unmodified value of $passthrough. + */ +function od_buffer_output( string $passthrough ): string { + ob_start( + static function ( string $output ): string { + /** + * Filters the template output buffer prior to sending to the client. + * + * @since 0.1.0 + * + * @param string $output Output buffer. + * @return string Filtered output buffer. + */ + return (string) apply_filters( 'od_template_output_buffer', $output ); + } + ); + return $passthrough; +} + /** * Adds template output buffer filter for optimization if eligible. * @@ -26,7 +63,6 @@ function od_maybe_add_template_output_buffer_filter() { } add_filter( 'od_template_output_buffer', $callback ); } -add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' ); /** * Determines whether the current response can be optimized. diff --git a/tests/plugins/optimization-detective/hooks-tests.php b/tests/plugins/optimization-detective/hooks-tests.php index ab1701ebc6..95b20bea03 100644 --- a/tests/plugins/optimization-detective/hooks-tests.php +++ b/tests/plugins/optimization-detective/hooks-tests.php @@ -8,41 +8,22 @@ class OD_Hooks_Tests extends WP_UnitTestCase { /** - * Make sure the hook is added. - */ - public function test_hooking_output_buffering_at_template_include() { - $this->assertEquals( PHP_INT_MAX, has_filter( 'template_include', 'od_buffer_output' ) ); - } - - /** - * Make output is buffered and that it is also filtered. + * Make sure the hooks are added in hooks.php. * - * @covers ::od_buffer_output + * @see OD_Storage_Post_Type_Tests::test_add_hooks() */ - public function test_buffering_and_filtering_output() { - $original = 'Hello World!'; - $expected = '¡Hola Mundo!'; - - // In order to test, a wrapping output buffer is required because ob_get_clean() does not invoke the output - // buffer callback. See . - ob_start(); - - add_filter( - 'od_template_output_buffer', - function ( $buffer ) use ( $original, $expected ) { - $this->assertSame( $original, $buffer ); - return $expected; - } + public function test_hooks_added() { + $this->assertEquals( PHP_INT_MAX, has_filter( 'template_include', 'od_buffer_output' ) ); + $this->assertEquals( 10, has_filter( 'wp', 'od_maybe_add_template_output_buffer_filter' ) ); + $this->assertSame( + 10, + has_action( + 'init', + array( + OD_URL_Metrics_Post_Type::class, + 'register_post_type', + ) + ) ); - - $original_ob_level = ob_get_level(); - od_buffer_output( '' ); - $this->assertSame( $original_ob_level + 1, ob_get_level(), 'Expected call to ob_start().' ); - echo $original; - - ob_end_flush(); // Flushing invokes the output buffer callback. - - $buffer = ob_get_clean(); // Get the buffer from our wrapper output buffer. - $this->assertSame( $expected, $buffer ); } } diff --git a/tests/plugins/optimization-detective/optimization-tests.php b/tests/plugins/optimization-detective/optimization-tests.php index f0d6e76be4..048178c506 100644 --- a/tests/plugins/optimization-detective/optimization-tests.php +++ b/tests/plugins/optimization-detective/optimization-tests.php @@ -32,6 +32,38 @@ public function tear_down() { parent::tear_down(); } + /** + * Make output is buffered and that it is also filtered. + * + * @covers ::od_buffer_output + */ + public function test_od_buffer_output() { + $original = 'Hello World!'; + $expected = '¡Hola Mundo!'; + + // In order to test, a wrapping output buffer is required because ob_get_clean() does not invoke the output + // buffer callback. See . + ob_start(); + + add_filter( + 'od_template_output_buffer', + function ( $buffer ) use ( $original, $expected ) { + $this->assertSame( $original, $buffer ); + return $expected; + } + ); + + $original_ob_level = ob_get_level(); + od_buffer_output( '' ); + $this->assertSame( $original_ob_level + 1, ob_get_level(), 'Expected call to ob_start().' ); + echo $original; + + ob_end_flush(); // Flushing invokes the output buffer callback. + + $buffer = ob_get_clean(); // Get the buffer from our wrapper output buffer. + $this->assertSame( $expected, $buffer ); + } + /** * Test od_maybe_add_template_output_buffer_filter(). * From 814f57db749bd2fd91c71658721df2f428c8f66a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 19 Mar 2024 17:01:40 -0700 Subject: [PATCH 348/371] Move other storage-related classes to storage directory --- plugins/optimization-detective/load.php | 10 +++++----- .../class-od-data-validation-exception.php | 0 .../{ => storage}/class-od-storage-lock.php | 0 .../{ => storage}/class-od-url-metric.php | 0 .../class-od-url-metrics-group-collection.php | 0 .../{ => storage}/class-od-url-metrics-group.php | 0 6 files changed, 5 insertions(+), 5 deletions(-) rename plugins/optimization-detective/{ => storage}/class-od-data-validation-exception.php (100%) rename plugins/optimization-detective/{ => storage}/class-od-storage-lock.php (100%) rename plugins/optimization-detective/{ => storage}/class-od-url-metric.php (100%) rename plugins/optimization-detective/{ => storage}/class-od-url-metrics-group-collection.php (100%) rename plugins/optimization-detective/{ => storage}/class-od-url-metrics-group.php (100%) diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index bdc68fba6e..948d15ae1e 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -28,11 +28,11 @@ define( 'OPTIMIZATION_DETECTIVE_VERSION', '0.1.0' ); // Storage logic. -require_once __DIR__ . '/class-od-data-validation-exception.php'; -require_once __DIR__ . '/class-od-url-metric.php'; -require_once __DIR__ . '/class-od-url-metrics-group.php'; -require_once __DIR__ . '/class-od-url-metrics-group-collection.php'; -require_once __DIR__ . '/class-od-storage-lock.php'; +require_once __DIR__ . '/storage/class-od-data-validation-exception.php'; +require_once __DIR__ . '/storage/class-od-url-metric.php'; +require_once __DIR__ . '/storage/class-od-url-metrics-group.php'; +require_once __DIR__ . '/storage/class-od-url-metrics-group-collection.php'; +require_once __DIR__ . '/storage/class-od-storage-lock.php'; require_once __DIR__ . '/storage/class-od-url-metrics-post-type.php'; require_once __DIR__ . '/storage/data.php'; require_once __DIR__ . '/storage/rest-api.php'; diff --git a/plugins/optimization-detective/class-od-data-validation-exception.php b/plugins/optimization-detective/storage/class-od-data-validation-exception.php similarity index 100% rename from plugins/optimization-detective/class-od-data-validation-exception.php rename to plugins/optimization-detective/storage/class-od-data-validation-exception.php diff --git a/plugins/optimization-detective/class-od-storage-lock.php b/plugins/optimization-detective/storage/class-od-storage-lock.php similarity index 100% rename from plugins/optimization-detective/class-od-storage-lock.php rename to plugins/optimization-detective/storage/class-od-storage-lock.php diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/storage/class-od-url-metric.php similarity index 100% rename from plugins/optimization-detective/class-od-url-metric.php rename to plugins/optimization-detective/storage/class-od-url-metric.php diff --git a/plugins/optimization-detective/class-od-url-metrics-group-collection.php b/plugins/optimization-detective/storage/class-od-url-metrics-group-collection.php similarity index 100% rename from plugins/optimization-detective/class-od-url-metrics-group-collection.php rename to plugins/optimization-detective/storage/class-od-url-metrics-group-collection.php diff --git a/plugins/optimization-detective/class-od-url-metrics-group.php b/plugins/optimization-detective/storage/class-od-url-metrics-group.php similarity index 100% rename from plugins/optimization-detective/class-od-url-metrics-group.php rename to plugins/optimization-detective/storage/class-od-url-metrics-group.php From 33b02728b262edd2415e5b9d0f06060ac56295fc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 20 Mar 2024 10:59:22 -0700 Subject: [PATCH 349/371] Move URL metric classes back to plugin root --- .../class-od-data-validation-exception.php | 0 .../{storage => }/class-od-url-metric.php | 0 .../class-od-url-metrics-group-collection.php | 0 .../{storage => }/class-od-url-metrics-group.php | 0 plugins/optimization-detective/load.php | 12 +++++++----- .../{ => storage}/class-od-storage-lock-tests.php | 0 .../class-od-url-metrics-post-type-tests.php | 0 7 files changed, 7 insertions(+), 5 deletions(-) rename plugins/optimization-detective/{storage => }/class-od-data-validation-exception.php (100%) rename plugins/optimization-detective/{storage => }/class-od-url-metric.php (100%) rename plugins/optimization-detective/{storage => }/class-od-url-metrics-group-collection.php (100%) rename plugins/optimization-detective/{storage => }/class-od-url-metrics-group.php (100%) rename tests/plugins/optimization-detective/{ => storage}/class-od-storage-lock-tests.php (100%) rename tests/plugins/optimization-detective/{ => storage}/class-od-url-metrics-post-type-tests.php (100%) diff --git a/plugins/optimization-detective/storage/class-od-data-validation-exception.php b/plugins/optimization-detective/class-od-data-validation-exception.php similarity index 100% rename from plugins/optimization-detective/storage/class-od-data-validation-exception.php rename to plugins/optimization-detective/class-od-data-validation-exception.php diff --git a/plugins/optimization-detective/storage/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php similarity index 100% rename from plugins/optimization-detective/storage/class-od-url-metric.php rename to plugins/optimization-detective/class-od-url-metric.php diff --git a/plugins/optimization-detective/storage/class-od-url-metrics-group-collection.php b/plugins/optimization-detective/class-od-url-metrics-group-collection.php similarity index 100% rename from plugins/optimization-detective/storage/class-od-url-metrics-group-collection.php rename to plugins/optimization-detective/class-od-url-metrics-group-collection.php diff --git a/plugins/optimization-detective/storage/class-od-url-metrics-group.php b/plugins/optimization-detective/class-od-url-metrics-group.php similarity index 100% rename from plugins/optimization-detective/storage/class-od-url-metrics-group.php rename to plugins/optimization-detective/class-od-url-metrics-group.php diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 948d15ae1e..8615f60645 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -27,13 +27,15 @@ define( 'OPTIMIZATION_DETECTIVE_VERSION', '0.1.0' ); +// Core infrastructure classes. +require_once __DIR__ . '/class-od-data-validation-exception.php'; +require_once __DIR__ . '/class-od-url-metric.php'; +require_once __DIR__ . '/class-od-url-metrics-group.php'; +require_once __DIR__ . '/class-od-url-metrics-group-collection.php'; + // Storage logic. -require_once __DIR__ . '/storage/class-od-data-validation-exception.php'; -require_once __DIR__ . '/storage/class-od-url-metric.php'; -require_once __DIR__ . '/storage/class-od-url-metrics-group.php'; -require_once __DIR__ . '/storage/class-od-url-metrics-group-collection.php'; -require_once __DIR__ . '/storage/class-od-storage-lock.php'; require_once __DIR__ . '/storage/class-od-url-metrics-post-type.php'; +require_once __DIR__ . '/storage/class-od-storage-lock.php'; require_once __DIR__ . '/storage/data.php'; require_once __DIR__ . '/storage/rest-api.php'; diff --git a/tests/plugins/optimization-detective/class-od-storage-lock-tests.php b/tests/plugins/optimization-detective/storage/class-od-storage-lock-tests.php similarity index 100% rename from tests/plugins/optimization-detective/class-od-storage-lock-tests.php rename to tests/plugins/optimization-detective/storage/class-od-storage-lock-tests.php diff --git a/tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php b/tests/plugins/optimization-detective/storage/class-od-url-metrics-post-type-tests.php similarity index 100% rename from tests/plugins/optimization-detective/class-od-url-metrics-post-type-tests.php rename to tests/plugins/optimization-detective/storage/class-od-url-metrics-post-type-tests.php From 82875cc4ec995acaf3a61f49d11e77e2f90143af Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 20 Mar 2024 11:21:13 -0700 Subject: [PATCH 350/371] Add Optimization Detective assets and update readme --- .../.wordpress-org/banner-1544x500.png | Bin 0 -> 289224 bytes .../.wordpress-org/banner-772x250.png | Bin 0 -> 80682 bytes .../.wordpress-org/icon-128x128.png | Bin 0 -> 1065 bytes .../.wordpress-org/icon-256x256.png | Bin 0 -> 1769 bytes .../.wordpress-org/icon.svg | 1 + plugins/optimization-detective/load.php | 2 +- plugins/optimization-detective/readme.txt | 6 ++++-- 7 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 plugins/optimization-detective/.wordpress-org/banner-1544x500.png create mode 100644 plugins/optimization-detective/.wordpress-org/banner-772x250.png create mode 100644 plugins/optimization-detective/.wordpress-org/icon-128x128.png create mode 100644 plugins/optimization-detective/.wordpress-org/icon-256x256.png create mode 100644 plugins/optimization-detective/.wordpress-org/icon.svg diff --git a/plugins/optimization-detective/.wordpress-org/banner-1544x500.png b/plugins/optimization-detective/.wordpress-org/banner-1544x500.png new file mode 100644 index 0000000000000000000000000000000000000000..e1dadc6c35031278a5697b75a122a918541e637a GIT binary patch literal 289224 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�&owjF;;wVl`*40Y}N5I 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�$&Xa6WLF5?+y5>H;E|K694 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

      _V89(|0Y`+Pcw9GS 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 literal 0 HcmV?d00001 diff --git a/plugins/optimization-detective/.wordpress-org/banner-772x250.png b/plugins/optimization-detective/.wordpress-org/banner-772x250.png new file mode 100644 index 0000000000000000000000000000000000000000..4403d68d4381f584c1cde1f869625fd2ca5d32ed GIT binary patch literal 80682 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�^b0Td#HR^+7SFvq5u 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 TmCxs$whteBj2ZPKgI)G7SljfX literal 0 HcmV?d00001 diff --git a/plugins/optimization-detective/.wordpress-org/icon-256x256.png b/plugins/optimization-detective/.wordpress-org/icon-256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..7f47fbb0714c456aa8ecb61971aa7149a3bbfc2b GIT binary patch literal 1769 zcmZuyX;f3!8a?C^Nx~Fllv)gfzSN?Nst6*50HQKf0;E92Li$9cOojj^Ovw$x;!z%u zT4XIP$fP6$+60jx3WjPyfx<;3NI^-8P=yv5O%Vg>jqQ5t{ph#$I%j|T?DPFNVV9QazoNQNQKiKs|K2$N-p{4?#!G(~huh_F`v|vvgwc#JBnVyD zM;G?e1$}fuFSY7NO1aFb%IEW|s;bctOC%DRtP4SqA%#MrTv(V<{)!gBy89jR zqOr&4pK8J%hgm&XLVQ}3^_4Bcv83I}ocy2V7bgwMcy0&XE=PFn)E`^cXiN|L5O1}$ zncbiDTh5M>Of)#0>1TUCKeR2@I>^q9x*DU3+PHHr#`QnZGvl4Oe9LFw61t5y zXU_&(w}f8WzXd0-FY`ZOdexS~w%i%Bb5Tzic#JHV{Tf4yB!3t7dKD<@M(Ts`#Di33 zF(U%*weT04uR}C@^3YwSJ_GB2YTNc|g0tuXCeLIfE}8-5?I8~nDuJPJSOT3Y*ymYc zK+SiPo9djT3O+q#p>#77?ZDz{f)A^=EBDf_95Nt!H!PC6J|};wF{9h%hRpYI;17i9 zNuFJfBbg2ud`*NeDU|yA6^e~_vmU0fIwEq>nWmqzO(drl#J4RHMFVsT(ME2DaMW8@ zK2Eax=g3VE-&xOgXvC{k2ZKBUUR-F6`)p&#$0IvC{^)2v5}TNaWRBL(r_172-s-0f zfRy+>w+p{=M)xQSfpZcb?V$?aY+$pI<(`v2ea>5TL|xY=-!yf1m>)lVda$lbx-@&Z zD4ExREMyw?moLq>|CP+e5R?`V->SbqQ4ijY&c)!B7ROgSWR2h0Qfx|irOmy}hFiM^xe;Mmr<*dp;Z>c3q-(l>ZmmQZi9kj1;6AN=la22iT5N*9JbqvM-l{SH?H? z*8^mEtMc3yI=o^4%yrbpCvRBvTZ0c{C9Jna1dPM|$xu_Q&fr&)3bgUD`$Pz z9ij)MPo9)S1~vIkFqfd1)uUN3EelR=fNmWYNPX+fnnNdMsn?GMNDj#Dz*mq2HA2L*W z?zLonk?}ms!LfItM~))xoFp@%U?Bz}md1H`eRK{QyHhv!=D;J%95PP%YPRjnZBFTRg_XbAuS@3eW<2^#z(n&K4EJh{ z19NY23*xCBd^Bv%c(H@?B5sz@k$YyXvJNY0p%2m_=`XC3@&=g8(NxxTOIq%_Uuo6d zi*ogikbgedcI*30XbGonM6HWfqJdxjB~$^Inv?H43(s$}rQHGA`$96V8H0GI^8fA3 z3#2qtNXxkRfkMiGDyVe>n3|NuhpV86hUD}}4=k>l#voE&#eEEnAYGFK1S3pbj=8Wu z7%tp3@?~E$VB+UaxtmZpBN>?OSpEa*b=&iBHQx6M&y=#uUTA`y+QJXG9R;3&kPBO# zn=vol^lOWzbca^`$12RY0B=(_ZOvmtacgbL@(7YN;FGejm0C7N=!&a-&wNQ&qRXOs zw?QW7fQuJRs^E|yFZbQ8hst`M%D-Y$)Vbpu+Q*Ak&w|!I;Y7+jU7{ZvJyMH36-_zm beXvHSh~b*o8NA7*&^ literal 0 HcmV?d00001 diff --git a/plugins/optimization-detective/.wordpress-org/icon.svg b/plugins/optimization-detective/.wordpress-org/icon.svg new file mode 100644 index 0000000000..3860eb67cc --- /dev/null +++ b/plugins/optimization-detective/.wordpress-org/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 8615f60645..6fa3ea11ba 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -2,7 +2,7 @@ /** * Plugin Name: Optimization Detective * Plugin URI: https://github.com/WordPress/performance/issues/869 - * Description: Improves accuracy of optimizing the loading of the LCP image by leveraging client-side detection with real user metrics. Also enables output buffering of template rendering which can be filtered. + * Description: Uses real user metrics to improve heuristics WordPress applies on the frontend to improve image loading priority. * Requires at least: 6.3 * Requires PHP: 7.0 * Version: 0.1.0 diff --git a/plugins/optimization-detective/readme.txt b/plugins/optimization-detective/readme.txt index a4ddcd87bf..32f34d3b45 100644 --- a/plugins/optimization-detective/readme.txt +++ b/plugins/optimization-detective/readme.txt @@ -9,11 +9,11 @@ License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, images -Improves accuracy of optimizing the loading of the LCP image by leveraging client-side detection with real user metrics. +Uses real user metrics to improve heuristics WordPress applies on the frontend to improve image loading priority. == Description == -Improves accuracy of optimizing the loading of the LCP image by leveraging client-side detection with real user metrics. For more information, see the [overview issue](https://github.com/WordPress/performance/issues/869) on GitHub. +This plugin uses real user metrics to improve heuristics WordPress applies on the frontend to improve image loading priority For more information, see the [overview issue](https://github.com/WordPress/performance/issues/869) on GitHub. == Installation == @@ -45,6 +45,8 @@ To report a security issue, please visit the [WordPress HackerOne](https://hacke Contributions are always welcome! Learn more about how to get involved in the [Core Performance Team Handbook](https://make.wordpress.org/performance/handbook/get-involved/). +The [plugin source code](https://github.com/WordPress/performance/tree/trunk/plugins/optimization-detective) is located in the [WordPress/performance](https://github.com/WordPress/performance) repo on GitHub. + == Changelog == = 0.1.0 = From 9518f4c862a705732ae7f077412ecff48b173ac7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 20 Mar 2024 11:35:44 -0700 Subject: [PATCH 351/371] Add generator tag for Optimization Detective --- plugins/optimization-detective/helper.php | 22 +++++++++++++++++++ plugins/optimization-detective/hooks.php | 1 + plugins/optimization-detective/load.php | 2 ++ .../optimization-detective/helper-tests.php | 21 ++++++++++++++++++ .../optimization-detective/hooks-tests.php | 1 + 5 files changed, 47 insertions(+) create mode 100644 plugins/optimization-detective/helper.php create mode 100644 tests/plugins/optimization-detective/helper-tests.php diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php new file mode 100644 index 0000000000..589baaf2e2 --- /dev/null +++ b/plugins/optimization-detective/helper.php @@ -0,0 +1,22 @@ +' . "\n"; +} diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index 829dcc4ac0..6b6feb924d 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -13,3 +13,4 @@ add_filter( 'template_include', 'od_buffer_output', PHP_INT_MAX ); OD_URL_Metrics_Post_Type::add_hooks(); add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' ); +add_action( 'wp_head', 'od_render_generator_meta_tag' ); diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 6fa3ea11ba..809d6b2308 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -27,6 +27,8 @@ define( 'OPTIMIZATION_DETECTIVE_VERSION', '0.1.0' ); +require_once __DIR__ . '/helper.php'; + // Core infrastructure classes. require_once __DIR__ . '/class-od-data-validation-exception.php'; require_once __DIR__ . '/class-od-url-metric.php'; diff --git a/tests/plugins/optimization-detective/helper-tests.php b/tests/plugins/optimization-detective/helper-tests.php new file mode 100644 index 0000000000..b84d9d33b8 --- /dev/null +++ b/tests/plugins/optimization-detective/helper-tests.php @@ -0,0 +1,21 @@ +assertStringStartsWith( 'assertStringContainsString( 'generator', $tag ); + $this->assertStringContainsString( 'Optimization Detective ' . OPTIMIZATION_DETECTIVE_VERSION, $tag ); + } +} diff --git a/tests/plugins/optimization-detective/hooks-tests.php b/tests/plugins/optimization-detective/hooks-tests.php index 95b20bea03..f19541e8ba 100644 --- a/tests/plugins/optimization-detective/hooks-tests.php +++ b/tests/plugins/optimization-detective/hooks-tests.php @@ -25,5 +25,6 @@ public function test_hooks_added() { ) ) ); + $this->assertEquals( 10, has_action( 'wp_head', 'od_render_generator_meta_tag' ) ); } } From ce0a690822eb6170d8224520dbaac93514a8c79b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 20 Mar 2024 11:52:58 -0700 Subject: [PATCH 352/371] Bump minimum WP version for Optimization Detective --- .../class-od-html-tag-processor.php | 3 --- plugins/optimization-detective/load.php | 2 +- .../class-od-url-metrics-post-type.php | 22 +++++++++---------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/plugins/optimization-detective/class-od-html-tag-processor.php b/plugins/optimization-detective/class-od-html-tag-processor.php index 1c21ee6bb1..1c2a660087 100644 --- a/plugins/optimization-detective/class-od-html-tag-processor.php +++ b/plugins/optimization-detective/class-od-html-tag-processor.php @@ -266,9 +266,6 @@ public function open_tags(): Generator { * @param string $message Warning message. */ private function warn( string $message ) { - if ( ! function_exists( 'wp_trigger_error' ) ) { - return; - } wp_trigger_error( __CLASS__ . '::open_tags', esc_html( $message ) diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 809d6b2308..70958bf024 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -3,7 +3,7 @@ * Plugin Name: Optimization Detective * Plugin URI: https://github.com/WordPress/performance/issues/869 * Description: Uses real user metrics to improve heuristics WordPress applies on the frontend to improve image loading priority. - * Requires at least: 6.3 + * Requires at least: 6.4 * Requires PHP: 7.0 * Version: 0.1.0 * Author: WordPress Performance Team diff --git a/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php index a883187523..dafe02fdfd 100644 --- a/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php +++ b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php @@ -117,18 +117,16 @@ public static function get_post( string $slug ) { * @return OD_URL_Metric[] URL metrics. */ public static function get_url_metrics_from_post( WP_Post $post ): array { - $this_function = __FUNCTION__; - $trigger_error = static function ( $error ) use ( $this_function ) { - if ( function_exists( 'wp_trigger_error' ) ) { - wp_trigger_error( $this_function, esc_html( $error ), E_USER_WARNING ); - } + $this_function = __FUNCTION__; + $trigger_warning = static function ( $message ) use ( $this_function ) { + wp_trigger_error( $this_function, esc_html( $message ), E_USER_WARNING ); }; $url_metrics_data = json_decode( $post->post_content, true ); if ( json_last_error() ) { - $trigger_error( + $trigger_warning( sprintf( - /* translators: 1: Post type slug, 2: Post ID, 3: JSON error message */ + /* translators: 1: Post type slug, 2: Post ID, 3: JSON error message */ __( 'Contents of %1$s post type (ID: %2$s) not valid JSON: %3$s', 'optimization-detective' ), self::SLUG, $post->ID, @@ -137,9 +135,9 @@ public static function get_url_metrics_from_post( WP_Post $post ): array { ); $url_metrics_data = array(); } elseif ( ! is_array( $url_metrics_data ) ) { - $trigger_error( + $trigger_warning( sprintf( - /* translators: %s is post type slug */ + /* translators: %s is post type slug */ __( 'Contents of %s post type was not a JSON array.', 'optimization-detective' ), self::SLUG ) @@ -150,7 +148,7 @@ public static function get_url_metrics_from_post( WP_Post $post ): array { return array_values( array_filter( array_map( - static function ( $url_metric_data ) use ( $trigger_error ) { + static function ( $url_metric_data ) use ( $trigger_warning ) { if ( ! is_array( $url_metric_data ) ) { return null; } @@ -158,9 +156,9 @@ static function ( $url_metric_data ) use ( $trigger_error ) { try { return new OD_URL_Metric( $url_metric_data ); } catch ( OD_Data_Validation_Exception $e ) { - $trigger_error( + $trigger_warning( sprintf( - /* translators: 1: Post type slug. 2: Exception message. */ + /* translators: 1: Post type slug. 2: Exception message. */ __( 'Unexpected shape to JSON array in post_content of %1$s post type: %2$s', 'optimization-detective' ), OD_URL_Metrics_Post_Type::SLUG, $e->getMessage() From e819ede522d44d273b42520dfa6ac6c5a511ac75 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 21 Mar 2024 17:28:57 -0700 Subject: [PATCH 353/371] Fix typos in hook phpdoc --- plugins/optimization-detective/detection.php | 4 ++-- plugins/optimization-detective/storage/data.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index d28e5f04c0..d141ee0d04 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -23,9 +23,9 @@ function od_get_detection_script( string $slug, OD_URL_Metrics_Group_Collection /** * Filters the time window between serve time and run time in which loading detection is allowed to run. * - * Allow this amount of milliseconds between when the page was first generated (and perhaps cached) and when the + * This the allowance of milliseconds between when the page was first generated (and perhaps cached) and when the * detect function on the page is allowed to perform its detection logic and submit the request to store the results. - * This avoids situations in which there is missing detection metrics in which case a site with page caching which + * This avoids situations in which there is missing URL Metrics in which case a site with page caching which * also has a lot of traffic could result in a cache stampede. * * @since 0.1.0 diff --git a/plugins/optimization-detective/storage/data.php b/plugins/optimization-detective/storage/data.php index ccb3b6f53a..643fac2c46 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -182,9 +182,9 @@ function od_verify_url_metrics_storage_nonce( string $nonce, string $slug, strin /** * Gets the breakpoint max widths to group URL metrics for various viewports. * - * Each max with represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then + * Each number represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then * this means there will be two viewport groupings, one for 0<=480, and another >480. If instead there were three - * provided breakpoints (320, 480, 576) then this means there will be four viewport groupings: + * provided breakpoints (320, 480, 576) then this means there will be four groups: * * 1. 0-320 (small smartphone) * 2. 321-480 (normal smartphone) @@ -273,7 +273,7 @@ function od_get_url_metrics_breakpoint_sample_size(): int { /** * Filters the sample size for a breakpoint's URL metrics on a given URL. * - * The sample size must greater than zero. + * The sample size must be greater than zero. * * @since 0.1.0 * From df6ca953ba62b7c290af814b2010d34ae6753807 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 21 Mar 2024 17:35:26 -0700 Subject: [PATCH 354/371] Update plugin directory name to note Developer Preview --- plugins/optimization-detective/readme.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/optimization-detective/readme.txt b/plugins/optimization-detective/readme.txt index 32f34d3b45..195f0d2d5d 100644 --- a/plugins/optimization-detective/readme.txt +++ b/plugins/optimization-detective/readme.txt @@ -1,4 +1,4 @@ -=== Optimization Detective === +=== Optimization Detective (Developer Preview) === Contributors: wordpressdotorg Requires at least: 6.3 From d5785ebd0abaf2529a5f2cec077931427008bf60 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 22 Mar 2024 10:11:34 +0530 Subject: [PATCH 355/371] Remove redundant build steps from workflows --- .github/workflows/deploy-dotorg.yml | 11 ++-------- .../workflows/deploy-standalone-plugins.yml | 20 +------------------ .github/workflows/php-test.yml | 2 -- 3 files changed, 3 insertions(+), 30 deletions(-) diff --git a/.github/workflows/deploy-dotorg.yml b/.github/workflows/deploy-dotorg.yml index 41b714c6d6..b6533a7afb 100644 --- a/.github/workflows/deploy-dotorg.yml +++ b/.github/workflows/deploy-dotorg.yml @@ -13,21 +13,14 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Node.js (.nvmrc) - uses: actions/setup-node@v3 - with: - node-version-file: ".nvmrc" - cache: npm - - name: npm install - run: npm ci - - name: Build assets - run: npm run build + - name: WordPress plugin deploy uses: 10up/action-wordpress-plugin-deploy@stable env: SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} SVN_USERNAME: ${{ secrets.SVN_USERNAME }} SLUG: performance-lab + - name: Upload release assets uses: softprops/action-gh-release@v1 if: startsWith(github.ref, 'refs/tags/') diff --git a/.github/workflows/deploy-standalone-plugins.yml b/.github/workflows/deploy-standalone-plugins.yml index 06651d234c..4fd129fc96 100644 --- a/.github/workflows/deploy-standalone-plugins.yml +++ b/.github/workflows/deploy-standalone-plugins.yml @@ -27,25 +27,7 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Node.js (.nvmrc) - uses: actions/setup-node@v3 - with: - node-version-file: '.nvmrc' - cache: npm - - name: Install npm dependencies - run: npm ci - - name: Build assets - run: npm run build - - name: Get directory - id: get-plugin-directory - if: ${{ github.event_name == 'workflow_dispatch' }} - run: | - echo "directory=$(node ./bin/plugin/cli.js get-plugin-dir --slug=${{ inputs.slug }})" >> $GITHUB_OUTPUT - - name: Get plugin version - id: get-version - if: ${{ github.event_name == 'workflow_dispatch' }} - run: | - echo "version=$(node ./bin/plugin/cli.js get-plugin-version --slug=${{ inputs.slug }})" >> $GITHUB_OUTPUT + - name: Set matrix id: set-matrix run: | diff --git a/.github/workflows/php-test.yml b/.github/workflows/php-test.yml index 2f1f4b1f75..ad043414da 100644 --- a/.github/workflows/php-test.yml +++ b/.github/workflows/php-test.yml @@ -62,8 +62,6 @@ jobs: cache: npm - name: npm install run: npm ci - - name: Build assets - run: npm run build - name: Install WordPress run: npm run wp-env start # Note that `composer update` is required instead of `composer install` From e02359aca28ee537e466126eade36dc9a523d8f8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 21 Mar 2024 21:42:14 -0700 Subject: [PATCH 356/371] Bump requires at least to 6.4 Co-authored-by: Mukesh Panchal --- plugins/optimization-detective/readme.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/optimization-detective/readme.txt b/plugins/optimization-detective/readme.txt index 195f0d2d5d..e6b0722ea0 100644 --- a/plugins/optimization-detective/readme.txt +++ b/plugins/optimization-detective/readme.txt @@ -1,7 +1,7 @@ === Optimization Detective (Developer Preview) === Contributors: wordpressdotorg -Requires at least: 6.3 +Requires at least: 6.4 Tested up to: 6.5 Requires PHP: 7.0 Stable tag: 0.1.0 From 381ba0f43b4eb7ed14ba11a6612983dbc780dc1a Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 22 Mar 2024 10:13:42 +0530 Subject: [PATCH 357/371] Move utils from webpack config to bin/webpack --- bin/webpack/utils.js | 21 +++++++++++++++++++++ webpack.config.js | 21 +-------------------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/bin/webpack/utils.js b/bin/webpack/utils.js index 9664cc2688..0b11d10975 100644 --- a/bin/webpack/utils.js +++ b/bin/webpack/utils.js @@ -77,8 +77,29 @@ const generateBuildManifest = ( slug, from ) => { fs.writeFileSync( manifestPath, JSON.stringify( manifest, null, 2 ) ); }; +/** + * Transformer to get version from package.json and return it as a PHP file. + * + * @param {Buffer} content The content as a Buffer of the file being transformed. + * @param {string} absoluteFrom The absolute path to the file being transformed. + * + * @return {Buffer|string} The transformed content. + */ +const assetDataTransformer = ( content, absoluteFrom ) => { + if ( 'package.json' !== path.basename( absoluteFrom ) ) { + return content; + } + + const contentAsString = content.toString(); + const contentAsJson = JSON.parse( contentAsString ); + const { version } = contentAsJson; + + return ` array(), 'version' => '${ version }');`; +}; + module.exports = { deleteFileOrDirectory, getPluginVersion, generateBuildManifest, + assetDataTransformer, }; diff --git a/webpack.config.js b/webpack.config.js index afaa1fd3a8..0759c855e3 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,6 +5,7 @@ const path = require( 'path' ); const WebpackBar = require( 'webpackbar' ); const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); const { + assetDataTransformer, deleteFileOrDirectory, generateBuildManifest, } = require( './bin/webpack/utils' ); @@ -25,26 +26,6 @@ const sharedConfig = { output: {}, }; -/** - * Transformer to get version from package.json and return it as a PHP file. - * - * @param {Buffer} content The content as a Buffer of the file being transformed. - * @param {string} absoluteFrom The absolute path to the file being transformed. - * - * @return {Buffer|string} The transformed content. - */ -const assetDataTransformer = ( content, absoluteFrom ) => { - if ( 'package.json' !== path.basename( absoluteFrom ) ) { - return content; - } - - const contentAsString = content.toString(); - const contentAsJson = JSON.parse( contentAsString ); - const { version } = contentAsJson; - - return ` array(), 'version' => '${ version }');`; -}; - const webVitals = () => { const source = path.resolve( __dirname, 'node_modules/web-vitals' ); const destination = path.resolve( From c2815e3fc9847f19f1fddb7d704551735cde8260 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 22 Mar 2024 12:50:51 +0530 Subject: [PATCH 358/371] Update optimization-detective plugin build step --- webpack.config.js | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/webpack.config.js b/webpack.config.js index 0759c855e3..b607cc32a2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -26,7 +26,23 @@ const sharedConfig = { output: {}, }; -const webVitals = () => { +// Store plugins that require build process. +const pluginsWithBuild = [ 'optimization-detective' ]; + +/** + * Webpack Config: Optimization Detective + * + * @param {*} env Webpack environment + * @return {Object} Webpack configuration + */ +const optimizationDetective = ( env ) => { + if ( env.plugin && env.plugin !== 'optimization-detective' ) { + return { + entry: {}, + output: {}, + }; + } + const source = path.resolve( __dirname, 'node_modules/web-vitals' ); const destination = path.resolve( __dirname, @@ -35,6 +51,7 @@ const webVitals = () => { return { ...sharedConfig, + name: 'optimization-detective', plugins: [ new CopyWebpackPlugin( { patterns: [ @@ -53,8 +70,8 @@ const webVitals = () => { ], } ), new WebpackBar( { - name: 'Web Vitals', - color: '#f5a623', + name: 'Building Optimization Detective Assets', + color: '#2196f3', } ), ], }; @@ -87,6 +104,9 @@ const buildPlugin = ( env ) => { const to = path.resolve( __dirname, 'build', env.plugin ); const from = path.resolve( __dirname, 'plugins', env.plugin ); + const dependencies = pluginsWithBuild.includes( env.plugin ) + ? [ `${ env.plugin }` ] + : []; return { ...sharedConfig, @@ -122,14 +142,12 @@ const buildPlugin = ( env ) => { }, }, new WebpackBar( { - name: `Building ${ env.plugin }`, + name: `Building ${ env.plugin } Plugin`, color: '#4caf50', } ), ], - dependencies: [ - // Add any dependencies here which should be built before the plugin. - ], + dependencies, }; }; -module.exports = [ webVitals, buildPlugin ]; +module.exports = [ optimizationDetective, buildPlugin ]; From d0cb266bd05f78c15db3c99e76e874c62fa735f3 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 22 Mar 2024 14:07:01 +0530 Subject: [PATCH 359/371] Move webpack utils import to Internal dependencies --- webpack.config.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/webpack.config.js b/webpack.config.js index b607cc32a2..2a260e83d2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,16 +4,16 @@ const path = require( 'path' ); const WebpackBar = require( 'webpackbar' ); const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); -const { - assetDataTransformer, - deleteFileOrDirectory, - generateBuildManifest, -} = require( './bin/webpack/utils' ); /** * Internal dependencies */ const { plugins: standalonePlugins } = require( './plugins.json' ); +const { + assetDataTransformer, + deleteFileOrDirectory, + generateBuildManifest, +} = require( './bin/webpack/utils' ); /** * WordPress dependencies From 2f3605ab2c0c845c48fd7d8810b58e349e56f3b7 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 22 Mar 2024 14:13:10 +0530 Subject: [PATCH 360/371] Add getPluginRootPath() to exports --- bin/webpack/utils.js | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/webpack/utils.js b/bin/webpack/utils.js index 0b11d10975..d0a909dfca 100644 --- a/bin/webpack/utils.js +++ b/bin/webpack/utils.js @@ -98,6 +98,7 @@ const assetDataTransformer = ( content, absoluteFrom ) => { }; module.exports = { + getPluginRootPath, deleteFileOrDirectory, getPluginVersion, generateBuildManifest, From b2465467501567dacc3f12655d76f822eee557de Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 22 Mar 2024 14:16:41 +0530 Subject: [PATCH 361/371] Add build:plugin command for embed-optimizer plugin --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index a5224883d3..d6569c237a 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "build:plugin:performance-lab": "rm -rf build/performance-lab && mkdir -p build/performance-lab && git archive HEAD | tar -x -C build/performance-lab", "build:plugin:auto-sizes": "webpack --mode production --env plugin=auto-sizes", "build:plugin:dominant-color-images": "webpack --mode production --env plugin=dominant-color-images", + "build:plugin:embed-optimizer": "webpack --mode production --env plugin=embed-optimizer", "build:plugin:optimization-detective": "webpack --mode production --env plugin=optimization-detective", "build:plugin:speculation-rules": "webpack --mode production --env plugin=speculation-rules", "build:plugin:webp-uploads": "webpack --mode production --env plugin=webp-uploads", From 767c895ef46636b4af1b90bdafecca8e1211fa0d Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 22 Mar 2024 15:23:01 +0530 Subject: [PATCH 362/371] Decouple plugins asset building from main Webpack config --- package.json | 4 +- .../optimization-detective/webpack.config.js | 78 +++++++++++++++++++ webpack.config.js | 61 +-------------- 3 files changed, 84 insertions(+), 59 deletions(-) create mode 100644 plugins/optimization-detective/webpack.config.js diff --git a/package.json b/package.json index d6569c237a..2b6cddd6b3 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,14 @@ "changelog": "./bin/plugin/cli.js changelog", "since": "./bin/plugin/cli.js since", "readme": "./bin/plugin/cli.js readme", - "build": "wp-scripts build", + "build": "npm-run-all 'build:!(plugin)'", + "build:optimization-detective": "wp-scripts build -c plugins/optimization-detective/webpack.config.js", "build-plugins": "npm-run-all 'build:plugin:!(performance-lab)'", "build:plugin:performance-lab": "rm -rf build/performance-lab && mkdir -p build/performance-lab && git archive HEAD | tar -x -C build/performance-lab", "build:plugin:auto-sizes": "webpack --mode production --env plugin=auto-sizes", "build:plugin:dominant-color-images": "webpack --mode production --env plugin=dominant-color-images", "build:plugin:embed-optimizer": "webpack --mode production --env plugin=embed-optimizer", + "prebuild:plugin:optimization-detective": "npm run build:optimization-detective", "build:plugin:optimization-detective": "webpack --mode production --env plugin=optimization-detective", "build:plugin:speculation-rules": "webpack --mode production --env plugin=speculation-rules", "build:plugin:webp-uploads": "webpack --mode production --env plugin=webp-uploads", diff --git a/plugins/optimization-detective/webpack.config.js b/plugins/optimization-detective/webpack.config.js new file mode 100644 index 0000000000..be9bf428aa --- /dev/null +++ b/plugins/optimization-detective/webpack.config.js @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +const path = require( 'path' ); +const WebpackBar = require( 'webpackbar' ); +const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); + +/** + * Internal dependencies + */ +const { + getPluginRootPath, + assetDataTransformer, +} = require( '../../bin/webpack/utils' ); + +/** + * WordPress dependencies + */ +const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); + +const sharedConfig = { + ...defaultConfig, + entry: {}, + output: {}, +}; + +/** + * Webpack Config: Optimization Detective + * + * @param {*} env Webpack environment + * @return {Object} Webpack configuration + */ +const optimizationDetective = ( env ) => { + if ( env.plugin && env.plugin !== 'optimization-detective' ) { + return { + entry: {}, + output: {}, + }; + } + + const source = path.resolve( + getPluginRootPath(), + 'node_modules/web-vitals' + ); + const destination = path.resolve( + getPluginRootPath(), + 'plugins/optimization-detective/detection' + ); + + return { + ...sharedConfig, + name: 'optimization-detective', + plugins: [ + new CopyWebpackPlugin( { + patterns: [ + { + from: `${ source }/dist/web-vitals.js`, + to: `${ destination }/web-vitals.js`, + }, + { + from: `${ source }/package.json`, + to: `${ destination }/web-vitals.asset.php`, + transform: { + transformer: assetDataTransformer, + cache: false, + }, + }, + ], + } ), + new WebpackBar( { + name: 'Building Optimization Detective Assets', + color: '#2196f3', + } ), + ], + }; +}; + +module.exports = optimizationDetective; diff --git a/webpack.config.js b/webpack.config.js index 2a260e83d2..2ab39ec96d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -10,7 +10,6 @@ const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); */ const { plugins: standalonePlugins } = require( './plugins.json' ); const { - assetDataTransformer, deleteFileOrDirectory, generateBuildManifest, } = require( './bin/webpack/utils' ); @@ -26,57 +25,6 @@ const sharedConfig = { output: {}, }; -// Store plugins that require build process. -const pluginsWithBuild = [ 'optimization-detective' ]; - -/** - * Webpack Config: Optimization Detective - * - * @param {*} env Webpack environment - * @return {Object} Webpack configuration - */ -const optimizationDetective = ( env ) => { - if ( env.plugin && env.plugin !== 'optimization-detective' ) { - return { - entry: {}, - output: {}, - }; - } - - const source = path.resolve( __dirname, 'node_modules/web-vitals' ); - const destination = path.resolve( - __dirname, - 'plugins/optimization-detective/detection' - ); - - return { - ...sharedConfig, - name: 'optimization-detective', - plugins: [ - new CopyWebpackPlugin( { - patterns: [ - { - from: `${ source }/dist/web-vitals.js`, - to: `${ destination }/web-vitals.js`, - }, - { - from: `${ source }/package.json`, - to: `${ destination }/web-vitals.asset.php`, - transform: { - transformer: assetDataTransformer, - cache: false, - }, - }, - ], - } ), - new WebpackBar( { - name: 'Building Optimization Detective Assets', - color: '#2196f3', - } ), - ], - }; -}; - /** * Webpack configuration for building the plugin for distribution. * Note: Need to pass plugin name like `--env.plugin=plugin-name` to build particular plugin. @@ -104,9 +52,6 @@ const buildPlugin = ( env ) => { const to = path.resolve( __dirname, 'build', env.plugin ); const from = path.resolve( __dirname, 'plugins', env.plugin ); - const dependencies = pluginsWithBuild.includes( env.plugin ) - ? [ `${ env.plugin }` ] - : []; return { ...sharedConfig, @@ -120,9 +65,10 @@ const buildPlugin = ( env ) => { globOptions: { dot: true, ignore: [ + '**/*.[Cc]ache', '**/.wordpress-org', '**/phpcs.xml.dist', - '**/*.[Cc]ache', + '**/webpack.config.js', ], }, }, @@ -146,8 +92,7 @@ const buildPlugin = ( env ) => { color: '#4caf50', } ), ], - dependencies, }; }; -module.exports = [ optimizationDetective, buildPlugin ]; +module.exports = [ buildPlugin ]; From 2052292603b475cb5ccaf87365243fbb5566514f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 22 Mar 2024 09:18:39 -0700 Subject: [PATCH 363/371] Fix doing_it_wrong to allow freshness ttl to be zero for development purposes --- plugins/optimization-detective/storage/data.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/optimization-detective/storage/data.php b/plugins/optimization-detective/storage/data.php index 643fac2c46..8efd96c5c3 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -33,7 +33,7 @@ function od_get_url_metric_freshness_ttl(): int { */ $freshness_ttl = (int) apply_filters( 'od_url_metric_freshness_ttl', DAY_IN_SECONDS ); - if ( $freshness_ttl <= 0 ) { + if ( $freshness_ttl < 0 ) { _doing_it_wrong( __FUNCTION__, esc_html( From 4656cbf379061fd96d545cdfb61aa0ab71855af6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 22 Mar 2024 09:40:38 -0700 Subject: [PATCH 364/371] Add console error when web-vitals.js is absent --- plugins/optimization-detective/detection.php | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index d28e5f04c0..ae7d3df71d 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -35,8 +35,24 @@ function od_get_detection_script( string $slug, OD_URL_Metrics_Group_Collection */ $detection_time_window = apply_filters( 'od_detection_time_window', 5000 ); - $web_vitals_lib_data = require __DIR__ . '/detection/web-vitals.asset.php'; - $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . '/detection/web-vitals.js' ); + try { + $web_vitals_lib_data = require __DIR__ . '/detection/web-vitals.asset.php'; + } catch ( Error $error ) { + $error_message = '[Optimization Detective] '; + $error_message .= sprintf( + /* translators: 1: File path. 2: CLI command. */ + __( 'Unable to load %1$s. Please make sure you have run %2$s.', 'optimization-detective' ), + 'detection/web-vitals.asset.php', + '`npm install && npm run build:optimization-detective`' + ); + if ( WP_DEBUG && WP_DEBUG_DISPLAY ) { + $error_message .= ' ' . __( 'PHP Error:', 'optimization-detective' ) . ' ' . $error->getMessage(); + } + return wp_get_inline_script_tag( + sprintf( 'console.error( %s );', wp_json_encode( $error_message ) ) + ); + } + $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . '/detection/web-vitals.js' ); $current_url = od_get_current_url(); $detect_args = array( From 6ae5fb01cca13b0f1a2f1d3de1859debf65f3ccd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 22 Mar 2024 11:31:10 -0700 Subject: [PATCH 365/371] Revert "Add console error when web-vitals.js is absent" This reverts commit 4656cbf379061fd96d545cdfb61aa0ab71855af6. --- plugins/optimization-detective/detection.php | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index ae7d3df71d..d28e5f04c0 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -35,24 +35,8 @@ function od_get_detection_script( string $slug, OD_URL_Metrics_Group_Collection */ $detection_time_window = apply_filters( 'od_detection_time_window', 5000 ); - try { - $web_vitals_lib_data = require __DIR__ . '/detection/web-vitals.asset.php'; - } catch ( Error $error ) { - $error_message = '[Optimization Detective] '; - $error_message .= sprintf( - /* translators: 1: File path. 2: CLI command. */ - __( 'Unable to load %1$s. Please make sure you have run %2$s.', 'optimization-detective' ), - 'detection/web-vitals.asset.php', - '`npm install && npm run build:optimization-detective`' - ); - if ( WP_DEBUG && WP_DEBUG_DISPLAY ) { - $error_message .= ' ' . __( 'PHP Error:', 'optimization-detective' ) . ' ' . $error->getMessage(); - } - return wp_get_inline_script_tag( - sprintf( 'console.error( %s );', wp_json_encode( $error_message ) ) - ); - } - $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . '/detection/web-vitals.js' ); + $web_vitals_lib_data = require __DIR__ . '/detection/web-vitals.asset.php'; + $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . '/detection/web-vitals.js' ); $current_url = od_get_current_url(); $detect_args = array( From 0e07d110fbd9f33186c89aeff690333d2912b604 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 22 Mar 2024 11:31:19 -0700 Subject: [PATCH 366/371] Revert "Decouple plugins asset building from main Webpack config" This reverts commit 767c895ef46636b4af1b90bdafecca8e1211fa0d. --- package.json | 4 +- .../optimization-detective/webpack.config.js | 78 ------------------- webpack.config.js | 61 ++++++++++++++- 3 files changed, 59 insertions(+), 84 deletions(-) delete mode 100644 plugins/optimization-detective/webpack.config.js diff --git a/package.json b/package.json index 2b6cddd6b3..d6569c237a 100644 --- a/package.json +++ b/package.json @@ -28,14 +28,12 @@ "changelog": "./bin/plugin/cli.js changelog", "since": "./bin/plugin/cli.js since", "readme": "./bin/plugin/cli.js readme", - "build": "npm-run-all 'build:!(plugin)'", - "build:optimization-detective": "wp-scripts build -c plugins/optimization-detective/webpack.config.js", + "build": "wp-scripts build", "build-plugins": "npm-run-all 'build:plugin:!(performance-lab)'", "build:plugin:performance-lab": "rm -rf build/performance-lab && mkdir -p build/performance-lab && git archive HEAD | tar -x -C build/performance-lab", "build:plugin:auto-sizes": "webpack --mode production --env plugin=auto-sizes", "build:plugin:dominant-color-images": "webpack --mode production --env plugin=dominant-color-images", "build:plugin:embed-optimizer": "webpack --mode production --env plugin=embed-optimizer", - "prebuild:plugin:optimization-detective": "npm run build:optimization-detective", "build:plugin:optimization-detective": "webpack --mode production --env plugin=optimization-detective", "build:plugin:speculation-rules": "webpack --mode production --env plugin=speculation-rules", "build:plugin:webp-uploads": "webpack --mode production --env plugin=webp-uploads", diff --git a/plugins/optimization-detective/webpack.config.js b/plugins/optimization-detective/webpack.config.js deleted file mode 100644 index be9bf428aa..0000000000 --- a/plugins/optimization-detective/webpack.config.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * External dependencies - */ -const path = require( 'path' ); -const WebpackBar = require( 'webpackbar' ); -const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); - -/** - * Internal dependencies - */ -const { - getPluginRootPath, - assetDataTransformer, -} = require( '../../bin/webpack/utils' ); - -/** - * WordPress dependencies - */ -const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); - -const sharedConfig = { - ...defaultConfig, - entry: {}, - output: {}, -}; - -/** - * Webpack Config: Optimization Detective - * - * @param {*} env Webpack environment - * @return {Object} Webpack configuration - */ -const optimizationDetective = ( env ) => { - if ( env.plugin && env.plugin !== 'optimization-detective' ) { - return { - entry: {}, - output: {}, - }; - } - - const source = path.resolve( - getPluginRootPath(), - 'node_modules/web-vitals' - ); - const destination = path.resolve( - getPluginRootPath(), - 'plugins/optimization-detective/detection' - ); - - return { - ...sharedConfig, - name: 'optimization-detective', - plugins: [ - new CopyWebpackPlugin( { - patterns: [ - { - from: `${ source }/dist/web-vitals.js`, - to: `${ destination }/web-vitals.js`, - }, - { - from: `${ source }/package.json`, - to: `${ destination }/web-vitals.asset.php`, - transform: { - transformer: assetDataTransformer, - cache: false, - }, - }, - ], - } ), - new WebpackBar( { - name: 'Building Optimization Detective Assets', - color: '#2196f3', - } ), - ], - }; -}; - -module.exports = optimizationDetective; diff --git a/webpack.config.js b/webpack.config.js index 2ab39ec96d..2a260e83d2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -10,6 +10,7 @@ const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); */ const { plugins: standalonePlugins } = require( './plugins.json' ); const { + assetDataTransformer, deleteFileOrDirectory, generateBuildManifest, } = require( './bin/webpack/utils' ); @@ -25,6 +26,57 @@ const sharedConfig = { output: {}, }; +// Store plugins that require build process. +const pluginsWithBuild = [ 'optimization-detective' ]; + +/** + * Webpack Config: Optimization Detective + * + * @param {*} env Webpack environment + * @return {Object} Webpack configuration + */ +const optimizationDetective = ( env ) => { + if ( env.plugin && env.plugin !== 'optimization-detective' ) { + return { + entry: {}, + output: {}, + }; + } + + const source = path.resolve( __dirname, 'node_modules/web-vitals' ); + const destination = path.resolve( + __dirname, + 'plugins/optimization-detective/detection' + ); + + return { + ...sharedConfig, + name: 'optimization-detective', + plugins: [ + new CopyWebpackPlugin( { + patterns: [ + { + from: `${ source }/dist/web-vitals.js`, + to: `${ destination }/web-vitals.js`, + }, + { + from: `${ source }/package.json`, + to: `${ destination }/web-vitals.asset.php`, + transform: { + transformer: assetDataTransformer, + cache: false, + }, + }, + ], + } ), + new WebpackBar( { + name: 'Building Optimization Detective Assets', + color: '#2196f3', + } ), + ], + }; +}; + /** * Webpack configuration for building the plugin for distribution. * Note: Need to pass plugin name like `--env.plugin=plugin-name` to build particular plugin. @@ -52,6 +104,9 @@ const buildPlugin = ( env ) => { const to = path.resolve( __dirname, 'build', env.plugin ); const from = path.resolve( __dirname, 'plugins', env.plugin ); + const dependencies = pluginsWithBuild.includes( env.plugin ) + ? [ `${ env.plugin }` ] + : []; return { ...sharedConfig, @@ -65,10 +120,9 @@ const buildPlugin = ( env ) => { globOptions: { dot: true, ignore: [ - '**/*.[Cc]ache', '**/.wordpress-org', '**/phpcs.xml.dist', - '**/webpack.config.js', + '**/*.[Cc]ache', ], }, }, @@ -92,7 +146,8 @@ const buildPlugin = ( env ) => { color: '#4caf50', } ), ], + dependencies, }; }; -module.exports = [ buildPlugin ]; +module.exports = [ optimizationDetective, buildPlugin ]; From 3aae425a62410655d585f0a1d629719a7d4d39e2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 22 Mar 2024 11:53:24 -0700 Subject: [PATCH 367/371] Add web-vitals file exists check to load.php --- plugins/optimization-detective/load.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 70958bf024..abd8dd8e65 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -25,6 +25,21 @@ return; } +if ( ! file_exists( __DIR__ . '/detection/web-vitals.asset.php' ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + trigger_error( + esc_html( + sprintf( + /* translators: 1: File path. 2: CLI command. */ + '[Optimization Detective] ' . __( 'Unable to load %1$s. Please make sure you have run %2$s.', 'optimization-detective' ), + 'detection/web-vitals.asset.php', + '`npm install && npm run build:optimization-detective`' + ) + ), + E_USER_ERROR + ); +} + define( 'OPTIMIZATION_DETECTIVE_VERSION', '0.1.0' ); require_once __DIR__ . '/helper.php'; From ae07548a36e900bb5aed1483875ba53ffd9b626b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 22 Mar 2024 12:05:34 -0700 Subject: [PATCH 368/371] Only do file exists check if in admin or WP-CLI --- plugins/optimization-detective/load.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index abd8dd8e65..2e7f70887f 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -25,7 +25,10 @@ return; } -if ( ! file_exists( __DIR__ . '/detection/web-vitals.asset.php' ) ) { +if ( + ( is_admin() || ( defined( 'WP_CLI' ) && WP_CLI ) ) && + ! file_exists( __DIR__ . '/detection/web-vitals.asset.php' ) +) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error trigger_error( esc_html( From bc906ae8a1048b6d7b399556e8d217fed8e6a4a9 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 23 Mar 2024 00:51:24 +0530 Subject: [PATCH 369/371] Update PL plugin tests workflow to remove standlaone plugins from wp-env config --- .github/workflows/php-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/php-test.yml b/.github/workflows/php-test.yml index ad043414da..f6454ee31d 100644 --- a/.github/workflows/php-test.yml +++ b/.github/workflows/php-test.yml @@ -62,6 +62,8 @@ jobs: cache: npm - name: npm install run: npm ci + - name: Remove standalone plugins from wp-env config + run: jq '.plugins = [.plugins[0]]' .wp-env.json > .wp-env.override.json - name: Install WordPress run: npm run wp-env start # Note that `composer update` is required instead of `composer install` From 48eca7542332586e5e2b131019842015f18e953a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 22 Mar 2024 13:17:49 -0700 Subject: [PATCH 370/371] Update readme from Google Doc --- plugins/optimization-detective/readme.txt | 90 ++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/plugins/optimization-detective/readme.txt b/plugins/optimization-detective/readme.txt index e6b0722ea0..6fc33da6d6 100644 --- a/plugins/optimization-detective/readme.txt +++ b/plugins/optimization-detective/readme.txt @@ -13,7 +13,91 @@ Uses real user metrics to improve heuristics WordPress applies on the frontend t == Description == -This plugin uses real user metrics to improve heuristics WordPress applies on the frontend to improve image loading priority For more information, see the [overview issue](https://github.com/WordPress/performance/issues/869) on GitHub. +This plugin captures real user metrics about what elements are displayed on the page across a variety of device form factors (e.g. desktop, tablet, and phone) in order to apply loading optimizations which are not possible with WordPress’s current server-side heuristics. + += Background = + +WordPress uses [server-side heuristics](https://make.wordpress.org/core/2023/07/13/image-performance-enhancements-in-wordpress-6-3/) to make educated guesses about which images are likely to be in the initial viewport. Likewise, it uses server-side heuristics to identify a hero image which is likely to be the Largest Contentful Paint (LCP) element. To optimize page loading, it avoids lazy-loading any of these images while also adding `fetchpriority=high` to the hero image. When these heuristics are applied successfully, the LCP metric for page loading can be improved 5-10%. Unfortunately, however, there are limitations to the heuristics that make the correct identification of which image is the LCP element only about 50% effective. See [Analyzing the Core Web Vitals performance impact of WordPress 6.3 in the field](https://make.wordpress.org/core/2023/09/19/analyzing-the-core-web-vitals-performance-impact-of-wordpress-6-3-in-the-field/). For example, it is [common](https://github.com/GoogleChromeLabs/wpp-research/pull/73) for the LCP element to vary between different viewport widths, such as desktop versus mobile. Since WordPress's heuristics are completely server-side it has no knowledge of how the page is actually laid out, and it cannot prioritize loading of images according to the client's viewport width. + +In order to increase the accuracy of identifying the LCP element, including across various client viewport widths, this plugin gathers metrics from real users (RUM) to detect the actual LCP element and then use this information to optimize the page for future visitors so that the loading of the LCP element is properly prioritized. This is the purpose of Optimization Detective. The approach is heavily inspired by Philip Walton’s [Dynamic LCP Priority: Learning from Past Visits](https://philipwalton.com/articles/dynamic-lcp-priority/). See also the initial exploration document that laid out this project: [Image Loading Optimization via Client-side Detection](https://docs.google.com/document/u/1/d/16qAJ7I_ljhEdx2Cn2VlK7IkiixobY9zNn8FXxN9T9Ls/view). + += Technical Foundation = + +At the core of Optimization Detective is the “URL Metric”, information about a page according to how it was loaded by a client with a specific viewport width. This includes which elements were visible in the initial viewport and which one was the LCP element. Each URL on a site can have an associated set of these URL Metrics (stored in a custom post type) which are gathered from real users. It gathers a sample of URL Metrics according to common responsive breakpoints (e.g. mobile, tablet, and desktop). When no more URL Metrics are needed for a URL due to the sample size being obtained for the breakpoints, it discontinues serving the JavaScript to gather the metrics (leveraging the [web-vitals.js](https://github.com/GoogleChrome/web-vitals) library). With the URL Metrics in hand, the output-buffered page is sent through the HTML Tag Processor and the images which were the LCP element for various breakpoints will get prioritized with high-priority preload links (along with `fetchpriority=high` on the actual `img` tag when it is the common LCP element across all breakpoints). LCP elements with background images added via inline `background-image` styles are also prioritized with preload links. + +URL Metrics have a “freshness TTL” after which they will be stale and the JavaScript will be served again to start gathering metrics again to ensure that the right elements continue to get their loading prioritized. When a URL Metrics custom post type hasn't been touched in a while, it is automatically garbage-collected. + +Prioritizing the loading of images which are the LCP element is only the first optimization implemented as a proof of concept for how other optimizations might also be applied. See a [list of issues](https://github.com/WordPress/performance/labels/%5BPlugin%5D%20Optimization%20Detective) for planned additional optimizations which are only feasible with the URL Metrics RUM data. + +Note that by default, URL Metrics are not gathered for administrator users, since they are not normal site visitors, and it is likely that additional elements will be present on the page which are not also shown to non-administrators. + +When the `WP_DEBUG` constant is enabled, additional logging for Optimization Detective is added to the browser console. + += Filters = + +**Filter:** `od_breakpoint_max_widths` (default: [480, 600, 782]) + +Filters the breakpoint max widths to group URL metrics for various viewports. Each number represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then this means there will be two viewport groupings, one for 0<=480, and another >480. If instead there were three provided breakpoints (320, 480, 576) then this means there will be four groups: + + 1. 0-320 (small smartphone) + 2. 321-480 (normal smartphone) + 3. 481-576 (phablets) + 4. >576 (desktop) + +The default breakpoints are reused from Gutenberg which appear to be used the most in media queries that affect frontend styles. + +**Filter:** `od_can_optimize_response` (default: boolean condition, see below) + +Filters whether the current response can be optimized. By default, detection and optimization are only performed when: + +1. It’s not a search template (i.e. `is_search()`). +2. It’s not the Customizer preview. +3. It’s not the response to a `POST` request. +4. The user is not an administrator (i.e. the `customize` capability). + +During development, you may want to force this to always be enabled: + +` + Date: Fri, 22 Mar 2024 14:38:34 -0700 Subject: [PATCH 371/371] Fix typo --- plugins/optimization-detective/detection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index d141ee0d04..3f2b5d4494 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -23,7 +23,7 @@ function od_get_detection_script( string $slug, OD_URL_Metrics_Group_Collection /** * Filters the time window between serve time and run time in which loading detection is allowed to run. * - * This the allowance of milliseconds between when the page was first generated (and perhaps cached) and when the + * This is the allowance of milliseconds between when the page was first generated (and perhaps cached) and when the * detect function on the page is allowed to perform its detection logic and submit the request to store the results. * This avoids situations in which there is missing URL Metrics in which case a site with page caching which * also has a lot of traffic could result in a cache stampede.