HEX
Server: Apache/2.4.65 (Debian)
System: Linux 88f31f35b0b8 6.1.0-38-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.147-1 (2025-08-02) x86_64
User: www-data (33)
PHP: 8.2.29
Disabled: NONE
Upload Files
File: /var/www/html/wp-content/plugins/optimization-detective/detect.js
// noinspection JSUnusedGlobalSymbols

/**
 * @typedef {import("web-vitals").LCPMetric} LCPMetric
 * @typedef {import("web-vitals").LCPMetricWithAttribution} LCPMetricWithAttribution
 * @typedef {import("./types.ts").ElementData} ElementData
 * @typedef {import("./types.ts").OnTTFBFunction} OnTTFBFunction
 * @typedef {import("./types.ts").OnFCPFunction} OnFCPFunction
 * @typedef {import("./types.ts").OnLCPFunction} OnLCPFunction
 * @typedef {import("./types.ts").OnINPFunction} OnINPFunction
 * @typedef {import("./types.ts").OnCLSFunction} OnCLSFunction
 * @typedef {import("./types.ts").OnTTFBWithAttributionFunction} OnTTFBWithAttributionFunction
 * @typedef {import("./types.ts").OnFCPWithAttributionFunction} OnFCPWithAttributionFunction
 * @typedef {import("./types.ts").OnLCPWithAttributionFunction} OnLCPWithAttributionFunction
 * @typedef {import("./types.ts").OnINPWithAttributionFunction} OnINPWithAttributionFunction
 * @typedef {import("./types.ts").OnCLSWithAttributionFunction} OnCLSWithAttributionFunction
 * @typedef {import("./types.ts").URLMetric} URLMetric
 * @typedef {import("./types.ts").URLMetricGroupStatus} URLMetricGroupStatus
 * @typedef {import("./types.ts").Extension} Extension
 * @typedef {import("./types.ts").ExtendedRootData} ExtendedRootData
 * @typedef {import("./types.ts").ExtendedElementData} ExtendedElementData
 * @typedef {import("./types.ts").GetRootDataFunction} GetRootDataFunction
 * @typedef {import("./types.ts").ExtendRootDataFunction} ExtendRootDataFunction
 * @typedef {import("./types.ts").GetElementDataFunction} GetElementDataFunction
 * @typedef {import("./types.ts").ExtendElementDataFunction} ExtendElementDataFunction
 * @typedef {import("./types.ts").Logger} Logger
 */

/**
 * Window reference to reduce size when the script is minified.
 *
 * @type {Window}
 */
const win = window;

/**
 * Document reference to reduce size when the script is minified.
 *
 * @type {Document}
 */
const doc = win.document;

/**
 * Prefix which is prepended to all messages logged to the console.
 *
 * @see {createLogger}
 * @type {string}
 */
const consoleLogPrefix = '[Optimization Detective]';

/**
 * Session storage key for client-side storage lock to prevent clients attempting to submit URL Metrics when there is a server-side storage lock.
 *
 * @see {isStorageLocked}
 * @see {setStorageLock}
 * @type {string}
 */
const storageLockTimeSessionKey = 'odStorageLockTime';

/**
 * Wait duration in milliseconds for debounced calls to re-compress the URL Metric JSON data.
 *
 * @see {debounceCompressUrlMetric}
 * @type {number}
 */
const compressionDebounceWaitDuration = 1000;

/**
 * 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 ) {
	if ( storageLockTTL === 0 ) {
		return false;
	}

	try {
		const storageLockTime = parseInt(
			sessionStorage.getItem( storageLockTimeSessionKey )
		);
		return (
			! isNaN( storageLockTime ) &&
			currentTime < storageLockTime + storageLockTTL * 1000
		);
	} catch ( e ) {
		return false;
	}
}

/**
 * Sets the storage lock.
 *
 * @param {number} currentTime - Current time in milliseconds.
 */
function setStorageLock( currentTime ) {
	try {
		sessionStorage.setItem(
			storageLockTimeSessionKey,
			String( currentTime )
		);
	} catch ( e ) {}
}

/**
 * Creates a logger object with log, warn, and error methods.
 *
 * @param {boolean} [debugMode=false]      - Whether all messages should be logged. If false, then only errors are logged.
 * @param {?string} [prefix=null]          - Prefix to prepend to the console message.
 * @param {?string} [scriptModuleUrl=null] - The URL for the script module which is emitting the log. This is used for extensions.
 * @return {Logger} Logger object with log, info, warn, and error methods.
 */
