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

Fix missing LoAF attribution when entries are dispatched before event entries #487

Merged
merged 3 commits into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 35 additions & 15 deletions src/attribution/onINP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ interface pendingEntriesGroup {
entries: PerformanceEventTiming[];
}

// The maximum number of previous frames for which data is kept in regards to.
// Storing data about previous frames is necessary to handle cases where event
// and LoAF entries are dispatched out or 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;
Expand All @@ -59,6 +68,9 @@ const pendingEntriesGroupMap: Map<number, pendingEntriesGroup> = new Map();
// are removed.
let previousRenderTimes: number[] = [];

// The `processingEnd` time of most recently-processed event, chronologically.
let latestProcessingEnd: number;

// A WeakMap so you can look up the `renderTime` of a given entry and the
// value returned will be the same value used by `pendingEntriesGroupMap`.
const entryToRenderTimeMap: WeakMap<
Expand All @@ -78,7 +90,8 @@ let idleHandle: number = -1;
* Adds new LoAF entries to the `pendingLoAFs` list.
*/
const handleLoAFEntries = (entries: PerformanceLongAnimationFrameTiming[]) => {
entries.forEach((entry) => pendingLoAFs.push(entry));
pendingLoAFs = pendingLoAFs.concat(entries);
queueCleanup();
};

// Get a reference to the interaction target element in case it's removed
Expand All @@ -105,6 +118,8 @@ const groupEntriesByRenderTime = (entry: PerformanceEventTiming) => {
let renderTime = entry.startTime + entry.duration;
let previousRenderTime;

latestProcessingEnd = Math.max(latestProcessingEnd, entry.processingEnd);

// Iterate of 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 = previousRenderTimes.length - 1; i >= 0; i--) {
Expand Down Expand Up @@ -143,6 +158,8 @@ const groupEntriesByRenderTime = (entry: PerformanceEventTiming) => {
if (entry.interactionId || entry.entryType === 'first-input') {
entryToRenderTimeMap.set(entry, renderTime);
}

queueCleanup();
};

const queueCleanup = () => {
Expand All @@ -167,7 +184,7 @@ const cleanupEntries = () => {
// events are dispatched out of order. When this happens they're generally
// only off by a frame or two, so keeping the most recent 50 should be
// more than sufficient.
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
previousRenderTimes = previousRenderTimes.slice(-50);
previousRenderTimes = previousRenderTimes.slice(-1 * MAX_PREVIOUS_FRAMES);

// Keep all render times that are part of a pending INP candidate or
// that occurred within the 50 most recently-dispatched animation frames.
Expand All @@ -181,8 +198,9 @@ const cleanupEntries = () => {
if (!renderTimesToKeep.has(key)) pendingEntriesGroupMap.delete(key);
});

// Remove all pending LoAF entries that don't intersect with entries in
// the newly cleaned up `pendingEntriesGroupMap`.
// Keep all pending LoAF entries that either:
// 1) intersect with entries in the newly cleaned up `pendingEntriesGroupMap`
// 2) occur after the most recently-processed event entry.
const loafsToKeep: Set<PerformanceLongAnimationFrameTiming> = new Set();
pendingEntriesGroupMap.forEach((group) => {
getIntersectingLoAFs(group.startTime, group.processingEnd).forEach(
Expand All @@ -191,6 +209,17 @@ const cleanupEntries = () => {
},
);
});
for (let i = 0; i < MAX_PREVIOUS_FRAMES; i++) {
// Look at pending LoAF in reverse order so the most recent are first.
const loaf = pendingLoAFs[pendingLoAFs.length - 1];

// If we reach LoAFs that overlap with event processing,
// we can assume all previous ones have already been handled.
if (!loaf || loaf.startTime < latestProcessingEnd) break;

loafsToKeep.add(loaf);
}

pendingLoAFs = Array.from(loafsToKeep);

// Reset the idle callback handle so it can be queued again.
Expand All @@ -200,7 +229,6 @@ const cleanupEntries = () => {
entryPreProcessingCallbacks.push(
saveInteractionTarget,
groupEntriesByRenderTime,
queueCleanup,
);

const getIntersectingLoAFs = (
Expand Down Expand Up @@ -319,15 +347,7 @@ export const onINP = (
loafObserver = observe('long-animation-frame', handleLoAFEntries);
}
unattributedOnINP((metric: INPMetric) => {
// Queue attribution and reporting in the next idle task.
// This is needed to increase the chances that all event entries that
// occurred between the user interaction and the next paint
// have been dispatched. Note: there is currently an experiment
// running in Chrome (EventTimingKeypressAndCompositionInteractionId)
// 123+ that if rolled out fully would make this no longer necessary.
whenIdle(() => {
const metricWithAttribution = attributeINP(metric);
onReport(metricWithAttribution);
});
const metricWithAttribution = attributeINP(metric);
onReport(metricWithAttribution);
}, opts);
};
23 changes: 16 additions & 7 deletions src/onINP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {observe} from './lib/observe.js';
import {onHidden} from './lib/onHidden.js';
import {initInteractionCountPolyfill} from './lib/polyfills/interactionCountPolyfill.js';
import {whenActivated} from './lib/whenActivated.js';
import {whenIdle} from './lib/whenIdle.js';

import {INPMetric, MetricRatingThresholds, ReportOpts} from './types.js';

Expand Down Expand Up @@ -75,15 +76,23 @@ export const onINP = (
let report: ReturnType<typeof bindReporter>;

const handleEntries = (entries: INPMetric['entries']) => {
entries.forEach(processInteractionEntry);
// Queue the `handleEntries()` callback in the next idle task.
// This is needed to increase the chances that all event entries that
// occurred between the user interaction and the next paint
// have been dispatched. Note: there is currently an experiment
// running in Chrome (EventTimingKeypressAndCompositionInteractionId)
// 123+ that if rolled out fully may make this no longer necessary.
whenIdle(() => {
entries.forEach(processInteractionEntry);

const inp = estimateP98LongestInteraction();
const inp = estimateP98LongestInteraction();

if (inp && inp.latency !== metric.value) {
metric.value = inp.latency;
metric.entries = inp.entries;
report();
}
if (inp && inp.latency !== metric.value) {
metric.value = inp.latency;
metric.entries = inp.entries;
report();
}
});
};

const po = observe('event', handleEntries, {
Expand Down
3 changes: 3 additions & 0 deletions test/e2e/onINP-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,9 @@ describe('onINP()', async function () {

await browser.keys(['a', 'b', 'c']);

// Ensure the interaction completes.
await nextFrame();

await stubForwardBack();
await beaconCountIs(1);

Expand Down
Loading