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

Save INP target after interactions to reduce null values when removed from the DOM #477

Merged
merged 11 commits into from
May 10, 2024
38 changes: 34 additions & 4 deletions src/attribution/onINP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ const entryToRenderTimeMap: WeakMap<
DOMHighResTimeStamp
> = new WeakMap();

// A mapping of interactions to the target selector
export const interactionTargetMap: Map<number, string> = 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.
Expand All @@ -77,6 +80,22 @@ const handleLoAFEntries = (entries: PerformanceLongAnimationFrameTiming[]) => {
entries.forEach((entry) => pendingLoAFs.push(entry));
};

const saveInteractionSelectors = (entry: PerformanceEventTiming) => {
const interactionId = entry.interactionId;
if (!interactionId) return;

// Save the selector early in case not available later if removed from DOM
if (!interactionTargetMap.get(interactionId) && entry.target) {
// Don't run the getSelector for keyboard events as could be a lot of them
// in short fashion when typing so just hard code to 'keyboard'.
const selector =
entry.entryType !== 'first-input' && entry.name.startsWith('key')
? 'keyboard'
: getSelector(entry.target);
interactionTargetMap.set(interactionId, selector);
}
};

/**
* Groups entries that were presented within the same animation frame by
* a common `renderTime`. This function works by referencing
Expand Down Expand Up @@ -141,6 +160,12 @@ const cleanupEntries = () => {
// more than sufficient.
previousRenderTimes = previousRenderTimes.slice(-50);

// We only need the 10 last targets;
if (interactionTargetMap.size > 10) {
const keys = Array.from(interactionTargetMap.keys()).slice(0, -10);
keys.forEach((k) => interactionTargetMap.delete(k));
}

// Keep all render times that are part of a pending INP candidate or
// that occurred within the 50 most recently-dispatched animation frames.
const renderTimesToKeep = new Set(
Expand Down Expand Up @@ -169,7 +194,10 @@ const cleanupEntries = () => {
idleHandle = -1;
};

entryPreProcessingCallbacks.push(groupEntriesByRenderTime);
entryPreProcessingCallbacks.push(
saveInteractionSelectors,
groupEntriesByRenderTime,
);

const getIntersectingLoAFs = (
start: DOMHighResTimeStamp,
Expand Down Expand Up @@ -211,7 +239,11 @@ const attributeINP = (metric: INPMetric): INPMetricWithAttribution => {
// 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
// We also fallback to interactionTargetMap for when target removed from DOM
const firstEntryWithTarget = metric.entries.find((entry) => entry.target);
const interactionTarget = firstEntryWithTarget
? getSelector(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
Expand All @@ -226,9 +258,7 @@ const attributeINP = (metric: INPMetric): INPMetricWithAttribution => {
const nextPaintTime = Math.max.apply(Math, nextPaintTimeCandidates);

const attribution: INPAttribution = {
interactionTarget: getSelector(
firstEntryWithTarget && firstEntryWithTarget.target,
),
interactionTarget: interactionTarget,
interactionType: firstEntry.name.startsWith('key') ? 'keyboard' : 'pointer',
interactionTime: firstEntry.startTime,
nextPaintTime: nextPaintTime,
Expand Down