function createLogger(
	debugMode = false,
	prefix = null,
	scriptModuleUrl = null
) {
	const logSource = scriptModuleUrl ? `\nSource: ${ scriptModuleUrl }` : null;

	/**
	 * Constructs the args to pass to the logging function.
	 *
	 * @param {Array}   message       - The message(s) to log.
	 * @param {boolean} includeSource - Whether to include the source. This should be true for warnings or errors.
	 * @return {Array} Amended message.
	 */
	const constructLogArgs = ( message, includeSource = false ) => {
		return [ prefix, ...message, includeSource ? logSource : null ].filter(
			( value ) => value !== null
		);
	};

	return {
		/**
		 * Logs a message if debug mode is enabled.
		 *
		 * @param {...*} message - The message(s) to log.
		 */
		log( ...message ) {
			if ( debugMode ) {
				// eslint-disable-next-line no-console
				console.log( ...constructLogArgs( message, false ) );
			}
		},

		/**
		 * Logs an informational message if debug mode is enabled.
		 *
		 * @param {...*} message - The message(s) to log as info.
		 */
		info( ...message ) {
			if ( debugMode ) {
				// eslint-disable-next-line no-console
				console.info( ...constructLogArgs( message, false ) );
			}
		},

		/**
		 * Logs a warning if debug mode is enabled.
		 *
		 * @param {...*} message - The message(s) to log as a warning.
		 */
		warn( ...message ) {
			if ( debugMode ) {
				// eslint-disable-next-line no-console
				console.warn( ...constructLogArgs( message, true ) );
			}
		},

		/**
		 * Logs an error.
		 *
		 * @param {...*} message - The message(s) to log as an error.
		 */
		error( ...message ) {
			// eslint-disable-next-line no-console
			console.error( ...constructLogArgs( message, true ) );
		},
	};
}

/**
 * Attempts to get the extension name (i.e. slug for plugin or theme) from the script module URL.
 *
 * If extraction of the slug fails, then the entire URL is returned.
 *
 * @param {string} scriptModuleUrl - Script module URL.
 * @return {string} Derived extension name.
 */
function getExtensionNameFromScriptModuleUrl( scriptModuleUrl ) {
	try {
		const url = new URL( scriptModuleUrl, win.location.href );
		const matches = url.pathname.match(
			/\/(?:themes|plugins)\/([^\/]+)\//
		);
		if ( matches ) {
			return matches[ 1 ];
		}
		return url.pathname;
	} catch ( err ) {
		return scriptModuleUrl;
	}
}

/**
 * Gets the status for the URL Metric group for the provided viewport width.
 *
 * The comparison logic here corresponds with the PHP logic in `OD_URL_Metric_Group::is_viewport_width_in_range()`.
 * This function is also similar to the PHP logic in `\OD_URL_Metric_Group_Collection::get_group_for_viewport_width()`.
 *
 * @param {number}                 viewportWidth          - Current viewport width.
 * @param {URLMetricGroupStatus[]} urlMetricGroupStatuses - Viewport group statuses.
 * @return {URLMetricGroupStatus} The URL metric group for the viewport width.
 */
function getGroupForViewportWidth( viewportWidth, urlMetricGroupStatuses ) {
	for ( const urlMetricGroupStatus of urlMetricGroupStatuses ) {
		if (
			viewportWidth > urlMetricGroupStatus.minimumViewportWidth &&
			( null === urlMetricGroupStatus.maximumViewportWidth ||
				viewportWidth <= urlMetricGroupStatus.maximumViewportWidth )
		) {
			return urlMetricGroupStatus;
		}
	}
	throw new Error(
		`${ consoleLogPrefix } Unexpectedly unable to locate group for the current viewport width.`
	);
}

/**
 * Gets the sessionStorage key for keeping track of whether the current client session already submitted a URL Metric.
 *
 * @param {string}               currentETag          - Current ETag.
 * @param {string}               currentUrl           - Current URL.
 * @param {URLMetricGroupStatus} urlMetricGroupStatus - URL Metric group status.
 * @param {Logger}               logger               - Logger.
 * @return {Promise<string|null>} Session storage key for the current URL or null if crypto is not available or caused an error.
 */
