UNPKG

web-vitals

Version:

Easily measure performance metrics in JavaScript

428 lines (374 loc) 17.1 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 {initUnique} from '../lib/initUnique.js'; import {InteractionManager, Interaction} from '../lib/InteractionManager.js'; import {observe} from '../lib/observe.js'; import {whenIdleOrHidden} from '../lib/whenIdleOrHidden.js'; import {onINP as unattributedOnINP} from '../onINP.js'; import { INPAttribution, INPAttributionReportOpts, INPMetric, INPMetricWithAttribution, INPLongestScriptSummary, } 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; /** * 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 will not * be reported. To avoid reporting no interactions in these cases, the library * will fall back to the input delay of the first interaction. 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 has 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: INPAttributionReportOpts = {}, ) => { // 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: WeakMap<Interaction, string> = new WeakMap(); // 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(); }; const saveInteractionTarget = (interaction: Interaction) => { if (!interactionTargetMap.get(interaction)) { const generateTargetFn = opts.generateTarget ?? getSelector; const customTarget = generateTargetFn(interaction.entries[0].target); interactionTargetMap.set(interaction, customTarget); } }; /** * 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 = () => { // 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<PerformanceLongAnimationFrameTiming> = 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._onBeforeProcessingEntry = groupEntriesByRenderTime; interactionManager._onAfterProcessingINPCandidate = saveInteractionTarget; 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 attributeLoAFDetails = (attribution: INPAttribution) => { // If there is no LoAF data then nothing further to attribute if (!attribution.longAnimationFrameEntries?.length) { return; } const interactionTime = attribution.interactionTime; const inputDelay = attribution.inputDelay; const processingDuration = attribution.processingDuration; // Stats across all LoAF entries and scripts. let totalScriptDuration = 0; let totalStyleAndLayoutDuration = 0; let totalPaintDuration = 0; let longestScriptDuration = 0; let longestScriptEntry: PerformanceScriptTiming | undefined; let longestScriptSubpart: INPLongestScriptSummary['subpart'] | undefined; for (const loafEntry of attribution.longAnimationFrameEntries) { totalStyleAndLayoutDuration = totalStyleAndLayoutDuration + loafEntry.startTime + loafEntry.duration - loafEntry.styleAndLayoutStart; for (const script of loafEntry.scripts) { const scriptEndTime = script.startTime + script.duration; if (scriptEndTime < interactionTime) { continue; } const intersectingScriptDuration = scriptEndTime - Math.max(interactionTime, script.startTime); // Since forcedStyleAndLayoutDuration doesn't provide timestamps, we // apportion the total based on the intersectingScriptDuration. Not // correct depending on when it occurred, but the best we can do. const intersectingForceStyleAndLayoutDuration = script.duration ? (intersectingScriptDuration / script.duration) * script.forcedStyleAndLayoutDuration : 0; // For scripts we exclude forcedStyleAndLayout (same as DevTools does // in its summary totals) and instead include that in // totalStyleAndLayoutDuration totalScriptDuration += intersectingScriptDuration - intersectingForceStyleAndLayoutDuration; totalStyleAndLayoutDuration += intersectingForceStyleAndLayoutDuration; if (intersectingScriptDuration > longestScriptDuration) { // Set the subpart this occurred in. longestScriptSubpart = script.startTime < interactionTime + inputDelay ? 'input-delay' : script.startTime >= interactionTime + inputDelay + processingDuration ? 'presentation-delay' : 'processing-duration'; longestScriptEntry = script; longestScriptDuration = intersectingScriptDuration; } } } // Calculate the totalPaintDuration from the last LoAF after // presentationDelay starts (where available) const lastLoAF = attribution.longAnimationFrameEntries.at(-1); const lastLoAFEndTime = lastLoAF ? lastLoAF.startTime + lastLoAF.duration : 0; if (lastLoAFEndTime >= interactionTime + inputDelay + processingDuration) { totalPaintDuration = attribution.nextPaintTime - lastLoAFEndTime; } if (longestScriptEntry && longestScriptSubpart) { attribution.longestScript = { entry: longestScriptEntry, subpart: longestScriptSubpart, intersectingDuration: longestScriptDuration, }; } attribution.totalScriptDuration = totalScriptDuration; attribution.totalStyleAndLayoutDuration = totalStyleAndLayoutDuration; attribution.totalPaintDuration = totalPaintDuration; attribution.totalUnattributedDuration = attribution.nextPaintTime - interactionTime - totalScriptDuration - totalStyleAndLayoutDuration - totalPaintDuration; }; 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); const interaction = interactionManager._longestInteractionMap.get( firstEntry.interactionId, ); const attribution: INPAttribution = { // TS flags the next line because `interactionTargetMap.get()` might // return `undefined`, but we ignore this assuming the user knows what // they are doing. interactionTarget: interactionTargetMap.get(interaction!)!, 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), longestScript: undefined, totalScriptDuration: undefined, totalStyleAndLayoutDuration: undefined, totalPaintDuration: undefined, totalUnattributedDuration: undefined, }; attributeLoAFDetails(attribution); // 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); }, opts); };