Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for generating custom targets in the attribution build #585

Merged
merged 15 commits into from
Jan 14, 2025
Merged
Next Next commit
Move the interactions logic into a reusable class
philipwalton committed Dec 20, 2024
commit 0ba5d803ca0f9ba6cb066c03240bbee40be55dcc
10 changes: 9 additions & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
@@ -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. `.entries`).
regex: /^\$/,
},
},
}),
];
};
522 changes: 267 additions & 255 deletions src/attribution/onINP.ts
Original file line number Diff line number Diff line change
@@ -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/interactions.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<number, Node> = 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<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;
};

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<number, Node> = 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<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.$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);
29 changes: 29 additions & 0 deletions src/lib/initUnique.ts
Original file line number Diff line number Diff line change
@@ -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<object, unknown> = 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<T>(identityObj: object, ClassObj: new () => T): T {
if (!instanceMap.get(identityObj)) {
instanceMap.set(identityObj, new ClassObj());
}
return instanceMap.get(identityObj)! as T;
}
192 changes: 96 additions & 96 deletions src/lib/interactions.ts
Original file line number Diff line number Diff line change
@@ -18,25 +18,17 @@ import {getInteractionCount} from './polyfills/interactionCountPolyfill.js';

interface Interaction {
id: number;
latency: number;
entries: PerformanceEventTiming[];
$latency: number;
}

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<number, Interaction> = new Map();

// The default `durationThreshold` used across this library for observing
// `event` entries via PerformanceObserver.
export const DEFAULT_DURATION_THRESHOLD = 40;
// 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.
@@ -50,96 +42,104 @@ 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[] = [];
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<number, Interaction> = new Map();

$entryPreProcessingCallbacks: EntryPreProcessingHook[] = [];

$resetInteractions() {
prevInteractionCount = getInteractionCount();
this.$longestInteractionList.length = 0;
this.$longestInteractionMap.clear();
}

/**
* 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);
/**
* 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];
}

// 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);
/**
* 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);
}

// 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,
);
// 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) {
longestInteractionMap.delete(interaction.id);
for (const interaction of removedInteractions) {
this.$longestInteractionMap.delete(interaction.id);
}
}
}
}
};
}
30 changes: 16 additions & 14 deletions src/onINP.ts
Original file line number Diff line number Diff line change
@@ -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/interactions.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<typeof bindReporter>;

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,
);
});
}