async function getAlreadySubmittedSessionStorageKey(
	currentETag,
	currentUrl,
	urlMetricGroupStatus,
	{ warn, error }
) {
	if ( ! win.crypto || ! win.crypto.subtle ) {
		warn(
			'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.'
		);
		return null;
	}

	try {
		const message = [
			currentETag,
			currentUrl,
			urlMetricGroupStatus.minimumViewportWidth,
			urlMetricGroupStatus.maximumViewportWidth || '',
		].join( '-' );

		/*
		 * Note that the components are hashed for a couple of reasons:
		 *
		 * 1. It results in a consistent length string devoid of any special characters that could cause problems.
		 * 2. Since the key includes the URL, hashing it avoids potential privacy concerns where the sessionStorage is
		 *    examined to see which URLs the client went to.
		 *
		 * The SHA-1 algorithm is chosen since it is the fastest and there is no need for cryptographic security.
		 */
		const msgBuffer = new TextEncoder().encode( message );
		const hashBuffer = await crypto.subtle.digest( 'SHA-1', msgBuffer );
		const hashHex = Array.from( new Uint8Array( hashBuffer ) )
			.map( ( b ) => b.toString( 16 ).padStart( 2, '0' ) )
			.join( '' );
		return `odSubmitted-${ hashHex }`;
	} catch ( err ) {
		error(
			'Unable to generate sessionStorage key for already-submitted URL due to error:',
			err
		);
		return null;
	}
}

/**
 * Gets the current time in milliseconds.
 *
 * @return {number} Current time in milliseconds.
 */
function getCurrentTime() {
	return Date.now();
}

/**
 * Recursively freezes an object to prevent mutation.
 *
 * @param {Object} obj - Object to recursively freeze.
 */
function recursiveFreeze( obj ) {
	for ( const prop of Object.getOwnPropertyNames( obj ) ) {
		const value = obj[ prop ];
		if ( null !== value && typeof value === 'object' ) {
			recursiveFreeze( value );
		}
	}
	Object.freeze( obj );
}

/**
 * URL Metric being assembled for submission.
 *
 * @type {URLMetric}
 */
let urlMetric;

/**
 * Reserved root property keys.
 *
 * @see {URLMetric}
 * @see {ExtendedElementData}
 * @type {Set<string>}
 */
const reservedRootPropertyKeys = new Set( [ 'url', 'viewport', 'elements' ] );

/**
 * Gets root URL Metric data.
 *
 * @type {GetRootDataFunction}
 * @return {URLMetric} URL Metric.
 */
function getRootData() {
	const immutableUrlMetric = structuredClone( urlMetric );
	recursiveFreeze( immutableUrlMetric );
	return immutableUrlMetric;
}

/**
 * Extends root URL Metric data.
 *
 * @type {ExtendRootDataFunction}
 * @param {ExtendedRootData} properties
 */
function extendRootData( properties ) {
	for ( const key of Object.getOwnPropertyNames( properties ) ) {
		if ( reservedRootPropertyKeys.has( key ) ) {
			throw new Error( `Disallowed setting of key '${ key }' on root.` );
		}
	}
	Object.assign( urlMetric, properties );
	debounceCompressUrlMetric();
}

/**
 * Mapping of XPath to element data.
 *
 * @type {Map<string, ElementData>}
 */
const elementsByXPath = new Map();

/**
 * Reserved element property keys.
 *
 * @see {ElementData}
 * @see {ExtendedRootData}
 * @type {Set<string>}
 */
const reservedElementPropertyKeys = new Set( [
	'isLCP',
	'isLCPCandidate',
	'xpath',
	'intersectionRatio',
	'intersectionRect',
	'boundingClientRect',
] );

/**
 * Gets element data.
 *
 * @type {GetElementDataFunction}
 * @param {string} xpath - XPath.
 * @return {ElementData|null} Element data, or null if no element for the XPath exists.
 */
function getElementData( xpath ) {
	const elementData = elementsByXPath.get( xpath );
	if ( elementData ) {
		const cloned = structuredClone( elementData );
		recursiveFreeze( cloned );
		return cloned;
	}
	return null;
}

/**
 * Extends element data.
 *
 * @type {ExtendElementDataFunction}
 * @param {string}              xpath      - XPath.
 * @param {ExtendedElementData} properties - Properties.
 */
function extendElementData( xpath, properties ) {
	if ( ! elementsByXPath.has( xpath ) ) {
		throw new Error( `Unknown element with XPath: ${ xpath }` );
	}
	for ( const key of Object.getOwnPropertyNames( properties ) ) {
		if ( reservedElementPropertyKeys.has( key ) ) {
			throw new Error(
				`Disallowed setting of key '${ key }' on element.`
			);
		}
	}
	const elementData = elementsByXPath.get( xpath );
	Object.assign( elementData, properties );
	debounceCompressUrlMetric();
}

