UNPKG

web-vitals

Version:

Easily measure performance metrics in JavaScript

339 lines (296 loc) 13 kB
/* * Copyright 2022 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 {getLoadState} from '../lib/getLoadState.js'; import {getSelector} from '../lib/getSelector.js'; import { longestInteractionList, entryPreProcessingCallbacks, longestInteractionMap, } from '../lib/interactions.js'; import {observe} from '../lib/observe.js'; import {whenIdle} from '../lib/whenIdle.js'; import {onINP as unattributedOnINP} from '../onINP.js'; import { INPAttribution, INPMetric, INPMetricWithAttribution, ReportOpts, } from '../types.js'; interface pendingEntriesGroup { startTime: DOMHighResTimeStamp; processingStart: DOMHighResTimeStamp; processingEnd: DOMHighResTimeStamp; renderTime: DOMHighResTimeStamp; entries: PerformanceEventTiming[]; } // The maximum number of previous frames for which data is kept. // Storing data about previous frames is necessary to handle cases where event // and LoAF entries are dispatched out of order, and so a buffer of previous // frame data is needed to determine various bits of INP attribution once all // the frame-related data has come in. // In most cases this out-of-order data is only off by a frame or two, so // 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<number, Node> = new Map(); // A reference to the idle task used to clean up entries from the above // variables. If the value is -1 it means no task is queue, and if it's // greater than -1 the value corresponds to the idle callback handle. let idleHandle: number = -1; /** * 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 (idleHandle < 0) { idleHandle = whenIdle(cleanupEntries); } }; const cleanupEntries = () => { // Delete any stored interaction target elements if they're not part of one // of the 10 longest interactions. if (interactionTargetMap.size > 10) { interactionTargetMap.forEach((_, key) => { 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<PerformanceLongAnimationFrameTiming> = new Set(); for (let i = 0; i < pendingEntriesGroups.length; i++) { const group = pendingEntriesGroups[i]; getIntersectingLoAFs(group.startTime, group.processingEnd).forEach( (loaf) => { 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); }); // Reset the idle callback handle so it can be queued again. idleHandle = -1; }; entryPreProcessingCallbacks.push( saveInteractionTarget, groupEntriesByRenderTime, ); const getIntersectingLoAFs = ( start: DOMHighResTimeStamp, end: DOMHighResTimeStamp, ) => { const intersectingLoAFs = []; for (let i = 0, loaf; (loaf = pendingLoAFs[i]); i++) { // 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; const processingEnd = group.processingEnd; // 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 && firstEntryWithTarget.target) || interactionTargetMap.get(firstEntry.interactionId); // Since entry durations are rounded to the nearest 8ms, we need to clamp // the `nextPaintTime` value to be higher than the `processingEnd` or // end time of any LoAF entry. const nextPaintTimeCandidates = [ firstEntry.startTime + firstEntry.duration, processingEnd, ].concat( longAnimationFrameEntries.map((loaf) => loaf.startTime + loaf.duration), ); const nextPaintTime = Math.max.apply(Math, nextPaintTimeCandidates); 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: Math.max(nextPaintTime - processingEnd, 0), loadState: getLoadState(firstEntry.startTime), }; // Use Object.assign to set property to keep tsc happy. 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 * the `event` performance entries reported for that interaction. The reported * value is a `DOMHighResTimeStamp`. * * A custom `durationThreshold` configuration option can optionally be passed to * control what `event-timing` entries are considered for INP reporting. The * default threshold is `40`, which means INP scores of less than 40 are * reported as 0. Note that this will not affect your 75th percentile INP value * unless that value is also less than 40 (well below the recommended * [good](https://web.dev/articles/inp#what_is_a_good_inp_score) threshold). * * If the `reportAllChanges` configuration option is set to `true`, the * `callback` function will be called as soon as the value is initially * determined as well as any time the value changes throughout the page * lifespan. * * _**Important:** INP should be continually monitored for changes throughout * the entire lifespan of a page—including if the user returns to the page after * it's been hidden/backgrounded. However, since browsers often [will not fire * additional callbacks once the user has backgrounded a * page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden), * `callback` is always called when the page's visibility state changes to * hidden. As a result, the `callback` function might be called multiple times * during the same page load._ */ export const onINP = ( onReport: (metric: INPMetricWithAttribution) => void, opts?: ReportOpts, ) => { if (!loafObserver) { loafObserver = observe('long-animation-frame', handleLoAFEntries); } unattributedOnINP((metric: INPMetric) => { const metricWithAttribution = attributeINP(metric); onReport(metricWithAttribution); }, opts); };