PluginProbe
Optimization Detective / trunk
Optimization Detective vtrunk
1.0.0-beta7 trunk 0.1.0 0.1.1 0.2.0 0.3.0 0.3.1 0.4.0 0.4.1 0.5.0 0.6.0 0.7.0 0.8.0 0.9.0 1.0.0-beta1 1.0.0-beta2 1.0.0-beta3 1.0.0-beta4 1.0.0-beta5 1.0.0-beta6
optimization-detective / detect.js

detect.js in Optimization Detective trunk, at detect.js

1,127 lines 33.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // noinspection JSUnusedGlobalSymbols
2
3 /**
4 * @typedef {import("web-vitals").LCPMetric} LCPMetric
5 * @typedef {import("web-vitals").LCPMetricWithAttribution} LCPMetricWithAttribution
6 * @typedef {import("./types.ts").ElementData} ElementData
7 * @typedef {import("./types.ts").OnTTFBFunction} OnTTFBFunction
8 * @typedef {import("./types.ts").OnFCPFunction} OnFCPFunction
9 * @typedef {import("./types.ts").OnLCPFunction} OnLCPFunction
10 * @typedef {import("./types.ts").OnINPFunction} OnINPFunction
11 * @typedef {import("./types.ts").OnCLSFunction} OnCLSFunction
12 * @typedef {import("./types.ts").OnTTFBWithAttributionFunction} OnTTFBWithAttributionFunction
13 * @typedef {import("./types.ts").OnFCPWithAttributionFunction} OnFCPWithAttributionFunction
14 * @typedef {import("./types.ts").OnLCPWithAttributionFunction} OnLCPWithAttributionFunction
15 * @typedef {import("./types.ts").OnINPWithAttributionFunction} OnINPWithAttributionFunction
16 * @typedef {import("./types.ts").OnCLSWithAttributionFunction} OnCLSWithAttributionFunction
17 * @typedef {import("./types.ts").URLMetric} URLMetric
18 * @typedef {import("./types.ts").URLMetricGroupStatus} URLMetricGroupStatus
19 * @typedef {import("./types.ts").Extension} Extension
20 * @typedef {import("./types.ts").ExtendedRootData} ExtendedRootData
21 * @typedef {import("./types.ts").ExtendedElementData} ExtendedElementData
22 * @typedef {import("./types.ts").GetRootDataFunction} GetRootDataFunction
23 * @typedef {import("./types.ts").ExtendRootDataFunction} ExtendRootDataFunction
24 * @typedef {import("./types.ts").GetElementDataFunction} GetElementDataFunction
25 * @typedef {import("./types.ts").ExtendElementDataFunction} ExtendElementDataFunction
26 * @typedef {import("./types.ts").Logger} Logger
27 */
28
29 /**
30 * Window reference to reduce size when the script is minified.
31 *
32 * @type {Window}
33 */
34 const win = window;
35
36 /**
37 * Document reference to reduce size when the script is minified.
38 *
39 * @type {Document}
40 */
41 const doc = win.document;
42
43 /**
44 * Prefix which is prepended to all messages logged to the console.
45 *
46 * @see {createLogger}
47 * @type {string}
48 */
49 const consoleLogPrefix = '[Optimization Detective]';
50
51 /**
52 * Session storage key for client-side storage lock to prevent clients attempting to submit URL Metrics when there is a server-side storage lock.
53 *
54 * @see {isStorageLocked}
55 * @see {setStorageLock}
56 * @type {string}
57 */
58 const storageLockTimeSessionKey = 'odStorageLockTime';
59
60 /**
61 * Wait duration in milliseconds for debounced calls to re-compress the URL Metric JSON data.
62 *
63 * @see {debounceCompressUrlMetric}
64 * @type {number}
65 */
66 const compressionDebounceWaitDuration = 1000;
67
68 /**
69 * Checks whether storage is locked.
70 *
71 * @param {number} currentTime - Current time in milliseconds.
72 * @param {number} storageLockTTL - Storage lock TTL in seconds.
73 * @return {boolean} Whether storage is locked.
74 */
75 function isStorageLocked( currentTime, storageLockTTL ) {
76 if ( storageLockTTL === 0 ) {
77 return false;
78 }
79
80 try {
81 const storageLockTime = parseInt(
82 sessionStorage.getItem( storageLockTimeSessionKey ) || '',
83 10
84 );
85 return (
86 ! isNaN( storageLockTime ) &&
87 currentTime < storageLockTime + storageLockTTL * 1000
88 );
89 } catch {
90 return false;
91 }
92 }
93
94 /**
95 * Sets the storage lock.
96 *
97 * @param {number} currentTime - Current time in milliseconds.
98 */
99 function setStorageLock( currentTime ) {
100 try {
101 sessionStorage.setItem(
102 storageLockTimeSessionKey,
103 String( currentTime )
104 );
105 } catch {}
106 }
107
108 /**
109 * Creates a logger object with log, warn, and error methods.
110 *
111 * @param {boolean} [debugMode=false] - Whether all messages should be logged. If false, then only errors are logged.
112 * @param {?string} [prefix=null] - Prefix to prepend to the console message.
113 * @param {?string} [scriptModuleUrl=null] - The URL for the script module which is emitting the log. This is used for extensions.
114 * @return {Logger} Logger object with log, info, warn, and error methods.
115 */
116 function createLogger(
117 debugMode = false,
118 prefix = null,
119 scriptModuleUrl = null
120 ) {
121 const logSource = scriptModuleUrl ? `\nSource: ${ scriptModuleUrl }` : null;
122
123 /**
124 * Constructs the args to pass to the logging function.
125 *
126 * @param {Array<any>} message - The message(s) to log.
127 * @param {boolean} includeSource - Whether to include the source. This should be true for warnings or errors.
128 * @return {Array<any>} Amended message.
129 */
130 const constructLogArgs = ( message, includeSource = false ) => {
131 return [ prefix, ...message, includeSource ? logSource : null ].filter(
132 ( value ) => value !== null
133 );
134 };
135
136 return {
137 /**
138 * Logs a message if debug mode is enabled.
139 *
140 * @param {...*} message - The message(s) to log.
141 */
142 log( ...message ) {
143 if ( debugMode ) {
144 // eslint-disable-next-line no-console
145 console.log( ...constructLogArgs( message, false ) );
146 }
147 },
148
149 /**
150 * Logs an informational message if debug mode is enabled.
151 *
152 * @param {...*} message - The message(s) to log as info.
153 */
154 info( ...message ) {
155 if ( debugMode ) {
156 // eslint-disable-next-line no-console
157 console.info( ...constructLogArgs( message, false ) );
158 }
159 },
160
161 /**
162 * Logs a warning if debug mode is enabled.
163 *
164 * @param {...*} message - The message(s) to log as a warning.
165 */
166 warn( ...message ) {
167 if ( debugMode ) {
168 // eslint-disable-next-line no-console
169 console.warn( ...constructLogArgs( message, true ) );
170 }
171 },
172
173 /**
174 * Logs an error.
175 *
176 * @param {...*} message - The message(s) to log as an error.
177 */
178 error( ...message ) {
179 // eslint-disable-next-line no-console
180 console.error( ...constructLogArgs( message, true ) );
181 },
182 };
183 }
184
185 /**
186 * Attempts to get the extension name (i.e. slug for plugin or theme) from the script module URL.
187 *
188 * If extraction of the slug fails, then the entire URL is returned.
189 *
190 * @param {string} scriptModuleUrl - Script module URL.
191 * @return {string} Derived extension name.
192 */
193 function getExtensionNameFromScriptModuleUrl( scriptModuleUrl ) {
194 try {
195 const url = new URL( scriptModuleUrl, win.location.href );
196 const matches = url.pathname.match(
197 /\/(?:themes|plugins)\/([^\/]+)\//
198 );
199 if ( matches ) {
200 return matches[ 1 ];
201 }
202 return url.pathname;
203 } catch {
204 return scriptModuleUrl;
205 }
206 }
207
208 /**
209 * Gets the status for the URL Metric group for the provided viewport width.
210 *
211 * The comparison logic here corresponds with the PHP logic in `OD_URL_Metric_Group::is_viewport_width_in_range()`.
212 * This function is also similar to the PHP logic in `\OD_URL_Metric_Group_Collection::get_group_for_viewport_width()`.
213 *
214 * @param {number} viewportWidth - Current viewport width.
215 * @param {URLMetricGroupStatus[]} urlMetricGroupStatuses - Viewport group statuses.
216 * @return {URLMetricGroupStatus} The URL metric group for the viewport width.
217 */
218 function getGroupForViewportWidth( viewportWidth, urlMetricGroupStatuses ) {
219 for ( const urlMetricGroupStatus of urlMetricGroupStatuses ) {
220 if (
221 viewportWidth > urlMetricGroupStatus.minimumViewportWidth &&
222 ( null === urlMetricGroupStatus.maximumViewportWidth ||
223 viewportWidth <= urlMetricGroupStatus.maximumViewportWidth )
224 ) {
225 return urlMetricGroupStatus;
226 }
227 }
228 throw new Error(
229 `${ consoleLogPrefix } Unexpectedly unable to locate group for the current viewport width.`
230 );
231 }
232
233 /**
234 * Gets the sessionStorage key for keeping track of whether the current client session already submitted a URL Metric.
235 *
236 * @param {string} currentETag - Current ETag.
237 * @param {string} currentUrl - Current URL.
238 * @param {URLMetricGroupStatus} urlMetricGroupStatus - URL Metric group status.
239 * @param {Logger} logger - Logger.
240 * @return {Promise<string|null>} Session storage key for the current URL or null if crypto is not available or caused an error.
241 */
242 async function getAlreadySubmittedSessionStorageKey(
243 currentETag,
244 currentUrl,
245 urlMetricGroupStatus,
246 { warn, error }
247 ) {
248 if ( ! win.crypto || ! win.crypto.subtle ) {
249 warn(
250 'Unable to generate sessionStorage key for already-submitted URL since crypto is not available, likely due to to the page not being served via HTTPS.'
251 );
252 return null;
253 }
254
255 try {
256 const message = [
257 currentETag,
258 currentUrl,
259 urlMetricGroupStatus.minimumViewportWidth,
260 urlMetricGroupStatus.maximumViewportWidth || '',
261 ].join( '-' );
262
263 /*
264 * Note that the components are hashed for a couple of reasons:
265 *
266 * 1. It results in a consistent length string devoid of any special characters that could cause problems.
267 * 2. Since the key includes the URL, hashing it avoids potential privacy concerns where the sessionStorage is
268 * examined to see which URLs the client went to.
269 *
270 * The SHA-1 algorithm is chosen since it is the fastest and there is no need for cryptographic security.
271 */
272 const msgBuffer = new TextEncoder().encode( message );
273 const hashBuffer = await crypto.subtle.digest( 'SHA-1', msgBuffer );
274 const hashHex = Array.from( new Uint8Array( hashBuffer ) )
275 .map( ( b ) => b.toString( 16 ).padStart( 2, '0' ) )
276 .join( '' );
277 return `odSubmitted-${ hashHex }`;
278 } catch ( err ) {
279 error(
280 'Unable to generate sessionStorage key for already-submitted URL due to error:',
281 err
282 );
283 return null;
284 }
285 }
286
287 /**
288 * Gets the current time in milliseconds.
289 *
290 * @return {number} Current time in milliseconds.
291 */
292 function getCurrentTime() {
293 return Date.now();
294 }
295
296 /**
297 * Recursively freezes an object to prevent mutation.
298 *
299 * @param {Object} obj - Object to recursively freeze.
300 */
301 function recursiveFreeze( /** @type {Record<string, any>} */ obj ) {
302 for ( const prop of Object.getOwnPropertyNames( obj ) ) {
303 const value = obj[ prop ];
304 if ( null !== value && typeof value === 'object' ) {
305 recursiveFreeze( value );
306 }
307 }
308 Object.freeze( obj );
309 }
310
311 /**
312 * URL Metric being assembled for submission.
313 *
314 * @type {URLMetric}
315 */
316 let urlMetric;
317
318 /**
319 * Reserved root property keys.
320 *
321 * @see {URLMetric}
322 * @see {ExtendedElementData}
323 * @type {Set<string>}
324 */
325 const reservedRootPropertyKeys = new Set( [ 'url', 'viewport', 'elements' ] );
326
327 /**
328 * Gets root URL Metric data.
329 *
330 * @type {GetRootDataFunction}
331 * @return {URLMetric} URL Metric.
332 */
333 function getRootData() {
334 const immutableUrlMetric = structuredClone( urlMetric );
335 recursiveFreeze( immutableUrlMetric );
336 return immutableUrlMetric;
337 }
338
339 /**
340 * Extends root URL Metric data.
341 *
342 * @type {ExtendRootDataFunction}
343 * @param {ExtendedRootData} properties
344 */
345 function extendRootData( properties ) {
346 for ( const key of Object.getOwnPropertyNames( properties ) ) {
347 if ( reservedRootPropertyKeys.has( key ) ) {
348 throw new Error( `Disallowed setting of key '${ key }' on root.` );
349 }
350 }
351 Object.assign( urlMetric, properties );
352 debounceCompressUrlMetric();
353 }
354
355 /**
356 * Mapping of XPath to element data.
357 *
358 * @type {Map<string, ElementData>}
359 */
360 const elementsByXPath = new Map();
361
362 /**
363 * Reserved element property keys.
364 *
365 * @see {ElementData}
366 * @see {ExtendedRootData}
367 * @type {Set<string>}
368 */
369 const reservedElementPropertyKeys = new Set( [
370 'isLCP',
371 'isLCPCandidate',
372 'xpath',
373 'intersectionRatio',
374 'intersectionRect',
375 'boundingClientRect',
376 ] );
377
378 /**
379 * Gets element data.
380 *
381 * @type {GetElementDataFunction}
382 * @param {string} xpath - XPath.
383 * @return {ElementData|null} Element data, or null if no element for the XPath exists.
384 */
385 function getElementData( xpath ) {
386 const elementData = elementsByXPath.get( xpath );
387 if ( elementData ) {
388 const cloned = structuredClone( elementData );
389 recursiveFreeze( cloned );
390 return cloned;
391 }
392 return null;
393 }
394
395 /**
396 * Extends element data.
397 *
398 * @type {ExtendElementDataFunction}
399 * @param {string} xpath - XPath.
400 * @param {ExtendedElementData} properties - Properties.
401 */
402 function extendElementData( xpath, properties ) {
403 if ( ! elementsByXPath.has( xpath ) ) {
404 throw new Error( `Unknown element with XPath: ${ xpath }` );
405 }
406 for ( const key of Object.getOwnPropertyNames( properties ) ) {
407 if ( reservedElementPropertyKeys.has( key ) ) {
408 throw new Error(
409 `Disallowed setting of key '${ key }' on element.`
410 );
411 }
412 }
413 const elementData = elementsByXPath.get( xpath );
414 if ( elementData ) {
415 Object.assign( elementData, properties );
416 }
417 debounceCompressUrlMetric();
418 }
419
420 /**
421 * Compresses a JSON string using CompressionStream API.
422 *
423 * @param {string} jsonString - JSON string to compress.
424 * @return {Promise<Blob>} Compressed data.
425 */
426 async function compress( jsonString ) {
427 const encodedData = new TextEncoder().encode( jsonString );
428 const compressedDataStream = new Blob( [ encodedData ] )
429 .stream()
430 .pipeThrough( new CompressionStream( 'gzip' ) );
431 const compressedDataBuffer = await new Response(
432 compressedDataStream
433 ).arrayBuffer();
434 return new Blob( [ compressedDataBuffer ], { type: 'application/gzip' } );
435 }
436
437 /**
438 * The compressed URL metric data.
439 *
440 * @see {debounceCompressUrlMetric}
441 * @type {?Blob}
442 */
443 let compressedPayload = null;
444
445 /**
446 * Timeout ID for debouncing URL metric compression.
447 *
448 * @see {debounceCompressUrlMetric}
449 * @type {?ReturnType<typeof setTimeout>}
450 */
451 let recompressionTimeout = null;
452
453 /**
454 * Handle for requestIdleCallback for URL metric compression.
455 *
456 * @see {debounceCompressUrlMetric}
457 * @type {?number}
458 */
459 let idleCallbackHandle = null;
460
461 /**
462 * Whether compression is enabled.
463 *
464 * @see {detect}
465 * @see {debounceCompressUrlMetric}
466 * @type {boolean}
467 */
468 let compressionEnabled = true;
469
470 /**
471 * Debounces the compression of the URL Metric.
472 */
473 function debounceCompressUrlMetric() {
474 if ( ! compressionEnabled ) {
475 return;
476 }
477 if ( null !== recompressionTimeout ) {
478 clearTimeout( recompressionTimeout );
479 recompressionTimeout = null;
480 }
481 if (
482 null !== idleCallbackHandle &&
483 typeof cancelIdleCallback === 'function'
484 ) {
485 cancelIdleCallback( idleCallbackHandle );
486 idleCallbackHandle = null;
487 }
488 recompressionTimeout = setTimeout( async () => {
489 if ( typeof requestIdleCallback === 'function' ) {
490 await new Promise( ( resolve ) => {
491 idleCallbackHandle = requestIdleCallback( resolve );
492 } );
493 idleCallbackHandle = null;
494 }
495 try {
496 compressedPayload = await compress( JSON.stringify( urlMetric ) );
497 } catch ( err ) {
498 const { error } = createLogger( false, consoleLogPrefix );
499 error(
500 'Failed to compress URL Metric falling back to sending uncompressed data:',
501 err
502 );
503 compressionEnabled = false;
504 }
505 recompressionTimeout = null;
506 }, compressionDebounceWaitDuration );
507 }
508
509 /**
510 * @typedef {{timestamp: number, creationDate: Date}} UrlMetricDebugData
511 * @typedef {{groups: Array<{url_metrics: Array<UrlMetricDebugData>}>}} CollectionDebugData
512 */
513
514 /**
515 * Args for the detect function.
516 *
517 * @since 1.0.0
518 *
519 * @typedef {Object} DetectFunctionArgs
520 * @property {string[]} extensionModuleUrls - URLs for extension script modules to import.
521 * @property {number} minViewportAspectRatio - Minimum aspect ratio allowed for the viewport.
522 * @property {number} maxViewportAspectRatio - Maximum aspect ratio allowed for the viewport.
523 * @property {boolean} isDebug - Whether to show debug messages.
524 * @property {string} restApiEndpoint - URL for where to send the detection data.
525 * @property {string} [restApiNonce] - Nonce for the REST API when the user is logged-in.
526 * @property {boolean} gzdecodeAvailable - Whether application/gzip can be sent to the REST API.
527 * @property {number} maxUrlMetricSize - Maximum size of the URL Metric to send.
528 * @property {string} currentETag - Current ETag.
529 * @property {string} currentUrl - Current URL.
530 * @property {string} urlMetricSlug - Slug for URL Metric.
531 * @property {number|null} cachePurgePostId - Cache purge post ID.
532 * @property {string} urlMetricHMAC - HMAC for URL Metric storage.
533 * @property {URLMetricGroupStatus[]} urlMetricGroupStatuses - URL Metric group statuses.
534 * @property {number} storageLockTTL - The TTL (in seconds) for the URL Metric storage lock.
535 * @property {number} freshnessTTL - The freshness age (TTL) for a given URL Metric.
536 * @property {string} webVitalsLibrarySrc - The URL for the web-vitals library.
537 * @property {CollectionDebugData} [urlMetricGroupCollection] - URL Metric group collection, when in debug mode.
538 */
539
540 /**
541 * The detect function.
542 *
543 * @since 1.0.0
544 * @callback DetectFunction
545 * @param {DetectFunctionArgs} args - The arguments for the function.
546 * @return {Promise<void>}
547 */
548
549 /**
550 * Detects the LCP element, loaded images, client viewport, and store for future optimizations.
551 *
552 * @type {DetectFunction}
553 * @param {DetectFunctionArgs} args - Args.
554 */
555 export default async function detect( {
556 minViewportAspectRatio,
557 maxViewportAspectRatio,
558 isDebug,
559 extensionModuleUrls,
560 restApiEndpoint,
561 restApiNonce,
562 gzdecodeAvailable,
563 maxUrlMetricSize,
564 currentETag,
565 currentUrl,
566 urlMetricSlug,
567 cachePurgePostId,
568 urlMetricHMAC,
569 urlMetricGroupStatuses,
570 storageLockTTL,
571 freshnessTTL,
572 webVitalsLibrarySrc,
573 urlMetricGroupCollection,
574 } ) {
575 const logger = createLogger( isDebug, consoleLogPrefix );
576 const { log, warn, error } = logger;
577 compressionEnabled = gzdecodeAvailable;
578
579 if ( isDebug && Array.isArray( urlMetricGroupCollection?.groups ) ) {
580 const allUrlMetrics = /** @type Array<UrlMetricDebugData> */ [];
581 for ( const group of urlMetricGroupCollection.groups ) {
582 for ( const otherUrlMetric of group.url_metrics ) {
583 otherUrlMetric.creationDate = new Date(
584 otherUrlMetric.timestamp * 1000
585 );
586 allUrlMetrics.push( otherUrlMetric );
587 }
588 }
589 log( 'Stored URL Metric Group Collection:', urlMetricGroupCollection );
590 allUrlMetrics.sort( ( a, b ) => b.timestamp - a.timestamp );
591 log(
592 'Stored URL Metrics in reverse chronological order:',
593 allUrlMetrics
594 );
595 }
596
597 if ( win.innerWidth === 0 || win.innerHeight === 0 ) {
598 log(
599 'Window must have non-zero dimensions for URL Metric collection.'
600 );
601 return;
602 }
603
604 if ( doc.visibilityState === 'hidden' && ! doc.prerendering ) {
605 log( 'Page opened in background tab so URL Metric is not collected.' );
606 return;
607 }
608
609 // Abort if the current viewport is not among those which need URL Metrics.
610 const urlMetricGroupStatus = getGroupForViewportWidth(
611 win.innerWidth,
612 urlMetricGroupStatuses
613 );
614 if ( urlMetricGroupStatus.complete ) {
615 log( 'No need for URL Metrics from the current viewport.' );
616 return;
617 }
618
619 // Abort if the client already submitted a URL Metric for this URL and viewport group.
620 const alreadySubmittedSessionStorageKey =
621 await getAlreadySubmittedSessionStorageKey(
622 currentETag,
623 currentUrl,
624 urlMetricGroupStatus,
625 logger
626 );
627 if (
628 null !== alreadySubmittedSessionStorageKey &&
629 alreadySubmittedSessionStorageKey in sessionStorage
630 ) {
631 const previousVisitTime = parseInt(
632 sessionStorage.getItem( alreadySubmittedSessionStorageKey ) || '',
633 10
634 );
635 if (
636 ! isNaN( previousVisitTime ) &&
637 ( freshnessTTL < 0 ||
638 ( getCurrentTime() - previousVisitTime ) / 1000 < freshnessTTL )
639 ) {
640 log(
641 'The current client session already submitted a fresh URL Metric for this URL so a new one will not be collected now.'
642 );
643 return;
644 }
645 }
646
647 // Abort if the viewport aspect ratio is not in a common range.
648 const aspectRatio = win.innerWidth / win.innerHeight;
649 if (
650 aspectRatio < minViewportAspectRatio ||
651 aspectRatio > maxViewportAspectRatio
652 ) {
653 warn(
654 `Viewport aspect ratio (${ aspectRatio }) is not in the accepted range of ${ minViewportAspectRatio } to ${ maxViewportAspectRatio }.`
655 );
656 return;
657 }
658
659 // TODO: Does this make sense here? Should it be moved up above the isViewportNeeded condition?
660 // As an alternative to this, the od_print_detection_script() function can short-circuit if the
661 // od_is_url_metric_storage_locked() function returns true. However, the downside with that is page caching could
662 // result in metrics missed from being gathered when a user navigates around a site and primes the page cache.
663 if ( isStorageLocked( getCurrentTime(), storageLockTTL ) ) {
664 warn( 'Aborted detection due to storage being locked.' );
665 return;
666 }
667
668 // Keep track of whether the window resized. If it was resized, we abort sending the URLMetric.
669 let didWindowResize = false;
670 win.addEventListener(
671 'resize',
672 () => {
673 didWindowResize = true;
674 },
675 { once: true }
676 );
677
678 const {
679 /** @type {OnTTFBFunction|OnTTFBWithAttributionFunction} */ onTTFB,
680 /** @type {OnFCPFunction|OnFCPWithAttributionFunction} */ onFCP,
681 /** @type {OnLCPFunction|OnLCPWithAttributionFunction} */ onLCP,
682 /** @type {OnINPFunction|OnINPWithAttributionFunction} */ onINP,
683 /** @type {OnCLSFunction|OnCLSWithAttributionFunction} */ onCLS,
684 } = await import( webVitalsLibrarySrc );
685
686 // TODO: Does this make sense here?
687 // Prevent detection when page is not scrolled to the initial viewport.
688 if ( doc.documentElement.scrollTop > 0 ) {
689 warn(
690 'Aborted detection since initial scroll position of page is not at the top.'
691 );
692 return;
693 }
694
695 log( 'Proceeding with detection' );
696
697 const breadcrumbedElements = doc.body.querySelectorAll( '[data-od-xpath]' );
698
699 /** @type {Map<Element, string>} */
700 const breadcrumbedElementsMap = new Map(
701 [ ...breadcrumbedElements ].map(
702 /**
703 * @param {Element} element
704 * @return {[Element, string]} Tuple of an element and its XPath.
705 */
706 ( element ) => [
707 element,
708 /** @type {string} */ (
709 element.getAttribute( 'data-od-xpath' )
710 ),
711 ]
712 )
713 );
714
715 /** @type {IntersectionObserverEntry[]} */
716 const elementIntersections = [];
717
718 /** @type {?IntersectionObserver} */
719 let intersectionObserver;
720
721 function disconnectIntersectionObserver() {
722 if ( intersectionObserver instanceof IntersectionObserver ) {
723 intersectionObserver.disconnect();
724 win.removeEventListener( 'scroll', disconnectIntersectionObserver ); // Clean up, even though this is registered with once:true.
725 }
726 }
727
728 // Wait for the intersection observer to report back on the initially visible elements.
729 // Note that the first callback will include _all_ observed entries per <https://github.com/w3c/IntersectionObserver/issues/476>.
730 if ( breadcrumbedElementsMap.size > 0 ) {
731 await /** @type {Promise<void>} */ (
732 new Promise( ( resolve ) => {
733 intersectionObserver = new IntersectionObserver(
734 ( entries ) => {
735 for ( const entry of entries ) {
736 elementIntersections.push( entry );
737 }
738 resolve();
739 },
740 {
741 root: null, // To watch for intersection relative to the device's viewport.
742 threshold: 0.0, // As soon as even one pixel is visible.
743 }
744 );
745
746 for ( const element of breadcrumbedElementsMap.keys() ) {
747 intersectionObserver.observe( element );
748 }
749 } )
750 );
751
752 // Stop observing as soon as the page scrolls since we only want initial-viewport elements.
753 win.addEventListener( 'scroll', disconnectIntersectionObserver, {
754 once: true,
755 passive: true,
756 } );
757 }
758
759 /** @type {(LCPMetric|LCPMetricWithAttribution)[]} */
760 const lcpMetricCandidates = [];
761
762 // Get at least one LCP candidate. More may be reported before the page finishes loading.
763 await /** @type {Promise<void>} */ (
764 new Promise( ( resolve ) => {
765 onLCP(
766 /**
767 * Handles an LCP metric being reported.
768 *
769 * @param {LCPMetric|LCPMetricWithAttribution} metric
770 */
771 ( metric ) => {
772 lcpMetricCandidates.push( metric );
773 resolve();
774 },
775 {
776 // This avoids needing to click to finalize the LCP candidate. While this is helpful for testing, it also
777 // ensures that we always get an LCP candidate reported. Otherwise, the callback may never fire if the
778 // user never does a click or keydown, per <https://github.com/GoogleChrome/web-vitals/blob/07f6f96/src/onLCP.ts#L99-L107>.
779 reportAllChanges: true,
780 }
781 );
782 } )
783 );
784
785 // Stop observing the initial viewport.
786 disconnectIntersectionObserver();
787
788 urlMetric = {
789 url: currentUrl,
790 viewport: {
791 width: win.innerWidth,
792 height: win.innerHeight,
793 },
794 elements: [],
795 };
796
797 const lcpMetric = lcpMetricCandidates[ lcpMetricCandidates.length - 1 ];
798
799 // Populate the elements in the URL Metric.
800 for ( const elementIntersection of elementIntersections ) {
801 const xpath = breadcrumbedElementsMap.get( elementIntersection.target );
802 if ( ! xpath ) {
803 warn( 'Unable to look up XPath for element' );
804 continue;
805 }
806
807 const element = /** @type {Element|null} */ (
808 lcpMetric?.entries[ 0 ]?.element
809 );
810 const isLCP = elementIntersection.target === element;
811
812 /** @type {ElementData} */
813 const elementData = {
814 isLCP,
815 isLCPCandidate: !! lcpMetricCandidates.find(
816 ( lcpMetricCandidate ) => {
817 const candidateElement = /** @type {Element|null} */ (
818 lcpMetricCandidate.entries[ 0 ]?.element
819 );
820 return candidateElement === elementIntersection.target;
821 }
822 ),
823 xpath,
824 intersectionRatio: elementIntersection.intersectionRatio,
825 intersectionRect: elementIntersection.intersectionRect,
826 boundingClientRect: elementIntersection.boundingClientRect,
827 };
828
829 urlMetric.elements.push( elementData );
830 elementsByXPath.set( elementData.xpath, elementData );
831 }
832 breadcrumbedElementsMap.clear(); // No longer needed.
833
834 /**
835 * Initialize extensions.
836 */
837
838 /** @type {Map<string, Extension>} */
839 const extensions = new Map();
840
841 /** @type {boolean} */
842 let extensionHasFinalize = false;
843
844 /** @type {Promise<void>[]} */
845 const extensionInitializePromises = [];
846
847 /** @type {string[]} */
848 const initializingExtensionModuleUrls = [];
849
850 // Load all extensions in parallel.
851 await Promise.all(
852 extensionModuleUrls.map( async ( extensionModuleUrl ) => {
853 const extension = /** @type {Extension} */ await import(
854 extensionModuleUrl
855 );
856 extensions.set( extensionModuleUrl, extension );
857 } )
858 );
859
860 // Initialize extensions.
861 for ( const [ extensionModuleUrl, extension ] of extensions.entries() ) {
862 try {
863 const extensionLogger = createLogger(
864 isDebug,
865 `[Optimization Detective: ${
866 extension.name ||
867 getExtensionNameFromScriptModuleUrl( extensionModuleUrl )
868 }]`,
869 extensionModuleUrl
870 );
871
872 // TODO: There should to be a way to pass additional args into the module. Perhaps extensionModuleUrls should be a mapping of URLs to args.
873 if ( extension.initialize instanceof Function ) {
874 const initializePromise = extension.initialize( {
875 isDebug,
876 ...extensionLogger,
877 onTTFB,
878 onFCP,
879 onLCP,
880 onINP,
881 onCLS,
882 getRootData,
883 extendRootData,
884 getElementData,
885 extendElementData,
886 } );
887 if ( initializePromise instanceof Promise ) {
888 extensionInitializePromises.push( initializePromise );
889 initializingExtensionModuleUrls.push( extensionModuleUrl );
890 }
891 }
892
893 if ( extension.finalize instanceof Function ) {
894 extensionLogger.warn(
895 'Use of the finalize function in extensions is deprecated. Please refactor your extension to use the initialize function instead, and update the URL Metric data as soon as a change is detected rather than waiting until finalization.'
896 );
897 extensionHasFinalize = true;
898 }
899 } catch ( err ) {
900 error(
901 `Failed to start initializing extension '${ extensionModuleUrl }':`,
902 err
903 );
904 }
905 }
906
907 // Wait for all extensions to finish initializing.
908 const settledInitializePromises = await Promise.allSettled(
909 extensionInitializePromises
910 );
911 for ( const [
912 i,
913 settledInitializePromise,
914 ] of settledInitializePromises.entries() ) {
915 if ( settledInitializePromise.status === 'rejected' ) {
916 error(
917 `Failed to initialize extension '${ initializingExtensionModuleUrls[ i ] }':`,
918 settledInitializePromise.reason
919 );
920 }
921 }
922
923 if ( compressionEnabled && extensionHasFinalize ) {
924 compressionEnabled = false;
925 warn(
926 'URL Metric compression is disabled because one or more extensions use the deprecated finalize function.'
927 );
928 }
929
930 log( 'Current URL Metric:', urlMetric );
931
932 // Compress the URL Metric once so that even if there are no extensions available or extending the URL Metric, it is compressed.
933 debounceCompressUrlMetric();
934
935 // Wait for the page to be hidden.
936 await /** @type {Promise<void>} */ (
937 new Promise( ( resolve ) => {
938 win.addEventListener( 'pagehide', () => resolve(), { once: true } );
939 win.addEventListener( 'pageswap', () => resolve(), { once: true } );
940 doc.addEventListener(
941 'visibilitychange',
942 () => {
943 if ( doc.visibilityState === 'hidden' ) {
944 // TODO: This will fire even when switching tabs.
945 resolve();
946 }
947 },
948 { once: true }
949 );
950 } )
951 );
952
953 // Only proceed with submitting the URL Metric if the viewport stayed the same size. Changing the viewport size (e.g. due
954 // to resizing a window or changing the orientation of a device) will result in unexpected metrics being collected.
955 if ( didWindowResize ) {
956 log( 'Aborting URL Metric collection due to viewport size change.' );
957 return;
958 }
959
960 // Finalize extensions.
961 if ( extensions.size > 0 ) {
962 /** @type {Promise<void>[]} */
963 const extensionFinalizePromises = [];
964
965 /** @type {string[]} */
966 const finalizingExtensionModuleUrls = [];
967
968 for ( const [
969 extensionModuleUrl,
970 extension,
971 ] of extensions.entries() ) {
972 if ( extension.finalize instanceof Function ) {
973 const extensionLogger = createLogger(
974 isDebug,
975 `[Optimization Detective: ${
976 extension.name ||
977 getExtensionNameFromScriptModuleUrl(
978 extensionModuleUrl
979 )
980 }]`,
981 extensionModuleUrl
982 );
983
984 try {
985 const finalizePromise = extension.finalize( {
986 isDebug,
987 ...extensionLogger,
988 getRootData,
989 getElementData,
990 extendElementData,
991 extendRootData,
992 } );
993 if ( finalizePromise instanceof Promise ) {
994 extensionFinalizePromises.push( finalizePromise );
995 finalizingExtensionModuleUrls.push(
996 extensionModuleUrl
997 );
998 }
999 } catch ( err ) {
1000 error(
1001 `Unable to start finalizing extension '${ extensionModuleUrl }':`,
1002 err
1003 );
1004 }
1005 }
1006 }
1007
1008 // Wait for all extensions to finish finalizing.
1009 const settledFinalizePromises = await Promise.allSettled(
1010 extensionFinalizePromises
1011 );
1012 for ( const [
1013 i,
1014 settledFinalizePromise,
1015 ] of settledFinalizePromises.entries() ) {
1016 if ( settledFinalizePromise.status === 'rejected' ) {
1017 error(
1018 `Failed to finalize extension '${ finalizingExtensionModuleUrls[ i ] }':`,
1019 settledFinalizePromise.reason
1020 );
1021 }
1022 }
1023 }
1024
1025 /*
1026 * Now prepare the URL Metric to be sent in the JSON request body.
1027 */
1028
1029 const maxBodyLengthKiB = 64;
1030 const maxBodyLengthBytes = maxBodyLengthKiB * 1024;
1031
1032 const jsonBody = JSON.stringify( urlMetric );
1033 if ( jsonBody.length > maxUrlMetricSize ) {
1034 error(
1035 `URL Metric is ${ jsonBody.length.toLocaleString() } bytes, exceeding the maximum size of ${ maxUrlMetricSize.toLocaleString() } bytes:`,
1036 urlMetric
1037 );
1038 return;
1039 }
1040 compressionEnabled = compressionEnabled && null !== compressedPayload;
1041 const payloadBlob =
1042 compressionEnabled && compressedPayload
1043 ? compressedPayload
1044 : new Blob( [ jsonBody ], { type: 'application/json' } );
1045 const percentOfBudget =
1046 ( payloadBlob.size / ( maxBodyLengthKiB * 1000 ) ) * 100;
1047
1048 /*
1049 * According to the fetch() spec:
1050 * "If the sum of contentLength and inflightKeepaliveBytes is greater than 64 kibibytes, then return a network error."
1051 * This is what browsers also implement for navigator.sendBeacon(). Therefore, if the size of the JSON is greater
1052 * than the maximum, we should avoid even trying to send it.
1053 */
1054 if ( payloadBlob.size > maxBodyLengthBytes ) {
1055 error(
1056 `Unable to send URL Metric because it is ${ payloadBlob.size.toLocaleString() } bytes, ${ Math.round(
1057 percentOfBudget
1058 ) }% of ${ maxBodyLengthKiB } KiB limit:`,
1059 urlMetric
1060 );
1061 return;
1062 }
1063
1064 // Even though the server may reject the REST API request, we still have to set the storage lock
1065 // because we can't look at the response when sending a beacon.
1066 setStorageLock( getCurrentTime() );
1067
1068 // Remember that the URL Metric was submitted for this URL to avoid having multiple entries submitted by the same client.
1069 if ( null !== alreadySubmittedSessionStorageKey ) {
1070 sessionStorage.setItem(
1071 alreadySubmittedSessionStorageKey,
1072 String( getCurrentTime() )
1073 );
1074 }
1075
1076 let message = 'Sending URL Metric (';
1077 message += `${ payloadBlob.size.toLocaleString() } bytes`;
1078 message += `, ${ Math.round(
1079 percentOfBudget
1080 ) }% of ${ maxBodyLengthKiB } KiB limit`;
1081 if ( compressionEnabled ) {
1082 message += `, gzip compressed -${ Math.round(
1083 ( ( jsonBody.length - payloadBlob.size ) / jsonBody.length ) * 100
1084 ) }%`;
1085 } else {
1086 message += ', uncompressed';
1087 }
1088 message += '):';
1089
1090 // The threshold of 50% is used because the limit for all beacons combined is 64 KiB, not just the data for one beacon.
1091 if ( percentOfBudget < 50 ) {
1092 log( message, urlMetric );
1093 } else {
1094 warn( message, urlMetric );
1095 }
1096
1097 const url = new URL( restApiEndpoint );
1098 if ( typeof restApiNonce === 'string' ) {
1099 url.searchParams.set( '_wpnonce', restApiNonce );
1100 }
1101 url.searchParams.set( 'slug', urlMetricSlug );
1102 url.searchParams.set( 'current_etag', currentETag );
1103 if ( typeof cachePurgePostId === 'number' ) {
1104 url.searchParams.set(
1105 'cache_purge_post_id',
1106 cachePurgePostId.toString()
1107 );
1108 }
1109 url.searchParams.set( 'hmac', urlMetricHMAC );
1110
1111 /** @type {Record<string, string>} */
1112 const headers = {
1113 'Content-Type': 'application/json',
1114 };
1115 if ( compressionEnabled ) {
1116 headers[ 'Content-Encoding' ] = 'gzip';
1117 }
1118
1119 const request = new Request( url, {
1120 method: 'POST',
1121 body: payloadBlob,
1122 headers,
1123 keepalive: true, // This makes fetch() behave the same as navigator.sendBeacon().
1124 } );
1125 await fetch( request );
1126 }
1127