/**
 * Compresses a JSON string using CompressionStream API.
 *
 * @param {string} jsonString - JSON string to compress.
 * @return {Promise<Blob>} Compressed data.
 */
async function compress( jsonString ) {
	const encodedData = new TextEncoder().encode( jsonString );
	const compressedDataStream = new Blob( [ encodedData ] )
		.stream()
		.pipeThrough( new CompressionStream( 'gzip' ) );
	const compressedDataBuffer = await new Response(
		compressedDataStream
	).arrayBuffer();
	return new Blob( [ compressedDataBuffer ], { type: 'application/gzip' } );
}

/**
 * The compressed URL metric data.
 *
 * @see {debounceCompressUrlMetric}
 * @type {?Blob}
 */
let compressedPayload = null;

/**
 * Timeout ID for debouncing URL metric compression.
 *
 * @see {debounceCompressUrlMetric}
 * @type {?ReturnType<typeof setTimeout>}
 */
let recompressionTimeout = null;

/**
 * Handle for requestIdleCallback for URL metric compression.
 *
 * @see {debounceCompressUrlMetric}
 * @type {?number}
 */
let idleCallbackHandle = null;

/**
 * Whether compression is enabled.
 *
 * @see {detect}
 * @see {debounceCompressUrlMetric}
 * @type {boolean}
 */
let compressionEnabled = true;

/**
 * Debounces the compression of the URL Metric.
 */
function debounceCompressUrlMetric() {
	if ( ! compressionEnabled ) {
		return;
	}
	if ( null !== recompressionTimeout ) {
		clearTimeout( recompressionTimeout );
		recompressionTimeout = null;
	}
	if (
		null !== idleCallbackHandle &&
		typeof cancelIdleCallback === 'function'
	) {
		cancelIdleCallback( idleCallbackHandle );
		idleCallbackHandle = null;
	}
	recompressionTimeout = setTimeout( async () => {
		if ( typeof requestIdleCallback === 'function' ) {
			await new Promise( ( resolve ) => {
				idleCallbackHandle = requestIdleCallback( resolve );
			} );
			idleCallbackHandle = null;
		}
		try {
			compressedPayload = await compress( JSON.stringify( urlMetric ) );
		} catch ( err ) {
			const { error } = createLogger( false, consoleLogPrefix );
			error(
				'Failed to compress URL Metric falling back to sending uncompressed data:',
				err
			);
			compressionEnabled = false;
		}
		recompressionTimeout = null;
	}, compressionDebounceWaitDuration );
}

/**
 * @typedef {{timestamp: number, creationDate: Date}} UrlMetricDebugData
 * @typedef {{groups: Array<{url_metrics: Array<UrlMetricDebugData>}>}} CollectionDebugData
 */

/**
 * Args for the detect function.
 *
 * @since 1.0.0
 *
 * @typedef {Object}                  DetectFunctionArgs
 * @property {string[]}               extensionModuleUrls        - URLs for extension script modules to import.
 * @property {number}                 minViewportAspectRatio     - Minimum aspect ratio allowed for the viewport.
 * @property {number}                 maxViewportAspectRatio     - Maximum aspect ratio allowed for the viewport.
 * @property {boolean}                isDebug                    - Whether to show debug messages.
 * @property {string}                 restApiEndpoint            - URL for where to send the detection data.
 * @property {string}                 [restApiNonce]             - Nonce for the REST API when the user is logged-in.
 * @property {boolean}                gzdecodeAvailable          - Whether application/gzip can be sent to the REST API.
 * @property {number}                 maxUrlMetricSize           - Maximum size of the URL Metric to send.
 * @property {string}                 currentETag                - Current ETag.
 * @property {string}                 currentUrl                 - Current URL.
 * @property {string}                 urlMetricSlug              - Slug for URL Metric.
 * @property {number|null}            cachePurgePostId           - Cache purge post ID.
 * @property {string}                 urlMetricHMAC              - HMAC for URL Metric storage.
 * @property {URLMetricGroupStatus[]} urlMetricGroupStatuses     - URL Metric group statuses.
 * @property {number}                 storageLockTTL             - The TTL (in seconds) for the URL Metric storage lock.
 * @property {number}                 freshnessTTL               - The freshness age (TTL) for a given URL Metric.
 * @property {string}                 webVitalsLibrarySrc        - The URL for the web-vitals library.
 * @property {CollectionDebugData}    [urlMetricGroupCollection] - URL Metric group collection, when in debug mode.
 */

