diff --git a/src/getINP.ts b/src/getINP.ts index 580c778c..d43e6d07 100644 --- a/src/getINP.ts +++ b/src/getINP.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 Google LLC + * 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. @@ -14,118 +14,151 @@ * limitations under the License. */ +import {onBFCacheRestore} from './lib/bfcache.js'; import {bindReporter} from './lib/bindReporter.js'; import {initMetric} from './lib/initMetric.js'; -import {observe, PerformanceEntryHandler} from './lib/observe.js'; -import {onBFCacheRestore} from './lib/onBFCacheRestore.js'; +import {observe} from './lib/observe.js'; import {onHidden} from './lib/onHidden.js'; -import {PerformanceEventTiming, ReportHandler} from './types.js'; +import {getInteractionCount, initInteractionCountPolyfill} from './lib/polyfills/interactionCountPolyfill.js'; +import {Metric, PerformanceEventTiming, ReportHandler} from './types.js'; -/* - * In order to compute a High Percentile (p98-p100) Interaction for INP, - * we need to store a list of the worst interactions measured. - * - * EVERY_N is the number of interactions before moving to the next-highest (i.e. p98) - * NUM_ENTRIES_TO_STORE is the max size of the list of entries - * - * EVERY_N * NUM_ENTRIES_TO_STORE becomes, effectively, the max number of interactions - * per page load for which getINP() works well. Adjust as needed. - */ -const EVERY_N = 50; -const NUM_ENTRIES_TO_STORE = 10; -const largestINPEntries: PerformanceEventTiming[] = []; -let minKnownInteractionId = Number.POSITIVE_INFINITY; -let maxKnownInteractionId = 0; - -function updateInteractionIds(interactionId: number): void { - minKnownInteractionId = Math.min(minKnownInteractionId, interactionId); - maxKnownInteractionId = Math.max(maxKnownInteractionId, interactionId); +interface Interaction { + id: number; + latency: number; + entries: PerformanceEventTiming[]; } -function estimateInteractionCount(): number { - return (maxKnownInteractionId > 0) ? ((maxKnownInteractionId - minKnownInteractionId) / 7) + 1 : 0; +// Used to store the interaction count after a bfcache restore, since p98 +// interaction latencies should only consider the current navigation. +let prevInteractionCount = 0; + +/** + * Returns the interaction count since the last bfcache restore (or for the + * full page lifecycle if there were no bfcache restores). + */ +const getInteractionCountForNavigation = () => { + return getInteractionCount() - prevInteractionCount; } -function addInteractionEntryToINPList(entry: PerformanceEventTiming): void { - // Optional: Skip this entry early if we know it won't be needed. - if (largestINPEntries.length >= NUM_ENTRIES_TO_STORE && entry.duration < largestINPEntries[largestINPEntries.length-1].duration) { - return; - } - - // If we already have an interaction with this same ID, merge with it. - const existing = largestINPEntries.findIndex((other) => entry.interactionId == other.interactionId); - if (existing >= 0) { - // Only replace if this one is actually longer - if (entry.duration > largestINPEntries[existing].duration) { - largestINPEntries[existing] = entry; +// 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 longest interactions on the page (by latency) sorted so the +// longest one is first. The list is as most MAX_INTERACTIONS_TO_CONSIDER long. +let longestInteractionList: Interaction[] = []; + +// A mapping of longest interactions by their interaction ID. +// This is used for faster lookup. +const longestInteractionMap: {[interactionId: string]: 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. + */ +const processEntry = (entry: PerformanceEventTiming) => { + // The least-long of the 10 longest interactions. + const minLongestInteraction = + longestInteractionList[longestInteractionList.length - 1] + + const existingInteraction = longestInteractionMap[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 || + entry.duration > minLongestInteraction.latency) { + // If the interaction already exists, update it. Otherwise create one. + if (existingInteraction) { + existingInteraction.entries.push(entry); + existingInteraction.latency = + Math.max(existingInteraction.latency, entry.duration); + } else { + const interaction = { + id: entry.interactionId!, + latency: entry.duration, + entries: [entry], + } + longestInteractionMap[interaction.id] = interaction; + longestInteractionList.push(interaction); } - } else { - largestINPEntries.push(entry); - } - largestINPEntries.sort((a,b) => b.duration - a.duration); - largestINPEntries.splice(NUM_ENTRIES_TO_STORE); + // Sort the entries by latency (descending) and keep only the top ten. + longestInteractionList.sort((a, b) => b.latency - a.latency); + longestInteractionList.splice(MAX_INTERACTIONS_TO_CONSIDER).forEach((i) => { + delete longestInteractionMap[i.id]; + }); + } } -function getCurrentINPEntry(): PerformanceEventTiming { - const interactionCount = estimateInteractionCount(); - const which = Math.min(largestINPEntries.length-1, Math.floor(interactionCount / EVERY_N)); - return largestINPEntries[which]; +/** + * Returns the estimated p98 longest interaction based on the stored + * interaction candidates and the interaction count for the current page. + */ +const estimateP98LongestInteraction = () => { + const candidateInteractionIndex = Math.min(longestInteractionList.length - 1, + Math.floor(getInteractionCountForNavigation() / 50)); + + return longestInteractionList[candidateInteractionIndex]; } export const getINP = (onReport: ReportHandler, reportAllChanges?: boolean) => { + // TODO(philipwalton): remove once the polyfill is no longer needed. + initInteractionCountPolyfill(); + let metric = initMetric('INP'); let report: ReturnType; - const entryHandler = (entry: PerformanceEventTiming) => { - // TODO: Perhaps ignore values before FCP - if (!entry.interactionId) return; - - updateInteractionIds(entry.interactionId); - addInteractionEntryToINPList(entry); - - const inpEntry = getCurrentINPEntry(); - - // Only report when the IMP value changes. However: - // * When we cross a %-ile boundary, pushing `which` up, or - // * A new long value is added to the top, moving the current INP entry down - // ...then the inpEntry will change, but `duration` value of the new entry may still be the same. - // While technically the INP metric.value doesn't change, we still report since metric.entries changes. - // - // Potentially, we may even want to compare the whole metric.entries range for equality, because: - // * We can have cases where a middle value updates due to new-longest value with same interactionId. - // * When we are already at MAX_ENTRIES and `which` stops changing, but the current smallest can get popped off. - const which = largestINPEntries.indexOf(inpEntry); - if (which >= metric.entries.length || metric.value != inpEntry.duration) { - metric.value = inpEntry.duration; - // We attach all the longest responsiveness entries, not just the HighP value. - // While technically the INP score is exactly the entry.duration of one specific HighP-ile entry... - // the entry would not have been picked (and IMP would be lower) if *any* of the worst entries were not so high. - // Improving any of them will improve score. - metric.entries.length = 0; - metric.entries.push(...largestINPEntries.slice(0, which + 1)); - } + const handleEntries = (entries: Metric['entries']) => { + (entries as PerformanceEventTiming[]).forEach((entry) => { + if (entry.interactionId) { + processEntry(entry); + } + }); + + const inp = estimateP98LongestInteraction(); - // Perhaps Event Timing is the first API that can have multiple entries in a single PO callback - // That means that we would ideally report() only after the whole list of entries is processed, not one per entry. - // If we were lucky, the entries would be in timestamp order so the first is the longest... but I've found they - // are ordered in other ways... by type, I think? - // Alternatively: sort entries in the observe() wrapper. - report(); + if (inp && inp.latency !== metric.value) { + metric.value = inp.latency; + metric.entries = inp.entries; + report(); + } }; - const po = observe('event', entryHandler as PerformanceEntryHandler); + const po = observe('event', handleEntries, { + // Event Timing entries have their durations rounded to the nearest 8ms, + // so a duration of 40ms would be any event that spans 2.5 or more frames + // at 60Hz. This threshold is chosen to strike a balance between usefulness + // 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: 40, + } as PerformanceObserverInit); + report = bindReporter(onReport, metric, reportAllChanges); if (po) { onHidden(() => { - po.takeRecords().map(entryHandler as PerformanceEntryHandler); + handleEntries(po.takeRecords()); + + // If the interaction count shows that there were interactions but + // none were captured by the PerformanceObserver, report a latency of 0. + if (metric.value < 0 && getInteractionCountForNavigation() > 0) { + metric.value = 0; + metric.entries = []; + } + report(true); - }, true); - - // TODO: Test this + }); + onBFCacheRestore(() => { - largestINPEntries.length = 0; + longestInteractionList = []; + // Important, we want the count for the full page here, + // not just for the current navigation. + prevInteractionCount = getInteractionCount(); + metric = initMetric('INP'); report = bindReporter(onReport, metric, reportAllChanges); }); diff --git a/src/lib/polyfills/interactionCountPolyfill.ts b/src/lib/polyfills/interactionCountPolyfill.ts new file mode 100644 index 00000000..24280db5 --- /dev/null +++ b/src/lib/polyfills/interactionCountPolyfill.ts @@ -0,0 +1,64 @@ +/* + * 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 {observe} from '../observe.js'; +import {Metric, PerformanceEventTiming} from '../../types.js'; + + +declare global { + interface Performance { + interactionCount: number; + } +} + +let interactionCountEstimate = 0; +let minKnownInteractionId = Infinity; +let maxKnownInteractionId = 0; + +const updateEstimate = (entries: Metric['entries']) => { + (entries as PerformanceEventTiming[]).forEach((e) => { + if (e.interactionId) { + minKnownInteractionId = Math.min(minKnownInteractionId, e.interactionId); + maxKnownInteractionId = Math.max(maxKnownInteractionId, e.interactionId); + + interactionCountEstimate = maxKnownInteractionId ? + (maxKnownInteractionId - minKnownInteractionId) / 7 + 1 : 0; + } + }); +} + +let po: PerformanceObserver | undefined; + +/** + * Returns the `interactionCount` value using the native API (if available) + * or the polyfill estimate in this module. + */ +export const getInteractionCount = () => { + return po ? interactionCountEstimate : performance.interactionCount || 0; +} + +/** + * Feature detects native support or initializes the polyfill if needed. + */ +export const initInteractionCountPolyfill = () => { + if ('interactionCount' in performance || po) return; + + po = observe('event', updateEstimate, { + type: 'event', + buffered: true, + durationThreshold: 0, + } as PerformanceObserverInit); +}; diff --git a/src/types.ts b/src/types.ts index 8b025bdb..ece189e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,10 @@ export interface LayoutShift extends PerformanceEntry { hadRecentInput: boolean; } +export interface PerformanceObserverInit { + durationThreshold?: number; +} + export type FirstInputPolyfillEntry = Omit diff --git a/test/e2e/getINP-test.js b/test/e2e/getINP-test.js new file mode 100644 index 00000000..4e15d539 --- /dev/null +++ b/test/e2e/getINP-test.js @@ -0,0 +1,315 @@ +/* + * 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. + */ + +const assert = require('assert'); +const {beaconCountIs, clearBeacons, getBeacons} = require('../utils/beacons.js'); +const {browserSupportsEntry} = require('../utils/browserSupportsEntry.js'); +const {stubForwardBack} = require('../utils/stubForwardBack.js'); +const {stubVisibilityChange} = require('../utils/stubVisibilityChange.js'); + + +describe('getINP()', async function() { + // Retry all tests in this suite up to 2 times. + this.retries(2); + + let browserSupportsINP; + before(async function() { + browserSupportsINP = await browserSupportsEntry('event'); + }); + + beforeEach(async function() { + await clearBeacons(); + }); + + it('reports the correct value on visibility hidden after interactions (reportAllChanges === false)', async function() { + if (!browserSupportsINP) this.skip(); + + await browser.url('/test/inp?click=100'); + + const h1 = await $('h1'); + await h1.click(); + + await stubVisibilityChange('hidden'); + + await beaconCountIs(1); + + const [inp] = await getBeacons(); + assert(inp.value >= 0); + assert(inp.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(inp.name, 'INP'); + assert.strictEqual(inp.value, inp.delta); + assert(containsEntry(inp.entries, 'click', 'h1')); + assert(interactionIDsMatch(inp.entries)); + assert(inp.entries[0].interactionId > 0); + assert.match(inp.navigationType, /navigate|reload/); + }); + + it('reports the correct value on visibility hidden after interactions (reportAllChanges === true)', async function() { + if (!browserSupportsINP) this.skip(); + + await browser.url('/test/inp?click=100&reportAllChanges=1'); + + const h1 = await $('h1'); + await h1.click(); + + await stubVisibilityChange('hidden'); + + await beaconCountIs(1); + + const [inp] = await getBeacons(); + assert(inp.value >= 0); + assert(inp.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(inp.name, 'INP'); + assert.strictEqual(inp.value, inp.delta); + assert(containsEntry(inp.entries, 'click', 'h1')); + assert(interactionIDsMatch(inp.entries)); + assert(inp.entries[0].interactionId > 0); + assert.match(inp.navigationType, /navigate|reload/); + }); + + it('reports the correct value on page unload after interactions (reportAllChanges === false)', async function() { + if (!browserSupportsINP) this.skip(); + + await browser.url('/test/inp?click=100'); + + const h1 = await $('h1'); + await h1.click(); + + await browser.url('about:blank'); + + await beaconCountIs(1); + + const [inp] = await getBeacons(); + assert(inp.value >= 0); + assert(inp.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(inp.name, 'INP'); + assert.strictEqual(inp.value, inp.delta); + assert(containsEntry(inp.entries, 'click', 'h1')); + assert(interactionIDsMatch(inp.entries)); + assert(inp.entries[0].interactionId > 0); + assert.match(inp.navigationType, /navigate|reload/); + }); + + it('reports the correct value on page unload after interactions (reportAllChanges === true)', async function() { + if (!browserSupportsINP) this.skip(); + + await browser.url('/test/inp?click=100&reportAllChanges=1'); + + const h1 = await $('h1'); + await h1.click(); + + await browser.url('about:blank'); + + await beaconCountIs(1); + + const [inp] = await getBeacons(); + assert(inp.value >= 0); + assert(inp.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(inp.name, 'INP'); + assert.strictEqual(inp.value, inp.delta); + assert(containsEntry(inp.entries, 'click', 'h1')); + assert(interactionIDsMatch(inp.entries)); + assert(inp.entries[0].interactionId > 0); + assert.match(inp.navigationType, /navigate|reload/); + }); + + it('reports approx p98 interaction when 50+ interactions (reportAllChanges === false)', async function() { + if (!browserSupportsINP) this.skip(); + + await browser.url('/test/inp?click=60&pointerdown=600'); + + const h1 = await $('h1'); + await h1.click(); + + await setBlockingTime('pointerdown', 400); + await h1.click(); + + await setBlockingTime('pointerdown', 200); + await h1.click(); + + await setBlockingTime('pointerdown', 0); + + await stubVisibilityChange('hidden'); + await beaconCountIs(1); + + const [inp1] = await getBeacons(); + assert(inp1.value >= 600); // Initial pointerdown blocking time. + + await clearBeacons(); + await stubVisibilityChange('visible'); + + let count = 3; + while (count < 50) { + await h1.click(); + count++; + } + + await stubVisibilityChange('hidden'); + await beaconCountIs(1); + + const [inp2] = await getBeacons(); + assert(inp2.value >= 400); // Initial pointerdown blocking time. + assert(inp2.value < inp1.value); // Should have gone down. + + await clearBeacons(); + await stubVisibilityChange('visible'); + + while (count < 100) { + await h1.click(); + count++; + } + + await stubVisibilityChange('hidden'); + await beaconCountIs(1); + + const [inp3] = await getBeacons(); + assert(inp3.value >= 200); // 2nd-highest pointerdown blocking time. + assert(inp3.value < inp2.value); // Should have gone down. + }); + + it('reports approx p98 interaction when 50+ interactions (reportAllChanges === true)', async function() { + if (!browserSupportsINP) this.skip(); + + await browser.url('/test/inp?click=60&pointerdown=600&reportAllChanges=1'); + + const h1 = await $('h1'); + await h1.click(); + + await setBlockingTime('pointerdown', 400); + await h1.click(); + + await setBlockingTime('pointerdown', 200); + await h1.click(); + + await setBlockingTime('pointerdown', 0); + + let count = 3; + while (count < 100) { + await h1.click(); + count++; + } + + await beaconCountIs(3); + + const [inp1, inp2, inp3] = await getBeacons(); + assert(inp1.value >= 600); // Initial pointerdown blocking time. + assert(inp2.value >= 400); // Initial pointerdown blocking time. + assert(inp2.value < inp1.value); // Should have gone down. + assert(inp3.value >= 200); // 2nd-highest pointerdown blocking time. + assert(inp3.value < inp2.value); // Should have gone down. + }); + + it('reports a new interaction after bfcache restore', async function() { + if (!browserSupportsINP) this.skip(); + + await browser.url('/test/inp'); + + await setBlockingTime('click', 100); + + const h1 = await $('h1'); + await h1.click(); + + await stubForwardBack(); + await beaconCountIs(1); + + const [inp1] = await getBeacons(); + assert(inp1.value >= 0); + assert(inp1.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(inp1.name, 'INP'); + assert.strictEqual(inp1.value, inp1.delta); + assert(containsEntry(inp1.entries, 'click', 'h1')); + assert(interactionIDsMatch(inp1.entries)); + assert.match(inp1.navigationType, /navigate|reload/); + + await clearBeacons(); + + await setBlockingTime('click', 0); + await setBlockingTime('keydown', 50); + + const textarea = await $('#textarea'); + await textarea.click(); + + await browser.keys(['a', 'b', 'c']); + + await stubForwardBack(); + await beaconCountIs(1); + + const [inp2] = await getBeacons(); + assert(inp2.value >= 0); + assert(inp2.id.match(/^v2-\d+-\d+$/)); + assert(inp1.id !== inp2.id); + assert.strictEqual(inp2.name, 'INP'); + assert.strictEqual(inp2.value, inp2.delta); + assert(containsEntry(inp2.entries, 'keydown', 'textarea')); + assert(interactionIDsMatch(inp2.entries)); + assert(inp2.entries[0].interactionId > inp1.entries[0].interactionId); + assert.strictEqual(inp2.navigationType, 'back_forward_cache'); + + await stubForwardBack(); + + await setBlockingTime('keydown', 0); + await setBlockingTime('pointerdown', 200); + + const button = await $('button'); + await button.click(); + + // Pause to ensure the interaction finishes (test is flakey without this). + await browser.pause(500); + + await stubVisibilityChange('hidden'); + await beaconCountIs(1); + + const [inp3] = await getBeacons(); + assert(inp3.value >= 0); + assert(inp3.id.match(/^v2-\d+-\d+$/)); + assert(inp1.id !== inp3.id); + assert.strictEqual(inp3.name, 'INP'); + assert.strictEqual(inp3.value, inp3.delta); + assert(containsEntry(inp3.entries, 'pointerdown', 'button')); + assert(interactionIDsMatch(inp3.entries)); + assert(inp3.entries[0].interactionId > inp2.entries[0].interactionId); + assert.strictEqual(inp3.navigationType, 'back_forward_cache'); + }); + + it('does not reports if there were no interactions', async function() { + if (!browserSupportsINP) this.skip(); + + await browser.url('/test/inp'); + + await stubVisibilityChange('hidden'); + + // Wait a bit to ensure no beacons were sent. + await browser.pause(1000); + + const beacons = await getBeacons(); + assert.strictEqual(beacons.length, 0); + }); +}); + + +const containsEntry = (entries, name, target) => { + return entries.findIndex((e) => e.name === name && e.target === target) > -1; +}; + +const interactionIDsMatch = (entries) => { + return entries.every((e) => e.interactionId === entries[0].interactionId); +}; + +const setBlockingTime = (event, value) => { + return browser.execute((event, value) => { + document.getElementById(`${event}-blocking-time`).value = value; + }, event, value); +}; diff --git a/test/utils/browserSupportsEntry.js b/test/utils/browserSupportsEntry.js index a212a945..3fbebca5 100644 --- a/test/utils/browserSupportsEntry.js +++ b/test/utils/browserSupportsEntry.js @@ -28,6 +28,12 @@ function browserSupportsEntry(type) { return false; } + // Firefox supports the event timing API but not `interactionId`. + if (type === 'event' && self.PerformanceEventTiming && + !('interactionId' in PerformanceEventTiming.prototype)) { + return false; + } + return window.PerformanceObserver && window.PerformanceObserver.supportedEntryTypes && window.PerformanceObserver.supportedEntryTypes.includes(type); diff --git a/test/views/inp.njk b/test/views/inp.njk new file mode 100644 index 00000000..7d742ddc --- /dev/null +++ b/test/views/inp.njk @@ -0,0 +1,99 @@ + + +{% extends 'layout.njk' %} + +{% block content %} +

INP Test

+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+ +

+ +

+ + + +

Navigate away

+ +

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin nec porta orci, ac sagittis augue. Nullam orci tellus, suscipit sed magna id, mattis iaculis ex. Etiam felis lectus, accumsan eu magna lacinia, lobortis tempus lacus. Donec nulla metus, blandit eget ullamcorper in, placerat eu massa. Curabitur vitae elementum orci, ac tincidunt neque. Maecenas accumsan odio sit amet arcu elementum, non vestibulum enim finibus. Phasellus malesuada lacinia suscipit. Cras ac gravida urna. In et mauris non tellus pretium ultrices. Fusce mattis a risus at tincidunt. Donec ac fringilla magna, nec suscipit lectus. Sed risus massa, rutrum ut leo quis, tempor dapibus dui. Proin in mauris non risus maximus tincidunt quis a mauris.

+ + +{% endblock %}