web-vitals
Version:
Easily measure performance metrics in JavaScript
193 lines • 10.3 kB
JavaScript
/*
* 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 { LCPEntryManager } from './lib/LCPEntryManager.js';
import { getBFCacheRestoreTime, onBFCacheRestore } from './lib/bfcache.js';
import { bindReporter } from './lib/bindReporter.js';
import { doubleRAF } from './lib/doubleRAF.js';
import { getActivationStart } from './lib/getActivationStart.js';
import { checkSoftNavsEnabled, storeSoftNavEntry } from './lib/softNavs.js';
import { getVisibilityWatcher } from './lib/getVisibilityWatcher.js';
import { initMetric } from './lib/initMetric.js';
import { initUnique } from './lib/initUnique.js';
import { observe } from './lib/observe.js';
import { whenActivated } from './lib/whenActivated.js';
import { whenIdleOrHidden } from './lib/whenIdleOrHidden.js';
/** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */
export const LCPThresholds = [2500, 4000];
/**
* Calculates the [LCP](https://web.dev/articles/lcp) value for the current page and
* calls the `callback` function once the value is ready (along with the
* relevant `largest-contentful-paint` performance entry used to determine the
* value). The reported value is a `DOMHighResTimeStamp`.
*
* If the `reportAllChanges` configuration option is set to `true`, the
* `callback` function will be called any time a new `largest-contentful-paint`
* performance entry is dispatched, or once the final value of the metric has
* been determined.
*/
export const onLCP = (onReport, opts = {}) => {
// As InteractionContentfulPaint entries used by soft navs can emit after
// LCP is finalized, we need a flag to know to ignore them.
let isFinalized = false;
const softNavsEnabled = checkSoftNavsEnabled(opts);
whenActivated(() => {
let visibilityWatcher = getVisibilityWatcher();
let metric = initMetric('LCP');
let report;
const lcpEntryManager = initUnique(opts, LCPEntryManager);
const initNewLCPMetric = (navigation, navigationId, navigationInteractionId, navigationURL, navigationStartTime) => {
metric = initMetric('LCP', -1, navigation, navigationId, navigationInteractionId, navigationURL, navigationStartTime);
report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges);
// Reset the finalized flag
isFinalized = false;
// If it's a soft nav, then need to reset the visibilityWatcher
if (navigation === 'soft-navigation') {
visibilityWatcher = getVisibilityWatcher(true);
}
};
const handleSoftNavEntry = (entry) => {
if (lcpEntryManager._softNavigationEntryMap && entry.navigationId) {
storeSoftNavEntry(lcpEntryManager._softNavigationEntryMap, entry);
}
if (!isFinalized)
report(true);
initNewLCPMetric('soft-navigation', entry.navigationId, entry.interactionId, entry.name, entry.startTime);
// Soft Navs should contain the largest paint until now, so handle that
// as if it just happened, then listen for more.
// It can however be null in rare circumstances
// (see https://github.com/GoogleChrome/web-vitals/issues/725)
const largestInteractionContentfulPaint = entry.getLargestInteractionContentfulPaint?.();
if (largestInteractionContentfulPaint) {
handleEntries([largestInteractionContentfulPaint]);
}
};
const handleEntries = (entries) => {
// If reportAllChanges is set or soft navs is enabled then call this
// function for each entry, otherwise only consider the last one.
if (!opts.reportAllChanges && !softNavsEnabled) {
entries = entries.slice(-1);
}
for (const entry of entries) {
if (!entry)
continue;
if (entry.entryType === 'soft-navigation') {
handleSoftNavEntry(entry);
continue;
}
let value = 0;
let metricEntries = [];
let renderTime = entry.startTime;
if (entry.entryType === 'largest-contentful-paint') {
// The startTime attribute returns the value of the renderTime if it is
// not 0, and the value of the loadTime otherwise. The activationStart
// reference is used because LCP should be relative to page activation
// rather than navigation start if the page was prerendered. But in cases
// where `activationStart` occurs after the LCP, this time should be
// clamped at 0.
value = Math.max(entry.startTime - getActivationStart(), 0);
lcpEntryManager._processEntry(entry);
metricEntries = [entry];
}
else if (entry.entryType === 'interaction-contentful-paint') {
const ICPEntry = entry;
// InteractionContentfulPaints should only happen after a
// PerformanceSoftNavigation so the metric should have been set
// with a non-zero navigationId mapping to a soft nav.
if (!metric.navigationId)
continue;
// Ignore interactions not for this soft nav
// (either paints that have bled into this interaction or paints when
// we should have already finalized)
if ('interactionId' in ICPEntry &&
ICPEntry.interactionId != metric.navigationInteractionId) {
continue;
}
renderTime = ICPEntry.largestContentfulPaint?.renderTime || 0;
// Paints should never be less than 0 but add cap just in case
value = Math.max(renderTime - entry.startTime, 0);
if (ICPEntry.largestContentfulPaint) {
lcpEntryManager._processEntry(ICPEntry.largestContentfulPaint);
metricEntries = [ICPEntry.largestContentfulPaint];
}
}
// Only report if the page wasn't hidden prior to LCP.
if (renderTime < visibilityWatcher.firstHiddenTime) {
metric.value = value;
metric.entries = metricEntries;
report();
}
}
};
const types = ['largest-contentful-paint'];
if (softNavsEnabled) {
types.push('interaction-contentful-paint', 'soft-navigation');
}
const po = observe(types, handleEntries);
if (po) {
report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges);
const finalizeEventTypes = ['keydown', 'click', 'visibilitychange'];
const finalizeLCP = (event) => {
if (event.isTrusted && !isFinalized) {
// Wrap the listener in an idle callback so it's run in a separate
// task to reduce potential INP impact.
// https://github.com/GoogleChrome/web-vitals/issues/383
const metricIdToFinalize = metric.id;
whenIdleOrHidden(() => {
if (!isFinalized) {
if (!softNavsEnabled) {
// Do some clean up since these won't be needed anymore.
po.disconnect();
for (const type of finalizeEventTypes) {
removeEventListener(type, finalizeLCP, { capture: true });
}
}
// As this is in a whenIdleOrHidden check, whether we're still
// on the metric you meant to finalize, and ignore if we've moved
// on in the meantime.
if (metricIdToFinalize === metric.id) {
isFinalized = true;
report(true);
}
}
});
}
};
// Finalize the current LCP after input or visibilitychange.
// Although the browser will automatically stop emitting entries in these
// cases, we don't know it's finalized, so we track to allow early report.
// Note: while scrolling is an input that stops LCP observation, it's
// unreliable since it can be programmatically generated.
// See: https://github.com/GoogleChrome/web-vitals/issues/75
for (const type of finalizeEventTypes) {
addEventListener(type, finalizeLCP, {
capture: true,
});
}
// Only report after a bfcache restore if the `PerformanceObserver`
// successfully registered.
onBFCacheRestore((event) => {
initNewLCPMetric('back-forward-cache', metric.navigationId, metric.navigationInteractionId, metric.navigationURL, getBFCacheRestoreTime());
report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges);
doubleRAF(() => {
metric.value = performance.now() - event.timeStamp;
isFinalized = true;
report(true);
});
});
}
});
};
//# sourceMappingURL=onLCP.js.map