/**
 * The detect function.
 *
 * @since 1.0.0
 * @callback DetectFunction
 * @param {DetectFunctionArgs} args - The arguments for the function.
 * @return {Promise<void>}
 */

/**
 * Detects the LCP element, loaded images, client viewport, and store for future optimizations.
 *
 * @type {DetectFunction}
 * @param {DetectFunctionArgs} args - Args.
 */
export default async function detect( {
	minViewportAspectRatio,
	maxViewportAspectRatio,
	isDebug,
	extensionModuleUrls,
	restApiEndpoint,
	restApiNonce,
	gzdecodeAvailable,
	maxUrlMetricSize,
	currentETag,
	currentUrl,
	urlMetricSlug,
	cachePurgePostId,
	urlMetricHMAC,
	urlMetricGroupStatuses,
	storageLockTTL,
	freshnessTTL,
	webVitalsLibrarySrc,
	urlMetricGroupCollection,
} ) {
	const logger = createLogger( isDebug, consoleLogPrefix );
	const { log, warn, error } = logger;
	compressionEnabled = gzdecodeAvailable;

	if ( isDebug && Array.isArray( urlMetricGroupCollection?.groups ) ) {
		const allUrlMetrics = /** @type Array<UrlMetricDebugData> */ [];
		for ( const group of urlMetricGroupCollection.groups ) {
			for ( const otherUrlMetric of group.url_metrics ) {
				otherUrlMetric.creationDate = new Date(
					otherUrlMetric.timestamp * 1000
				);
				allUrlMetrics.push( otherUrlMetric );
			}
		}
		log( 'Stored URL Metric Group Collection:', urlMetricGroupCollection );
		allUrlMetrics.sort( ( a, b ) => b.timestamp - a.timestamp );
		log(
			'Stored URL Metrics in reverse chronological order:',
			allUrlMetrics
		);
	}

	if ( win.innerWidth === 0 || win.innerHeight === 0 ) {
		log(
			'Window must have non-zero dimensions for URL Metric collection.'
		);
		return;
	}

	if ( doc.visibilityState === 'hidden' && ! doc.prerendering ) {
		log( 'Page opened in background tab so URL Metric is not collected.' );
		return;
	}

	// Abort if the current viewport is not among those which need URL Metrics.
	const urlMetricGroupStatus = getGroupForViewportWidth(
		win.innerWidth,
		urlMetricGroupStatuses
	);
	if ( urlMetricGroupStatus.complete ) {
		log( 'No need for URL Metrics from the current viewport.' );
		return;
	}

	// Abort if the client already submitted a URL Metric for this URL and viewport group.
	const alreadySubmittedSessionStorageKey =
		await getAlreadySubmittedSessionStorageKey(
			currentETag,
			currentUrl,
			urlMetricGroupStatus,
			logger
		);
	if (
		null !== alreadySubmittedSessionStorageKey &&
		alreadySubmittedSessionStorageKey in sessionStorage
	) {
		const previousVisitTime = parseInt(
			sessionStorage.getItem( alreadySubmittedSessionStorageKey ),
			10
		);
		if (
			! isNaN( previousVisitTime ) &&
			( freshnessTTL < 0 ||
				( getCurrentTime() - previousVisitTime ) / 1000 < freshnessTTL )
		) {
			log(
				'The current client session already submitted a fresh URL Metric for this URL so a new one will not be collected now.'
			);
			return;
		}
	}

	// Abort if the viewport aspect ratio is not in a common range.
	const aspectRatio = win.innerWidth / win.innerHeight;
	if (
		aspectRatio < minViewportAspectRatio ||
		aspectRatio > maxViewportAspectRatio
	) {
		warn(
			`Viewport aspect ratio (${ aspectRatio }) is not in the accepted range of ${ minViewportAspectRatio } to ${ maxViewportAspectRatio }.`
		);
		return;
	}

	// TODO: Does this make sense here? Should it be moved up above the isViewportNeeded condition?
	// As an alternative to this, the od_print_detection_script() function can short-circuit if the
	// od_is_url_metric_storage_locked() function returns true. However, the downside with that is page caching could
	// result in metrics missed from being gathered when a user navigates around a site and primes the page cache.
	if ( isStorageLocked( getCurrentTime(), storageLockTTL ) ) {
		warn( 'Aborted detection due to storage being locked.' );
		return;
	}

	// Keep track of whether the window resized. If it was resized, we abort sending the URLMetric.
	let didWindowResize = false;
	win.addEventListener(
		'resize',
		() => {
			didWindowResize = true;
		},
		{ once: true }
	);

	const {
		/** @type {OnTTFBFunction|OnTTFBWithAttributionFunction} */ onTTFB,
		/** @type {OnFCPFunction|OnFCPWithAttributionFunction} */ onFCP,
		/** @type {OnLCPFunction|OnLCPWithAttributionFunction} */ onLCP,
		/** @type {OnINPFunction|OnINPWithAttributionFunction} */ onINP,
		/** @type {OnCLSFunction|OnCLSWithAttributionFunction} */ onCLS,
	} = await import( webVitalsLibrarySrc );

	// TODO: Does this make sense here?
	// Prevent detection when page is not scrolled to the initial viewport.
	if ( doc.documentElement.scrollTop > 0 ) {
		warn(
			'Aborted detection since initial scroll position of page is not at the top.'
		);
		return;
	}

	log( 'Proceeding with detection' );

	const breadcrumbedElements = doc.body.querySelectorAll( '[data-od-xpath]' );

	/** @type {Map<Element, string>} */
	const breadcrumbedElementsMap = new Map(
		[ ...breadcrumbedElements ].map(
			/**
			 * @param {Element} element
			 * @return {[Element, string]} Tuple of an element and its XPath.
			 */
			( element ) => [ element, element.getAttribute( 'data-od-xpath' ) ]
		)
	);

	/** @type {IntersectionObserverEntry[]} */
	const elementIntersections = [];

	/** @type {?IntersectionObserver} */
	let intersectionObserver;

	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 elements.
	// Note that the first callback will include _all_ observed entries per <https://github.com/w3c/IntersectionObserver/issues/476>.
	if ( breadcrumbedElementsMap.size > 0 ) {
		await new Promise( ( resolve ) => {
			intersectionObserver = new IntersectionObserver(
				( entries ) => {
					for ( const entry of entries ) {
						elementIntersections.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 element of breadcrumbedElementsMap.keys() ) {
				intersectionObserver.observe( element );
			}
		} );

		// Stop observing as soon as the page scrolls since we only want initial-viewport elements.
		win.addEventListener( 'scroll', disconnectIntersectionObserver, {
			once: true,
			passive: true,
		} );
	}

	/** @type {(LCPMetric|LCPMetricWithAttribution)[]} */
	const lcpMetricCandidates = [];

	// Get at least one LCP candidate. More may be reported before the page finishes loading.
	await new Promise( ( resolve ) => {
		onLCP(
			/**
			 * Handles an LCP metric being reported.
			 *
			 * @param {LCPMetric|LCPMetricWithAttribution} metric
			 */
			( metric ) => {
				lcpMetricCandidates.push( metric );
				resolve();
			},
			{
				// This avoids needing to click to finalize the 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 <https://github.com/GoogleChrome/web-vitals/blob/07f6f96/src/onLCP.ts#L99-L107>.
				reportAllChanges: true,
			}
		);
	} );

	// Stop observing the initial viewport.
	disconnectIntersectionObserver();

	urlMetric = {
		url: currentUrl,
		viewport: {
			width: win.innerWidth,
			height: win.innerHeight,
		},
		elements: [],
	};

	const lcpMetric = lcpMetricCandidates[ lcpMetricCandidates.length - 1 ];

	// Populate the elements in the URL Metric.
	for ( const elementIntersection of elementIntersections ) {
		const xpath = breadcrumbedElementsMap.get( elementIntersection.target );
		if ( ! xpath ) {
			warn( 'Unable to look up XPath for element' );
			continue;
		}

		const element = /** @type {Element|null} */ (
			lcpMetric?.entries[ 0 ]?.element
		);
		const isLCP = elementIntersection.target === element;

		/** @type {ElementData} */
		const elementData = {
			isLCP,
			isLCPCandidate: !! lcpMetricCandidates.find(
				( lcpMetricCandidate ) => {
					const candidateElement = /** @type {Element|null} */ (
						lcpMetricCandidate.entries[ 0 ]?.element
					);
					return candidateElement === elementIntersection.target;
				}
			),
			xpath,
			intersectionRatio: elementIntersection.intersectionRatio,
			intersectionRect: elementIntersection.intersectionRect,
			boundingClientRect: elementIntersection.boundingClientRect,
		};

		urlMetric.elements.push( elementData );
		elementsByXPath.set( elementData.xpath, elementData );
	}
	breadcrumbedElementsMap.clear(); // No longer needed.

	/**
	 * Initialize extensions.
	 */

	/** @type {Map<string, Extension>} */
	const extensions = new Map();

	/** @type {boolean} */
	let extensionHasFinalize = false;

	/** @type {Promise[]} */
	const extensionInitializePromises = [];

	/** @type {string[]} */
	const initializingExtensionModuleUrls = [];

	// Load all extensions in parallel.
	await Promise.all(
		extensionModuleUrls.map( async ( extensionModuleUrl ) => {
			const extension = /** @type {Extension} */ await import(
				extensionModuleUrl
			);
			extensions.set( extensionModuleUrl, extension );
		} )
	);

	// Initialize extensions.
	for ( const [ extensionModuleUrl, extension ] of extensions.entries() ) {
		try {
			const extensionLogger = createLogger(
				isDebug,
				`[Optimization Detective: ${
					extension.name ||
					getExtensionNameFromScriptModuleUrl( extensionModuleUrl )
				}]`,
				extensionModuleUrl
			);

			// TODO: There should to be a way to pass additional args into the module. Perhaps extensionModuleUrls should be a mapping of URLs to args.
			if ( extension.initialize instanceof Function ) {
				const initializePromise = extension.initialize( {
					isDebug,
					...extensionLogger,
					onTTFB,
					onFCP,
					onLCP,
					onINP,
					onCLS,
					getRootData,
					extendRootData,
					getElementData,
					extendElementData,
				} );
				if ( initializePromise instanceof Promise ) {
					extensionInitializePromises.push( initializePromise );
					initializingExtensionModuleUrls.push( extensionModuleUrl );
				}
			}

			if ( extension.finalize instanceof Function ) {
				extensionLogger.warn(
					'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.'
				);
				extensionHasFinalize = true;
			}
		} catch ( err ) {
			error(
				`Failed to start initializing extension '${ extensionModuleUrl }':`,
				err
			);
		}
	}

	// Wait for all extensions to finish initializing.
	const settledInitializePromises = await Promise.allSettled(
		extensionInitializePromises
	);
	for ( const [
		i,
		settledInitializePromise,
	] of settledInitializePromises.entries() ) {
		if ( settledInitializePromise.status === 'rejected' ) {
			error(
				`Failed to initialize extension '${ initializingExtensionModuleUrls[ i ] }':`,
				settledInitializePromise.reason
			);
		}
	}

	if ( compressionEnabled && extensionHasFinalize ) {
		compressionEnabled = false;
		warn(
			'URL Metric compression is disabled because one or more extensions use the deprecated finalize function.'
		);
	}

	log( 'Current URL Metric:', urlMetric );

	// Compress the URL Metric once so that even if there are no extensions available or extending the URL Metric, it is compressed.
	debounceCompressUrlMetric();

	// Wait for the page to be hidden.
	await new Promise( ( resolve ) => {
		win.addEventListener( 'pagehide', resolve, { once: true } );
		win.addEventListener( 'pageswap', resolve, { once: true } );
		doc.addEventListener(
			'visibilitychange',
			() => {
				if ( doc.visibilityState === 'hidden' ) {
					// TODO: This will fire even when switching tabs.
					resolve();
				}
			},
			{ once: true }
		);
	} );

	// Only proceed with submitting the URL Metric if the viewport stayed the same size. Changing the viewport size (e.g. due
	// to resizing a window or changing the orientation of a device) will result in unexpected metrics being collected.
	if ( didWindowResize ) {
		log( 'Aborting URL Metric collection due to viewport size change.' );
		return;
	}

	// Finalize extensions.
	if ( extensions.size > 0 ) {
		/** @type {Promise[]} */
		const extensionFinalizePromises = [];

		/** @type {string[]} */
		const finalizingExtensionModuleUrls = [];

		for ( const [
			extensionModuleUrl,
			extension,
		] of extensions.entries() ) {
			if ( extension.finalize instanceof Function ) {
				const extensionLogger = createLogger(
					isDebug,
					`[Optimization Detective: ${
						extension.name ||
						getExtensionNameFromScriptModuleUrl(
							extensionModuleUrl
						)
					}]`,
					extensionModuleUrl
				);

				try {
					const finalizePromise = extension.finalize( {
						isDebug,
						...extensionLogger,
						getRootData,
						getElementData,
						extendElementData,
						extendRootData,
					} );
					if ( finalizePromise instanceof Promise ) {
						extensionFinalizePromises.push( finalizePromise );
						finalizingExtensionModuleUrls.push(
							extensionModuleUrl
						);
					}
				} catch ( err ) {
					error(
						`Unable to start finalizing extension '${ extensionModuleUrl }':`,
						err
					);
				}
			}
		}

		// Wait for all extensions to finish finalizing.
		const settledFinalizePromises = await Promise.allSettled(
			extensionFinalizePromises
		);
		for ( const [
			i,
			settledFinalizePromise,
		] of settledFinalizePromises.entries() ) {
			if ( settledFinalizePromise.status === 'rejected' ) {
				error(
					`Failed to finalize extension '${ finalizingExtensionModuleUrls[ i ] }':`,
					settledFinalizePromise.reason
				);
			}
		}
	}

	/*
	 * Now prepare the URL Metric to be sent in the JSON request body.
	 */

	const maxBodyLengthKiB = 64;
	const maxBodyLengthBytes = maxBodyLengthKiB * 1024;

	const jsonBody = JSON.stringify( urlMetric );
	if ( jsonBody.length > maxUrlMetricSize ) {
		error(
			`URL Metric is ${ jsonBody.length.toLocaleString() } bytes, exceeding the maximum size of ${ maxUrlMetricSize.toLocaleString() } bytes:`,
			urlMetric
		);
		return;
	}
	compressionEnabled = compressionEnabled && null !== compressedPayload;
	const payloadBlob = compressionEnabled
		? compressedPayload
		: new Blob( [ jsonBody ], { type: 'application/json' } );
	const percentOfBudget =
		( payloadBlob.size / ( maxBodyLengthKiB * 1000 ) ) * 100;

	/*
	 * According to the fetch() spec:
	 * "If the sum of contentLength and inflightKeepaliveBytes is greater than 64 kibibytes, then return a network error."
	 * This is what browsers also implement for navigator.sendBeacon(). Therefore, if the size of the JSON is greater
	 * than the maximum, we should avoid even trying to send it.
	 */
	if ( payloadBlob.size > maxBodyLengthBytes ) {
		error(
			`Unable to send URL Metric because it is ${ payloadBlob.size.toLocaleString() } bytes, ${ Math.round(
				percentOfBudget
			) }% of ${ maxBodyLengthKiB } KiB limit:`,
			urlMetric
		);
		return;
	}

	// Even though the server may reject the REST API request, we still have to set the storage lock
	// because we can't look at the response when sending a beacon.
	setStorageLock( getCurrentTime() );

	// Remember that the URL Metric was submitted for this URL to avoid having multiple entries submitted by the same client.
	if ( null !== alreadySubmittedSessionStorageKey ) {
		sessionStorage.setItem(
			alreadySubmittedSessionStorageKey,
			String( getCurrentTime() )
		);
	}

	let message = 'Sending URL Metric (';
	message += `${ payloadBlob.size.toLocaleString() } bytes`;
	message += `, ${ Math.round(
		percentOfBudget
	) }% of ${ maxBodyLengthKiB } KiB limit`;
	if ( compressionEnabled ) {
		message += `, gzip compressed -${ Math.round(
			( ( jsonBody.length - payloadBlob.size ) / jsonBody.length ) * 100
		) }%`;
	} else {
		message += ', uncompressed';
	}
	message += '):';

	// The threshold of 50% is used because the limit for all beacons combined is 64 KiB, not just the data for one beacon.
	if ( percentOfBudget < 50 ) {
		log( message, urlMetric );
	} else {
		warn( message, urlMetric );
	}

	const url = new URL( restApiEndpoint );
	if ( typeof restApiNonce === 'string' ) {
		url.searchParams.set( '_wpnonce', restApiNonce );
	}
	url.searchParams.set( 'slug', urlMetricSlug );
	url.searchParams.set( 'current_etag', currentETag );
	if ( typeof cachePurgePostId === 'number' ) {
		url.searchParams.set(
			'cache_purge_post_id',
			cachePurgePostId.toString()
		);
	}
	url.searchParams.set( 'hmac', urlMetricHMAC );

	const headers = {
		'Content-Type': 'application/json',
	};
	if ( compressionEnabled ) {
		headers[ 'Content-Encoding' ] = 'gzip';
	}

	const request = new Request( url, {
		method: 'POST',
		body: payloadBlob,
		headers,
		keepalive: true, // This makes fetch() behave the same as navigator.sendBeacon().
	} );
	await fetch( request );
}