diff --git a/src/getINP.ts b/src/getINP.ts new file mode 100644 index 00000000..580c778c --- /dev/null +++ b/src/getINP.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2020 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 {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 {onHidden} from './lib/onHidden.js'; +import {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); +} + +function estimateInteractionCount(): number { + return (maxKnownInteractionId > 0) ? ((maxKnownInteractionId - minKnownInteractionId) / 7) + 1 : 0; +} + +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; + } + } else { + largestINPEntries.push(entry); + } + + largestINPEntries.sort((a,b) => b.duration - a.duration); + largestINPEntries.splice(NUM_ENTRIES_TO_STORE); +} + +function getCurrentINPEntry(): PerformanceEventTiming { + const interactionCount = estimateInteractionCount(); + const which = Math.min(largestINPEntries.length-1, Math.floor(interactionCount / EVERY_N)); + return largestINPEntries[which]; +} + +export const getINP = (onReport: ReportHandler, reportAllChanges?: boolean) => { + 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)); + } + + // 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(); + }; + + const po = observe('event', entryHandler as PerformanceEntryHandler); + report = bindReporter(onReport, metric, reportAllChanges); + + if (po) { + onHidden(() => { + po.takeRecords().map(entryHandler as PerformanceEntryHandler); + report(true); + }, true); + + // TODO: Test this + onBFCacheRestore(() => { + largestINPEntries.length = 0; + metric = initMetric('INP'); + report = bindReporter(onReport, metric, reportAllChanges); + }); + } +}; diff --git a/src/index.ts b/src/index.ts index 4e4461b4..0e1a5879 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export {getCLS} from './getCLS.js'; export {getFCP} from './getFCP.js'; export {getFID} from './getFID.js'; +export {getINP} from './getINP.js'; export {getLCP} from './getLCP.js'; export {getTTFB} from './getTTFB.js'; diff --git a/src/lib/observe.ts b/src/lib/observe.ts index 7c40e005..20cd3799 100644 --- a/src/lib/observe.ts +++ b/src/lib/observe.ts @@ -41,7 +41,11 @@ export const observe = ( const po: PerformanceObserver = new PerformanceObserver((l) => l.getEntries().map(callback)); - po.observe({type, buffered: true}); + // This durationThreshold means event timing will fire often, potentially with + // performance implications. It is much noisier than other PO observers. + // We may want to leave the default 104ms setting, or use an even high threshold + // and treat all pages without long responsiveness issues as if it were 0ms. + po.observe({type, buffered: true, durationThreshold: 0 } as PerformanceObserverInit); return po; } } catch (e) { diff --git a/src/types.ts b/src/types.ts index dcdc8196..f161c827 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,7 @@ export interface Metric { // The name of the metric (in acronym form). - name: 'CLS' | 'FCP' | 'FID' | 'LCP' | 'TTFB'; + name: 'CLS' | 'FCP' | 'FID' | 'INP' | 'LCP' | 'TTFB'; // The current value of the metric. value: number; @@ -50,6 +50,7 @@ export interface PerformanceEventTiming extends PerformanceEntry { duration: DOMHighResTimeStamp; cancelable?: boolean; target?: Element; + interactionId?: number; } export type FirstInputPolyfillEntry =