From 06df5a74eb28285c51a9156516c0337ed56cd6be Mon Sep 17 00:00:00 2001 From: Philip Walton Date: Fri, 10 Jan 2025 13:35:37 -0800 Subject: [PATCH] Support multiple calls to `onINP()` with different config options (#583) * Move the interactions logic into a reusable class * Rename the interactions.ts file to match symbol * Add basic test for INP * Add tests for LCP * Add tests for CLS * Add tests for FCP * Add tests for TTFB * Add stricter comparison tests * Switch from $ to _ --- rollup.config.js | 10 +- src/attribution/onINP.ts | 522 +++++++++++++++++----------------- src/lib/InteractionManager.ts | 149 ++++++++++ src/lib/initUnique.ts | 29 ++ src/lib/interactions.ts | 145 ---------- src/onINP.ts | 30 +- test/e2e/onCLS-test.js | 88 ++++++ test/e2e/onFCP-test.js | 29 ++ test/e2e/onINP-test.js | 80 +++++- test/e2e/onLCP-test.js | 25 ++ test/e2e/onTTFB-test.js | 30 ++ test/utils/beacons.js | 38 ++- test/views/cls.njk | 20 +- test/views/fcp.njk | 20 +- test/views/inp.njk | 17 ++ test/views/layout.njk | 32 ++- test/views/lcp.njk | 20 +- test/views/ttfb.njk | 26 +- 18 files changed, 864 insertions(+), 446 deletions(-) create mode 100644 src/lib/InteractionManager.ts create mode 100644 src/lib/initUnique.ts delete mode 100644 src/lib/interactions.ts diff --git a/rollup.config.js b/rollup.config.js index 9ce0b619..c3164b41 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -42,8 +42,16 @@ const configurePlugins = ({module}) => { }), terser({ module, - mangle: true, compress: true, + mangle: { + properties: { + // Any object properties beginning with the '_' character will be + // mangled. Use this prefix for any object properties that are not + // part of the public API and do that not match an existing build-in + // API names (e.g. `.id` or `.entries`). + regex: /^_/, + }, + }, }), ]; }; diff --git a/src/attribution/onINP.ts b/src/attribution/onINP.ts index 1a77e6e6..8ef8c7c6 100644 --- a/src/attribution/onINP.ts +++ b/src/attribution/onINP.ts @@ -16,11 +16,8 @@ import {getLoadState} from '../lib/getLoadState.js'; import {getSelector} from '../lib/getSelector.js'; -import { - longestInteractionList, - entryPreProcessingCallbacks, - longestInteractionMap, -} from '../lib/interactions.js'; +import {initUnique} from '../lib/initUnique.js'; +import {InteractionManager} from '../lib/InteractionManager.js'; import {observe} from '../lib/observe.js'; import {whenIdleOrHidden} from '../lib/whenIdleOrHidden.js'; import {onINP as unattributedOnINP} from '../onINP.js'; @@ -48,253 +45,6 @@ interface pendingEntriesGroup { // keeping the most recent 50 should be more than sufficient. const MAX_PREVIOUS_FRAMES = 50; -// A PerformanceObserver, observing new `long-animation-frame` entries. -// If this variable is defined it means the browser supports LoAF. -let loafObserver: PerformanceObserver | undefined; - -// A list of LoAF entries that have been dispatched and could potentially -// intersect with the INP candidate interaction. Note that periodically this -// list is cleaned up and entries that are known to not match INP are removed. -let pendingLoAFs: PerformanceLongAnimationFrameTiming[] = []; - -// An array of groups of all the event timing entries that occurred within a -// particular frame. Note that periodically this array is cleaned up and entries -// that are known to not match INP are removed. -let pendingEntriesGroups: pendingEntriesGroup[] = []; - -// The `processingEnd` time of most recently-processed event, chronologically. -let latestProcessingEnd: number = 0; - -// A WeakMap to look up the event-timing-entries group of a given entry. -// Note that this only maps from "important" entries: either the first input or -// those with an `interactionId`. -const entryToEntriesGroupMap: WeakMap< - PerformanceEventTiming, - pendingEntriesGroup -> = new WeakMap(); - -// A mapping of interactionIds to the target Node. -export const interactionTargetMap: Map = new Map(); - -// A boolean flag indicating whether or not a cleanup task has been queued. -let cleanupPending = false; - -/** - * Adds new LoAF entries to the `pendingLoAFs` list. - */ -const handleLoAFEntries = (entries: PerformanceLongAnimationFrameTiming[]) => { - pendingLoAFs = pendingLoAFs.concat(entries); - queueCleanup(); -}; - -// Get a reference to the interaction target element in case it's removed -// from the DOM later. -const saveInteractionTarget = (entry: PerformanceEventTiming) => { - if ( - entry.interactionId && - entry.target && - !interactionTargetMap.has(entry.interactionId) - ) { - interactionTargetMap.set(entry.interactionId, entry.target); - } -}; - -/** - * Groups entries that were presented within the same animation frame by - * a common `renderTime`. This function works by referencing - * `pendingEntriesGroups` and using an existing render time if one is found - * (otherwise creating a new one). This function also adds all interaction - * entries to an `entryToRenderTimeMap` WeakMap so that the "grouped" entries - * can be looked up later. - */ -const groupEntriesByRenderTime = (entry: PerformanceEventTiming) => { - const renderTime = entry.startTime + entry.duration; - let group; - - latestProcessingEnd = Math.max(latestProcessingEnd, entry.processingEnd); - - // Iterate over all previous render times in reverse order to find a match. - // Go in reverse since the most likely match will be at the end. - for (let i = pendingEntriesGroups.length - 1; i >= 0; i--) { - const potentialGroup = pendingEntriesGroups[i]; - - // If a group's render time is within 8ms of the entry's render time, - // assume they were part of the same frame and add it to the group. - if (Math.abs(renderTime - potentialGroup.renderTime) <= 8) { - group = potentialGroup; - group.startTime = Math.min(entry.startTime, group.startTime); - group.processingStart = Math.min( - entry.processingStart, - group.processingStart, - ); - group.processingEnd = Math.max(entry.processingEnd, group.processingEnd); - group.entries.push(entry); - - break; - } - } - - // If there was no matching group, assume this is a new frame. - if (!group) { - group = { - startTime: entry.startTime, - processingStart: entry.processingStart, - processingEnd: entry.processingEnd, - renderTime, - entries: [entry], - }; - - pendingEntriesGroups.push(group); - } - - // Store the grouped render time for this entry for reference later. - if (entry.interactionId || entry.entryType === 'first-input') { - entryToEntriesGroupMap.set(entry, group); - } - - queueCleanup(); -}; - -const queueCleanup = () => { - // Queue cleanup of entries that are not part of any INP candidates. - if (!cleanupPending) { - whenIdleOrHidden(cleanupEntries); - cleanupPending = true; - } -}; - -const cleanupEntries = () => { - // Delete any stored interaction target elements if they're not part of one - // of the 10 longest interactions. - if (interactionTargetMap.size > 10) { - for (const [key] of interactionTargetMap) { - if (!longestInteractionMap.has(key)) { - interactionTargetMap.delete(key); - } - } - } - - // Keep all render times that are part of a pending INP candidate or - // that occurred within the 50 most recently-dispatched groups of events. - const longestInteractionGroups = longestInteractionList.map((i) => { - return entryToEntriesGroupMap.get(i.entries[0]); - }); - const minIndex = pendingEntriesGroups.length - MAX_PREVIOUS_FRAMES; - pendingEntriesGroups = pendingEntriesGroups.filter((group, index) => { - if (index >= minIndex) return true; - return longestInteractionGroups.includes(group); - }); - - // Keep all pending LoAF entries that either: - // 1) intersect with entries in the newly cleaned up `pendingEntriesGroups` - // 2) occur after the most recently-processed event entry (for up to MAX_PREVIOUS_FRAMES) - const loafsToKeep: Set = new Set(); - for (const group of pendingEntriesGroups) { - const loafs = getIntersectingLoAFs(group.startTime, group.processingEnd); - for (const loaf of loafs) { - loafsToKeep.add(loaf); - } - } - const prevFrameIndexCutoff = pendingLoAFs.length - 1 - MAX_PREVIOUS_FRAMES; - // Filter `pendingLoAFs` to preserve LoAF order. - pendingLoAFs = pendingLoAFs.filter((loaf, index) => { - if (loaf.startTime > latestProcessingEnd && index > prevFrameIndexCutoff) { - return true; - } - - return loafsToKeep.has(loaf); - }); - - cleanupPending = false; -}; - -entryPreProcessingCallbacks.push( - saveInteractionTarget, - groupEntriesByRenderTime, -); - -const getIntersectingLoAFs = ( - start: DOMHighResTimeStamp, - end: DOMHighResTimeStamp, -) => { - const intersectingLoAFs: PerformanceLongAnimationFrameTiming[] = []; - - for (const loaf of pendingLoAFs) { - // If the LoAF ends before the given start time, ignore it. - if (loaf.startTime + loaf.duration < start) continue; - - // If the LoAF starts after the given end time, ignore it and all - // subsequent pending LoAFs (because they're in time order). - if (loaf.startTime > end) break; - - // Still here? If so this LoAF intersects with the interaction. - intersectingLoAFs.push(loaf); - } - return intersectingLoAFs; -}; - -const attributeINP = (metric: INPMetric): INPMetricWithAttribution => { - const firstEntry = metric.entries[0]; - const group = entryToEntriesGroupMap.get(firstEntry)!; - - const processingStart = firstEntry.processingStart; - - // Due to the fact that durations can be rounded down to the nearest 8ms, - // we have to clamp `nextPaintTime` so it doesn't appear to occur before - // processing starts. Note: we can't use `processingEnd` since processing - // can extend beyond the event duration in some cases (see next comment). - const nextPaintTime = Math.max( - firstEntry.startTime + firstEntry.duration, - processingStart, - ); - - // For the purposes of attribution, clamp `processingEnd` to `nextPaintTime`, - // so processing is never reported as taking longer than INP (which can - // happen via the web APIs in the case of sync modals, e.g. `alert()`). - // See: https://github.com/GoogleChrome/web-vitals/issues/492 - const processingEnd = Math.min(group.processingEnd, nextPaintTime); - - // Sort the entries in processing time order. - const processedEventEntries = group.entries.sort((a, b) => { - return a.processingStart - b.processingStart; - }); - - const longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[] = - getIntersectingLoAFs(firstEntry.startTime, processingEnd); - - // The first interaction entry may not have a target defined, so use the - // first one found in the entry list. - // TODO: when the following bug is fixed just use `firstInteractionEntry`. - // https://bugs.chromium.org/p/chromium/issues/detail?id=1367329 - // As a fallback, also check the interactionTargetMap (to account for - // cases where the element is removed from the DOM before reporting happens). - const firstEntryWithTarget = metric.entries.find((entry) => entry.target); - const interactionTargetElement = - firstEntryWithTarget?.target ?? - interactionTargetMap.get(firstEntry.interactionId); - - const attribution: INPAttribution = { - interactionTarget: getSelector(interactionTargetElement), - interactionTargetElement: interactionTargetElement, - interactionType: firstEntry.name.startsWith('key') ? 'keyboard' : 'pointer', - interactionTime: firstEntry.startTime, - nextPaintTime: nextPaintTime, - processedEventEntries: processedEventEntries, - longAnimationFrameEntries: longAnimationFrameEntries, - inputDelay: processingStart - firstEntry.startTime, - processingDuration: processingEnd - processingStart, - presentationDelay: nextPaintTime - processingEnd, - loadState: getLoadState(firstEntry.startTime), - }; - - // Use `Object.assign()` to ensure the original metric object is returned. - const metricWithAttribution: INPMetricWithAttribution = Object.assign( - metric, - {attribution}, - ); - return metricWithAttribution; -}; - /** * Calculates the [INP](https://web.dev/articles/inp) value for the current * page and calls the `callback` function once the value is ready, along with @@ -328,9 +78,271 @@ export const onINP = ( onReport: (metric: INPMetricWithAttribution) => void, opts: ReportOpts = {}, ) => { - if (!loafObserver) { - loafObserver = observe('long-animation-frame', handleLoAFEntries); - } + // Clone the opts object to ensure it's unique, so we can initialize a + // single instance of the `InteractionManager` class that's shared only with + // this function invocation and the `unattributedOnINP()` invocation below + // (which is passed the same `opts` object). + opts = Object.assign({}, opts); + + const interactionManager = initUnique(opts, InteractionManager); + + // A list of LoAF entries that have been dispatched and could potentially + // intersect with the INP candidate interaction. Note that periodically this + // list is cleaned up and entries that are known to not match INP are removed. + let pendingLoAFs: PerformanceLongAnimationFrameTiming[] = []; + + // An array of groups of all the event timing entries that occurred within a + // particular frame. Note that periodically this array is cleaned up and entries + // that are known to not match INP are removed. + let pendingEntriesGroups: pendingEntriesGroup[] = []; + + // The `processingEnd` time of most recently-processed event, chronologically. + let latestProcessingEnd: number = 0; + + // A WeakMap to look up the event-timing-entries group of a given entry. + // Note that this only maps from "important" entries: either the first input or + // those with an `interactionId`. + const entryToEntriesGroupMap: WeakMap< + PerformanceEventTiming, + pendingEntriesGroup + > = new WeakMap(); + + // A mapping of interactionIds to the target Node. + const interactionTargetMap: Map = new Map(); + + // A boolean flag indicating whether or not a cleanup task has been queued. + let cleanupPending = false; + + /** + * Adds new LoAF entries to the `pendingLoAFs` list. + */ + const handleLoAFEntries = ( + entries: PerformanceLongAnimationFrameTiming[], + ) => { + pendingLoAFs = pendingLoAFs.concat(entries); + queueCleanup(); + }; + + // Get a reference to the interaction target element in case it's removed + // from the DOM later. + const saveInteractionTarget = (entry: PerformanceEventTiming) => { + if ( + entry.interactionId && + entry.target && + !interactionTargetMap.has(entry.interactionId) + ) { + interactionTargetMap.set(entry.interactionId, entry.target); + } + }; + + /** + * Groups entries that were presented within the same animation frame by + * a common `renderTime`. This function works by referencing + * `pendingEntriesGroups` and using an existing render time if one is found + * (otherwise creating a new one). This function also adds all interaction + * entries to an `entryToRenderTimeMap` WeakMap so that the "grouped" entries + * can be looked up later. + */ + const groupEntriesByRenderTime = (entry: PerformanceEventTiming) => { + const renderTime = entry.startTime + entry.duration; + let group; + + latestProcessingEnd = Math.max(latestProcessingEnd, entry.processingEnd); + + // Iterate over all previous render times in reverse order to find a match. + // Go in reverse since the most likely match will be at the end. + for (let i = pendingEntriesGroups.length - 1; i >= 0; i--) { + const potentialGroup = pendingEntriesGroups[i]; + + // If a group's render time is within 8ms of the entry's render time, + // assume they were part of the same frame and add it to the group. + if (Math.abs(renderTime - potentialGroup.renderTime) <= 8) { + group = potentialGroup; + group.startTime = Math.min(entry.startTime, group.startTime); + group.processingStart = Math.min( + entry.processingStart, + group.processingStart, + ); + group.processingEnd = Math.max( + entry.processingEnd, + group.processingEnd, + ); + group.entries.push(entry); + + break; + } + } + + // If there was no matching group, assume this is a new frame. + if (!group) { + group = { + startTime: entry.startTime, + processingStart: entry.processingStart, + processingEnd: entry.processingEnd, + renderTime, + entries: [entry], + }; + + pendingEntriesGroups.push(group); + } + + // Store the grouped render time for this entry for reference later. + if (entry.interactionId || entry.entryType === 'first-input') { + entryToEntriesGroupMap.set(entry, group); + } + + queueCleanup(); + }; + + const queueCleanup = () => { + // Queue cleanup of entries that are not part of any INP candidates. + if (!cleanupPending) { + whenIdleOrHidden(cleanupEntries); + cleanupPending = true; + } + }; + + const cleanupEntries = () => { + // Delete any stored interaction target elements if they're not part of one + // of the 10 longest interactions. + if (interactionTargetMap.size > 10) { + for (const [key] of interactionTargetMap) { + if (!interactionManager._longestInteractionMap.has(key)) { + interactionTargetMap.delete(key); + } + } + } + + // Keep all render times that are part of a pending INP candidate or + // that occurred within the 50 most recently-dispatched groups of events. + const longestInteractionGroups = + interactionManager._longestInteractionList.map((i) => { + return entryToEntriesGroupMap.get(i.entries[0]); + }); + const minIndex = pendingEntriesGroups.length - MAX_PREVIOUS_FRAMES; + pendingEntriesGroups = pendingEntriesGroups.filter((group, index) => { + if (index >= minIndex) return true; + return longestInteractionGroups.includes(group); + }); + + // Keep all pending LoAF entries that either: + // 1) intersect with entries in the newly cleaned up `pendingEntriesGroups` + // 2) occur after the most recently-processed event entry (for up to MAX_PREVIOUS_FRAMES) + const loafsToKeep: Set = new Set(); + for (const group of pendingEntriesGroups) { + const loafs = getIntersectingLoAFs(group.startTime, group.processingEnd); + for (const loaf of loafs) { + loafsToKeep.add(loaf); + } + } + const prevFrameIndexCutoff = pendingLoAFs.length - 1 - MAX_PREVIOUS_FRAMES; + // Filter `pendingLoAFs` to preserve LoAF order. + pendingLoAFs = pendingLoAFs.filter((loaf, index) => { + if ( + loaf.startTime > latestProcessingEnd && + index > prevFrameIndexCutoff + ) { + return true; + } + + return loafsToKeep.has(loaf); + }); + + cleanupPending = false; + }; + + interactionManager._entryPreProcessingCallbacks.push( + saveInteractionTarget, + groupEntriesByRenderTime, + ); + + const getIntersectingLoAFs = ( + start: DOMHighResTimeStamp, + end: DOMHighResTimeStamp, + ) => { + const intersectingLoAFs: PerformanceLongAnimationFrameTiming[] = []; + + for (const loaf of pendingLoAFs) { + // If the LoAF ends before the given start time, ignore it. + if (loaf.startTime + loaf.duration < start) continue; + + // If the LoAF starts after the given end time, ignore it and all + // subsequent pending LoAFs (because they're in time order). + if (loaf.startTime > end) break; + + // Still here? If so this LoAF intersects with the interaction. + intersectingLoAFs.push(loaf); + } + return intersectingLoAFs; + }; + + const attributeINP = (metric: INPMetric): INPMetricWithAttribution => { + const firstEntry = metric.entries[0]; + const group = entryToEntriesGroupMap.get(firstEntry)!; + + const processingStart = firstEntry.processingStart; + + // Due to the fact that durations can be rounded down to the nearest 8ms, + // we have to clamp `nextPaintTime` so it doesn't appear to occur before + // processing starts. Note: we can't use `processingEnd` since processing + // can extend beyond the event duration in some cases (see next comment). + const nextPaintTime = Math.max( + firstEntry.startTime + firstEntry.duration, + processingStart, + ); + + // For the purposes of attribution, clamp `processingEnd` to `nextPaintTime`, + // so processing is never reported as taking longer than INP (which can + // happen via the web APIs in the case of sync modals, e.g. `alert()`). + // See: https://github.com/GoogleChrome/web-vitals/issues/492 + const processingEnd = Math.min(group.processingEnd, nextPaintTime); + + // Sort the entries in processing time order. + const processedEventEntries = group.entries.sort((a, b) => { + return a.processingStart - b.processingStart; + }); + + const longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[] = + getIntersectingLoAFs(firstEntry.startTime, processingEnd); + + // The first interaction entry may not have a target defined, so use the + // first one found in the entry list. + // TODO: when the following bug is fixed just use `firstInteractionEntry`. + // https://bugs.chromium.org/p/chromium/issues/detail?id=1367329 + // As a fallback, also check the interactionTargetMap (to account for + // cases where the element is removed from the DOM before reporting happens). + const firstEntryWithTarget = metric.entries.find((entry) => entry.target); + const interactionTargetElement = + firstEntryWithTarget?.target ?? + interactionTargetMap.get(firstEntry.interactionId); + + const attribution: INPAttribution = { + interactionTarget: getSelector(interactionTargetElement), + interactionTargetElement: interactionTargetElement, + interactionType: firstEntry.name.startsWith('key') + ? 'keyboard' + : 'pointer', + interactionTime: firstEntry.startTime, + nextPaintTime: nextPaintTime, + processedEventEntries: processedEventEntries, + longAnimationFrameEntries: longAnimationFrameEntries, + inputDelay: processingStart - firstEntry.startTime, + processingDuration: processingEnd - processingStart, + presentationDelay: nextPaintTime - processingEnd, + loadState: getLoadState(firstEntry.startTime), + }; + + // Use `Object.assign()` to ensure the original metric object is returned. + const metricWithAttribution: INPMetricWithAttribution = Object.assign( + metric, + {attribution}, + ); + return metricWithAttribution; + }; + + // Start observing LoAF entries for attribution. + observe('long-animation-frame', handleLoAFEntries); + unattributedOnINP((metric: INPMetric) => { const metricWithAttribution = attributeINP(metric); onReport(metricWithAttribution); diff --git a/src/lib/InteractionManager.ts b/src/lib/InteractionManager.ts new file mode 100644 index 00000000..fff80720 --- /dev/null +++ b/src/lib/InteractionManager.ts @@ -0,0 +1,149 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {getInteractionCount} from './polyfills/interactionCountPolyfill.js'; + +interface Interaction { + _latency: number; + // While the `id` and `entires` properties are also internal and could be + // mangled by prefixing with an underscore, since they correspond to public + // symbols there is no need to mangle them as the library will compress + // better if we reuse the existing names. + id: number; + entries: PerformanceEventTiming[]; +} + +interface EntryPreProcessingHook { + (entry: PerformanceEventTiming): void; +} + +// To prevent unnecessary memory usage on pages with lots of interactions, +// store at most 10 of the longest interactions to consider as INP candidates. +const MAX_INTERACTIONS_TO_CONSIDER = 10; + +// Used to store the interaction count after a bfcache restore, since p98 +// interaction latencies should only consider the current navigation. +let prevInteractionCount = 0; + +/** + * Returns the interaction count since the last bfcache restore (or for the + * full page lifecycle if there were no bfcache restores). + */ +const getInteractionCountForNavigation = () => { + return getInteractionCount() - prevInteractionCount; +}; + +export class InteractionManager { + /** + * A list of longest interactions on the page (by latency) sorted so the + * longest one is first. The list is at most MAX_INTERACTIONS_TO_CONSIDER + * long. + */ + _longestInteractionList: Interaction[] = []; + + /** + * A mapping of longest interactions by their interaction ID. + * This is used for faster lookup. + */ + _longestInteractionMap: Map = new Map(); + + _entryPreProcessingCallbacks: EntryPreProcessingHook[] = []; + + _resetInteractions() { + prevInteractionCount = getInteractionCount(); + this._longestInteractionList.length = 0; + this._longestInteractionMap.clear(); + } + + /** + * Returns the estimated p98 longest interaction based on the stored + * interaction candidates and the interaction count for the current page. + */ + _estimateP98LongestInteraction() { + const candidateInteractionIndex = Math.min( + this._longestInteractionList.length - 1, + Math.floor(getInteractionCountForNavigation() / 50), + ); + + return this._longestInteractionList[candidateInteractionIndex]; + } + + /** + * Takes a performance entry and adds it to the list of worst interactions + * if its duration is long enough to make it among the worst. If the + * entry is part of an existing interaction, it is merged and the latency + * and entries list is updated as needed. + */ + _processEntry(entry: PerformanceEventTiming) { + for (const cb of this._entryPreProcessingCallbacks) { + cb(entry); + } + + // Skip further processing for entries that cannot be INP candidates. + if (!(entry.interactionId || entry.entryType === 'first-input')) return; + + // The least-long of the 10 longest interactions. + const minLongestInteraction = this._longestInteractionList.at(-1); + + const existingInteraction = this._longestInteractionMap.get( + entry.interactionId!, + ); + + // Only process the entry if it's possibly one of the ten longest, + // or if it's part of an existing interaction. + if ( + existingInteraction || + this._longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER || + // If the above conditions are false, `minLongestInteraction` will be set. + entry.duration > minLongestInteraction!._latency + ) { + // If the interaction already exists, update it. Otherwise create one. + if (existingInteraction) { + // If the new entry has a longer duration, replace the old entries, + // otherwise add to the array. + if (entry.duration > existingInteraction._latency) { + existingInteraction.entries = [entry]; + existingInteraction._latency = entry.duration; + } else if ( + entry.duration === existingInteraction._latency && + entry.startTime === existingInteraction.entries[0].startTime + ) { + existingInteraction.entries.push(entry); + } + } else { + const interaction = { + id: entry.interactionId!, + entries: [entry], + _latency: entry.duration, + }; + this._longestInteractionMap.set(interaction.id, interaction); + this._longestInteractionList.push(interaction); + } + + // Sort the entries by latency (descending) and keep only the top ten. + this._longestInteractionList.sort((a, b) => b._latency - a._latency); + if (this._longestInteractionList.length > MAX_INTERACTIONS_TO_CONSIDER) { + const removedInteractions = this._longestInteractionList.splice( + MAX_INTERACTIONS_TO_CONSIDER, + ); + + for (const interaction of removedInteractions) { + this._longestInteractionMap.delete(interaction.id); + } + } + } + } +} diff --git a/src/lib/initUnique.ts b/src/lib/initUnique.ts new file mode 100644 index 00000000..1eda4870 --- /dev/null +++ b/src/lib/initUnique.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const instanceMap: WeakMap = new WeakMap(); + +/** + * A function that accepts and identity object and a class object and returns + * either a new instance of that class or an existing instance, if the + * identity object was previously used. + */ +export function initUnique(identityObj: object, ClassObj: new () => T): T { + if (!instanceMap.get(identityObj)) { + instanceMap.set(identityObj, new ClassObj()); + } + return instanceMap.get(identityObj)! as T; +} diff --git a/src/lib/interactions.ts b/src/lib/interactions.ts deleted file mode 100644 index 51817f40..00000000 --- a/src/lib/interactions.ts +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {getInteractionCount} from './polyfills/interactionCountPolyfill.js'; - -interface Interaction { - id: number; - latency: number; - entries: PerformanceEventTiming[]; -} - -interface EntryPreProcessingHook { - (entry: PerformanceEventTiming): void; -} - -// A list of longest interactions on the page (by latency) sorted so the -// longest one is first. The list is at most MAX_INTERACTIONS_TO_CONSIDER long. -export const longestInteractionList: Interaction[] = []; - -// A mapping of longest interactions by their interaction ID. -// This is used for faster lookup. -export const longestInteractionMap: Map = new Map(); - -// The default `durationThreshold` used across this library for observing -// `event` entries via PerformanceObserver. -export const DEFAULT_DURATION_THRESHOLD = 40; - -// Used to store the interaction count after a bfcache restore, since p98 -// interaction latencies should only consider the current navigation. -let prevInteractionCount = 0; - -/** - * Returns the interaction count since the last bfcache restore (or for the - * full page lifecycle if there were no bfcache restores). - */ -const getInteractionCountForNavigation = () => { - return getInteractionCount() - prevInteractionCount; -}; - -export const resetInteractions = () => { - prevInteractionCount = getInteractionCount(); - longestInteractionList.length = 0; - longestInteractionMap.clear(); -}; - -/** - * Returns the estimated p98 longest interaction based on the stored - * interaction candidates and the interaction count for the current page. - */ -export const estimateP98LongestInteraction = () => { - const candidateInteractionIndex = Math.min( - longestInteractionList.length - 1, - Math.floor(getInteractionCountForNavigation() / 50), - ); - - return longestInteractionList[candidateInteractionIndex]; -}; - -// To prevent unnecessary memory usage on pages with lots of interactions, -// store at most 10 of the longest interactions to consider as INP candidates. -const MAX_INTERACTIONS_TO_CONSIDER = 10; - -/** - * A list of callback functions to run before each entry is processed. - * Exposing this list allows the attribution build to hook into the - * entry processing pipeline. - */ -export const entryPreProcessingCallbacks: EntryPreProcessingHook[] = []; - -/** - * Takes a performance entry and adds it to the list of worst interactions - * if its duration is long enough to make it among the worst. If the - * entry is part of an existing interaction, it is merged and the latency - * and entries list is updated as needed. - */ -export const processInteractionEntry = (entry: PerformanceEventTiming) => { - for (const cb of entryPreProcessingCallbacks) { - cb(entry); - } - - // Skip further processing for entries that cannot be INP candidates. - if (!(entry.interactionId || entry.entryType === 'first-input')) return; - - // The least-long of the 10 longest interactions. - const minLongestInteraction = longestInteractionList.at(-1); - - const existingInteraction = longestInteractionMap.get(entry.interactionId!); - - // Only process the entry if it's possibly one of the ten longest, - // or if it's part of an existing interaction. - if ( - existingInteraction || - longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER || - // If the above conditions are false, `minLongestInteraction` will be set. - entry.duration > minLongestInteraction!.latency - ) { - // If the interaction already exists, update it. Otherwise create one. - if (existingInteraction) { - // If the new entry has a longer duration, replace the old entries, - // otherwise add to the array. - if (entry.duration > existingInteraction.latency) { - existingInteraction.entries = [entry]; - existingInteraction.latency = entry.duration; - } else if ( - entry.duration === existingInteraction.latency && - entry.startTime === existingInteraction.entries[0].startTime - ) { - existingInteraction.entries.push(entry); - } - } else { - const interaction = { - id: entry.interactionId!, - latency: entry.duration, - entries: [entry], - }; - longestInteractionMap.set(interaction.id, interaction); - longestInteractionList.push(interaction); - } - - // Sort the entries by latency (descending) and keep only the top ten. - longestInteractionList.sort((a, b) => b.latency - a.latency); - if (longestInteractionList.length > MAX_INTERACTIONS_TO_CONSIDER) { - const removedInteractions = longestInteractionList.splice( - MAX_INTERACTIONS_TO_CONSIDER, - ); - - for (const interaction of removedInteractions) { - longestInteractionMap.delete(interaction.id); - } - } - } -}; diff --git a/src/onINP.ts b/src/onINP.ts index 923a85d1..2d37f9ef 100644 --- a/src/onINP.ts +++ b/src/onINP.ts @@ -17,12 +17,8 @@ import {onBFCacheRestore} from './lib/bfcache.js'; import {bindReporter} from './lib/bindReporter.js'; import {initMetric} from './lib/initMetric.js'; -import { - DEFAULT_DURATION_THRESHOLD, - processInteractionEntry, - estimateP98LongestInteraction, - resetInteractions, -} from './lib/interactions.js'; +import {initUnique} from './lib/initUnique.js'; +import {InteractionManager} from './lib/InteractionManager.js'; import {observe} from './lib/observe.js'; import {initInteractionCountPolyfill} from './lib/polyfills/interactionCountPolyfill.js'; import {whenActivated} from './lib/whenActivated.js'; @@ -33,6 +29,10 @@ import {INPMetric, MetricRatingThresholds, ReportOpts} from './types.js'; /** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */ export const INPThresholds: MetricRatingThresholds = [200, 500]; +// The default `durationThreshold` used across this library for observing +// `event` entries via PerformanceObserver. +const DEFAULT_DURATION_THRESHOLD = 40; + /** * Calculates the [INP](https://web.dev/articles/inp) value for the current * page and calls the `callback` function once the value is ready, along with @@ -83,6 +83,8 @@ export const onINP = ( let metric = initMetric('INP'); let report: ReturnType; + const interactionManager = initUnique(opts, InteractionManager); + const handleEntries = (entries: INPMetric['entries']) => { // Queue the `handleEntries()` callback in the next idle task. // This is needed to increase the chances that all event entries that @@ -92,13 +94,13 @@ export const onINP = ( // 123+ that if rolled out fully may make this no longer necessary. whenIdleOrHidden(() => { for (const entry of entries) { - processInteractionEntry(entry); + interactionManager._processEntry(entry); } - const inp = estimateP98LongestInteraction(); + const inp = interactionManager._estimateP98LongestInteraction(); - if (inp && inp.latency !== metric.value) { - metric.value = inp.latency; + if (inp && inp._latency !== metric.value) { + metric.value = inp._latency; metric.entries = inp.entries; report(); } @@ -112,14 +114,14 @@ export const onINP = ( // and performance. Running this callback for any interaction that spans // just one or two frames is likely not worth the insight that could be // gained. - durationThreshold: opts!.durationThreshold ?? DEFAULT_DURATION_THRESHOLD, + durationThreshold: opts.durationThreshold ?? DEFAULT_DURATION_THRESHOLD, }); report = bindReporter( onReport, metric, INPThresholds, - opts!.reportAllChanges, + opts.reportAllChanges, ); if (po) { @@ -137,14 +139,14 @@ export const onINP = ( // Only report after a bfcache restore if the `PerformanceObserver` // successfully registered. onBFCacheRestore(() => { - resetInteractions(); + interactionManager._resetInteractions(); metric = initMetric('INP'); report = bindReporter( onReport, metric, INPThresholds, - opts!.reportAllChanges, + opts.reportAllChanges, ); }); } diff --git a/test/e2e/onCLS-test.js b/test/e2e/onCLS-test.js index 31095521..d3046d32 100644 --- a/test/e2e/onCLS-test.js +++ b/test/e2e/onCLS-test.js @@ -62,6 +62,42 @@ describe('onCLS()', async function () { assert.match(cls.navigationType, /navigate|reload/); }); + it('reports the correct value on visibility hidden after shifts (reportAllChanges === true)', async function () { + if (!browserSupportsCLS) this.skip(); + + await navigateTo('/test/cls?reportAllChanges=1'); + + // Wait until all images are loaded and rendered, then change to hidden. + await imagesPainted(); + await stubVisibilityChange('hidden'); + + await beaconCountIs(3); + + const [cls1, cls2, cls3] = await getBeacons(); + + assert.strictEqual(cls1.value, 0); + assert(cls1.id.match(/^v5-\d+-\d+$/)); + assert.strictEqual(cls1.name, 'CLS'); + assert.strictEqual(cls1.value, cls1.delta); + assert.strictEqual(cls1.rating, 'good'); + assert.strictEqual(cls1.entries.length, 0); + assert.match(cls1.navigationType, /navigate|reload/); + + assert.strictEqual(cls2.value, cls1.value + cls2.delta); + assert.strictEqual(cls2.id, cls1.id); + assert.strictEqual(cls2.name, 'CLS'); + assert.strictEqual(cls2.rating, 'good'); + assert.strictEqual(cls2.entries.length, 1); + assert.match(cls2.navigationType, /navigate|reload/); + + assert.strictEqual(cls3.value, cls2.value + cls3.delta); + assert.strictEqual(cls3.id, cls2.id); + assert.strictEqual(cls3.name, 'CLS'); + assert.strictEqual(cls3.rating, 'good'); + assert.strictEqual(cls3.entries.length, 2); + assert.match(cls3.navigationType, /navigate|reload/); + }); + it('reports the correct value on page unload after shifts (reportAllChanges === false)', async function () { if (!browserSupportsCLS) this.skip(); @@ -726,6 +762,58 @@ describe('onCLS()', async function () { assert.strictEqual(cls.navigationType, 'restore'); }); + it('works when calling the function twice with different options', async function () { + if (!browserSupportsCLS) this.skip(); + + await navigateTo('/test/cls?doubleCall=1&reportAllChanges2=1'); + + // Wait until all images are loaded and rendered. + await imagesPainted(); + + await beaconCountIs(3, {instance: 2}); + + const [cls2_1, cls2_2, cls2_3] = await getBeacons(); + + assert.strictEqual(cls2_1.value, 0); + assert(cls2_1.id.match(/^v5-\d+-\d+$/)); + assert.strictEqual(cls2_1.name, 'CLS'); + assert.strictEqual(cls2_1.value, cls2_1.delta); + assert.strictEqual(cls2_1.rating, 'good'); + assert.strictEqual(cls2_1.entries.length, 0); + assert.match(cls2_1.navigationType, /navigate|reload/); + + assert.strictEqual(cls2_2.value, cls2_1.value + cls2_2.delta); + assert.strictEqual(cls2_2.id, cls2_1.id); + assert.strictEqual(cls2_2.name, 'CLS'); + assert.strictEqual(cls2_2.rating, 'good'); + assert.strictEqual(cls2_2.entries.length, 1); + assert.match(cls2_2.navigationType, /navigate|reload/); + + assert.strictEqual(cls2_3.value, cls2_2.value + cls2_3.delta); + assert.strictEqual(cls2_3.id, cls2_2.id); + assert.strictEqual(cls2_3.name, 'CLS'); + assert.strictEqual(cls2_3.rating, 'good'); + assert.strictEqual(cls2_3.entries.length, 2); + assert.match(cls2_3.navigationType, /navigate|reload/); + + assert.strictEqual((await getBeacons({instance: 1})).length, 0); + + await stubVisibilityChange('hidden'); + + await beaconCountIs(1, {instance: 1}); + + const [cls1_1] = await getBeacons(); + + assert(cls1_1.id.match(/^v5-\d+-\d+$/)); + assert(cls1_1.id !== cls2_3.id); + assert.strictEqual(cls1_1.value, cls2_3.value); + assert.strictEqual(cls1_1.delta, cls2_3.value); + assert.strictEqual(cls1_1.name, cls2_3.name); + assert.strictEqual(cls1_1.rating, cls2_3.rating); + assert.deepEqual(cls1_1.entries, cls2_3.entries); + assert.strictEqual(cls1_1.navigationType, cls2_3.navigationType); + }); + describe('attribution', function () { it('includes attribution data on the metric object', async function () { if (!browserSupportsCLS) this.skip(); diff --git a/test/e2e/onFCP-test.js b/test/e2e/onFCP-test.js index 3757400b..7130b6e9 100644 --- a/test/e2e/onFCP-test.js +++ b/test/e2e/onFCP-test.js @@ -267,6 +267,35 @@ describe('onFCP()', async function () { assert.strictEqual(fcp.navigationType, 'restore'); }); + it('works when calling the function twice with different options', async function () { + if (!browserSupportsFCP) this.skip(); + + await navigateTo('/test/fcp?doubleCall=1&reportAllChanges2=1'); + + await beaconCountIs(1, {instance: 1}); + await beaconCountIs(1, {instance: 2}); + + const [fcp1] = await getBeacons({instance: 1}); + const [fcp2] = await getBeacons({instance: 2}); + + assert(fcp1.value >= 0); + assert(fcp1.id.match(/^v5-\d+-\d+$/)); + assert.strictEqual(fcp1.name, 'FCP'); + assert.strictEqual(fcp1.value, fcp1.delta); + assert.strictEqual(fcp1.rating, 'good'); + assert.strictEqual(fcp1.entries.length, 1); + assert.match(fcp1.navigationType, /navigate|reload/); + + assert(fcp2.id.match(/^v5-\d+-\d+$/)); + assert(fcp2.id !== fcp1.id); + assert.strictEqual(fcp2.value, fcp1.value); + assert.strictEqual(fcp2.delta, fcp1.delta); + assert.strictEqual(fcp2.name, fcp1.name); + assert.strictEqual(fcp2.rating, fcp1.rating); + assert.deepEqual(fcp2.entries, fcp1.entries); + assert.strictEqual(fcp2.navigationType, fcp1.navigationType); + }); + describe('attribution', function () { it('includes attribution data on the metric object', async function () { if (!browserSupportsFCP) this.skip(); diff --git a/test/e2e/onINP-test.js b/test/e2e/onINP-test.js index 80e2175d..5e2a1ad9 100644 --- a/test/e2e/onINP-test.js +++ b/test/e2e/onINP-test.js @@ -440,6 +440,69 @@ describe('onINP()', async function () { assert.strictEqual(inp.navigationType, 'restore'); }); + it('works when calling the function twice with different options', async function () { + if (!browserSupportsINP) this.skip(); + + await navigateTo( + '/test/inp?click=100&keydown=200&doubleCall=1&reportAllChanges2=1', + {readyState: 'interactive'}, + ); + + const textarea = await $('#textarea'); + simulateUserLikeClick(textarea); + + await beaconCountIs(1, {instance: 2}); + + const [inp2_1] = await getBeacons({instance: 2}); + + assert(inp2_1.value > 100 - 8); + assert(inp2_1.id.match(/^v5-\d+-\d+$/)); + assert.strictEqual(inp2_1.name, 'INP'); + assert.strictEqual(inp2_1.value, inp2_1.delta); + assert.strictEqual(inp2_1.rating, 'good'); + assert( + containsEntry(inp2_1.entries, 'click', '[object HTMLTextAreaElement]'), + ); + assert(allEntriesValid(inp2_1.entries)); + assert.match(inp2_1.navigationType, /navigate|reload/); + + // Assert no beacons for instance 1 were received. + assert.strictEqual((await getBeacons({instance: 1})).length, 0); + + await browser.keys(['a']); + + await beaconCountIs(2, {instance: 2}); + + const [, inp2_2] = await getBeacons({instance: 2}); + + assert.strictEqual(inp2_2.id, inp2_1.id); + assert.strictEqual(inp2_2.name, 'INP'); + assert.strictEqual(inp2_2.value, inp2_2.delta + inp2_1.delta); + assert.strictEqual(inp2_2.delta, inp2_2.value - inp2_1.delta); + assert.strictEqual(inp2_2.rating, 'needs-improvement'); + assert( + containsEntry(inp2_2.entries, 'keydown', '[object HTMLTextAreaElement]'), + ); + assert(allEntriesValid(inp2_2.entries)); + assert.match(inp2_2.navigationType, /navigate|reload/); + + await stubVisibilityChange('hidden'); + await beaconCountIs(1, {instance: 1}); + + const [inp1] = await getBeacons({instance: 1}); + assert(inp1.id.match(/^v5-\d+-\d+$/)); + assert(inp1.id !== inp2_1.id); + + assert(inp1.id.match(/^v5-\d+-\d+$/)); + assert(inp1.id !== inp2_2.id); + assert.strictEqual(inp1.value, inp2_2.value); + assert.strictEqual(inp1.delta, inp2_2.value); + assert.strictEqual(inp1.name, inp2_2.name); + assert.strictEqual(inp1.rating, inp2_2.rating); + assert.deepEqual(inp1.entries, inp2_2.entries); + assert.strictEqual(inp1.navigationType, inp2_2.navigationType); + }); + describe('attribution', function () { it('includes attribution data on the metric object', async function () { if (!browserSupportsINP) this.skip(); @@ -722,9 +785,24 @@ const containsEntry = (entries, name, target) => { return entries.findIndex((e) => e.name === name && e.target === target) > -1; }; +const allEntriesValid = (entries) => { + const renderTimes = entries + .map((e) => e.startTime + e.duration) + .sort((a, b) => a - b); + + const allEntriesHaveSameRenderTimes = + renderTimes.at(-1) - renderTimes.at(0) === 0; + + const entryData = entries.map((e) => JSON.stringify(e)); + + const allEntriesAreUnique = entryData.length === new Set(entryData).size; + + return allEntriesHaveSameRenderTimes && allEntriesAreUnique; +}; + const allEntriesPresentTogether = (entries) => { const renderTimes = entries - .map((e) => Math.max(e.startTime + e.duration, e.processingEnd)) + .map((e) => e.startTime + e.duration) .sort((a, b) => a - b); return renderTimes.at(-1) - renderTimes.at(0) <= 8; diff --git a/test/e2e/onLCP-test.js b/test/e2e/onLCP-test.js index 6983448d..616eacaa 100644 --- a/test/e2e/onLCP-test.js +++ b/test/e2e/onLCP-test.js @@ -436,6 +436,31 @@ describe('onLCP()', async function () { assert.strictEqual(lcp.navigationType, 'restore'); }); + it('works when calling the function twice with different options', async function () { + if (!browserSupportsLCP) this.skip(); + + await navigateTo('/test/lcp?doubleCall=1&reportAllChanges2=1'); + + await beaconCountIs(2, {instance: 2}); + + const beacons2 = await getBeacons({instance: 2}); + assertFullReportsAreCorrect(beacons2); + + assert.strictEqual((await getBeacons({instance: 1})).length, 0); + + // Load a new page to trigger the hidden state. + await navigateTo('about:blank'); + + await beaconCountIs(1, {instance: 1}); + + const beacons1 = await getBeacons({instance: 1}); + assertStandardReportsAreCorrect(beacons1); + + assert(beacons1[0].id !== beacons2[0].id); + assert(beacons1[0].id !== beacons2[1].id); + assert.deepEqual(beacons1[0].entries, beacons2[1].entries); + }); + describe('attribution', function () { it('includes attribution data on the metric object', async function () { if (!browserSupportsLCP) this.skip(); diff --git a/test/e2e/onTTFB-test.js b/test/e2e/onTTFB-test.js index 25453043..07e34fb1 100644 --- a/test/e2e/onTTFB-test.js +++ b/test/e2e/onTTFB-test.js @@ -241,6 +241,36 @@ describe('onTTFB()', async function () { assertValidEntry(ttfb.entries[0]); }); + it('works when calling the function twice with different options', async function () { + await navigateTo('/test/ttfb?doubleCall=1&reportAllChanges2=1'); + + await beaconCountIs(1, {instance: 1}); + await beaconCountIs(1, {instance: 2}); + + const [ttfb1] = await getBeacons({instance: 1}); + const [ttfb2] = await getBeacons({instance: 2}); + + assert(ttfb1.value >= 0); + assert(ttfb1.value >= ttfb1.entries[0].requestStart); + assert(ttfb1.value <= ttfb1.entries[0].loadEventEnd); + assert(ttfb1.id.match(/^v5-\d+-\d+$/)); + assert.strictEqual(ttfb1.name, 'TTFB'); + assert.strictEqual(ttfb1.value, ttfb1.delta); + assert.strictEqual(ttfb1.rating, 'good'); + assert.strictEqual(ttfb1.navigationType, 'navigate'); + assert.strictEqual(ttfb1.entries.length, 1); + assertValidEntry(ttfb1.entries[0]); + + assert(ttfb2.id.match(/^v5-\d+-\d+$/)); + assert(ttfb2.id !== ttfb1.id); + assert.strictEqual(ttfb2.value, ttfb1.value); + assert.strictEqual(ttfb2.delta, ttfb1.delta); + assert.strictEqual(ttfb2.name, ttfb1.name); + assert.strictEqual(ttfb2.rating, ttfb1.rating); + assert.deepEqual(ttfb2.entries, ttfb1.entries); + assert.strictEqual(ttfb2.navigationType, ttfb1.navigationType); + }); + describe('attribution', function () { it('includes attribution data on the metric object', async function () { await navigateTo('/test/ttfb?attribution=1'); diff --git a/test/utils/beacons.js b/test/utils/beacons.js index bd61d817..efbeb1ea 100644 --- a/test/utils/beacons.js +++ b/test/utils/beacons.js @@ -19,35 +19,51 @@ import fs from 'fs-extra'; const BEACON_FILE = './test/beacons.log'; /** - * @param {Object} count - * @return {Promise} True if the beacon count matches. + * Runs a webdriverio waitUntil command, ending once the specified number of + * beacons haven been received (optionally matching the passed `opts` object). */ -export async function beaconCountIs(count) { +export async function beaconCountIs(count, opts = {}) { await browser.waitUntil(async () => { - const beacons = await getBeacons(); + const beacons = await getBeacons(opts); + return beacons.length === count; }); } /** - * Gets the array of beacons sent for the current page load (i.e. the - * most recently sent beacons with the same metric ID). - * @return {Promise} + * Returns an array of beacons matching the passed `opts` object. If no + * `opts` are specified, the default is to return all beacon matching + * the most recently-received metric ID. */ -export async function getBeacons(id = undefined) { +export async function getBeacons(opts = {}) { const json = await fs.readFile(BEACON_FILE, 'utf-8'); const allBeacons = json.trim().split('\n').filter(Boolean).map(JSON.parse); if (allBeacons.length) { - const lastBeaconID = allBeacons[allBeacons.length - 1].id; - return allBeacons.filter((beacon) => beacon.id === lastBeaconID); + const lastBeacon = allBeacons.findLast((beacon) => { + if (opts.instance) { + return opts.instance === beacon.instance; + } + return true; + }); + + if (lastBeacon) { + return allBeacons.filter((beacon) => { + if (beacon.id === lastBeacon.id) { + if (opts.instance) { + return opts.instance === beacon.instance; + } + return true; + } + return false; + }); + } } return []; } /** * Clears the array of beacons on the page. - * @return {Promise} */ export async function clearBeacons() { await fs.truncate(BEACON_FILE); diff --git a/test/views/cls.njk b/test/views/cls.njk index f155427f..7fcbe44c 100644 --- a/test/views/cls.njk +++ b/test/views/cls.njk @@ -34,11 +34,29 @@ const {onCLS} = await __testImport('{{ modulePath }}'); onCLS((cls) => { + cls.instance = 1; + // Log for easier manual testing. console.log(cls); // Test sending the metric to an analytics endpoint. navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(cls))); - }, {reportAllChanges: self.__reportAllChanges}); + }, { + reportAllChanges: self.__reportAllChanges, + }); + + if (self.__doubleCall) { + onCLS((cls) => { + cls.instance = 2; + + // Log for easier manual testing. + console.log(cls); + + // Test sending the metric to an analytics endpoint. + navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(cls))); + }, { + reportAllChanges: self.__reportAllChanges2, + }); + } {% endblock %} diff --git a/test/views/fcp.njk b/test/views/fcp.njk index 3e1be0b8..5e9d3f02 100644 --- a/test/views/fcp.njk +++ b/test/views/fcp.njk @@ -31,11 +31,29 @@ const {onFCP} = await __testImport('{{ modulePath }}'); onFCP((fcp) => { + fcp.instance = 1; + // Log for easier manual testing. console.log(fcp); // Test sending the metric to an analytics endpoint. navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(fcp))); - }, {reportAllChanges: self.__reportAllChanges}); + }, { + reportAllChanges: self.__reportAllChanges, + }); + + if (self.__doubleCall) { + onFCP((fcp) => { + fcp.instance = 2; + + // Log for easier manual testing. + console.log(fcp); + + // Test sending the metric to an analytics endpoint. + navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(fcp))); + }, { + reportAllChanges: self.__reportAllChanges2, + }); + } {% endblock %} diff --git a/test/views/inp.njk b/test/views/inp.njk index b7730875..dd6f5654 100644 --- a/test/views/inp.njk +++ b/test/views/inp.njk @@ -115,6 +115,8 @@ const {onINP} = await __testImport('{{ modulePath }}'); onINP((inp) => { + inp.instance = 1; + // Log for easier manual testing. console.log(inp); @@ -124,5 +126,20 @@ reportAllChanges: self.__reportAllChanges, durationThreshold: self.__durationThreshold, }); + + if (self.__doubleCall) { + onINP((inp) => { + inp.instance = 2; + + // Log for easier manual testing. + console.log(inp); + + // Test sending the metric to an analytics endpoint. + navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(inp))); + }, { + reportAllChanges: self.__reportAllChanges2, + durationThreshold: self.__durationThreshold2, + }); + } {% endblock %} diff --git a/test/views/layout.njk b/test/views/layout.njk index 00b7a77b..7aa2a63b 100644 --- a/test/views/layout.njk +++ b/test/views/layout.njk @@ -171,21 +171,29 @@ const params = new URL(location.href).searchParams; - if (params.has('reportAllChanges')) { - self.__reportAllChanges = Boolean(params.get('reportAllChanges')); - } + function infer(param) { + const val = params.get(param); - if (params.has('durationThreshold')) { - self.__durationThreshold = Number(params.get('durationThreshold')); + if (val) { + if (val.match(/^\d+$/)) { + return Number(val); + } else if (val.match(/^(true|false)$/)) { + return val === 'false' ? false : true; + } + return val; + } } - if (params.has('lazyLoad')) { - self.__lazyLoad = Boolean(params.get('lazyLoad')); - } + // Default the first call to not reporting all changes. + self.__reportAllChanges = Boolean(infer('reportAllChanges')); + self.__durationThreshold = infer('durationThreshold'); - if (params.has('loadAfterInput')) { - self.__loadAfterInput = Boolean(params.get('loadAfterInput')); - } + self.__doubleCall = Boolean(infer('doubleCall')); + self.__reportAllChanges2 = Boolean(infer('reportAllChanges2')); + self.__durationThreshold2 = infer('durationThreshold2'); + + self.__lazyLoad = Boolean(infer('lazyLoad')); + self.__loadAfterInput = Boolean(infer('loadAfterInput')); if (params.has('hidden')) { // Stub the page being loaded in the hidden state, but defer to the @@ -232,7 +240,7 @@ }; self.__toSafeObject = (oldObj) => { - if (typeof oldObj !== 'object') { + if (oldObj === null || typeof oldObj !== 'object') { return oldObj; } else if (oldObj instanceof EventTarget) { return oldObj.toString(); diff --git a/test/views/lcp.njk b/test/views/lcp.njk index 7a962385..e9424c73 100644 --- a/test/views/lcp.njk +++ b/test/views/lcp.njk @@ -35,11 +35,29 @@ const {onLCP} = await __testImport('{{ modulePath }}'); onLCP((lcp) => { + lcp.instance = 1; + // Log for easier manual testing. console.log(lcp); // Test sending the metric to an analytics endpoint. navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(lcp))); - }, {reportAllChanges: self.__reportAllChanges}); + }, { + reportAllChanges: self.__reportAllChanges, + }); + + if (self.__doubleCall) { + onLCP((lcp) => { + lcp.instance = 2; + + // Log for easier manual testing. + console.log(lcp); + + // Test sending the metric to an analytics endpoint. + navigator.sendBeacon(`/collect`, JSON.stringify(__toSafeObject(lcp))); + }, { + reportAllChanges: self.__reportAllChanges2, + }); + } {% endblock %} diff --git a/test/views/ttfb.njk b/test/views/ttfb.njk index e4d7c935..46f218f2 100644 --- a/test/views/ttfb.njk +++ b/test/views/ttfb.njk @@ -36,15 +36,33 @@ } - + }, { + reportAllChanges: self.__reportAllChanges2, + }); + } + {% endblock %}