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

Ensure CLS is only reported if page was visible #149

Merged
merged 1 commit into from May 13, 2021
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 2 additions & 6 deletions README.md
Expand Up @@ -444,14 +444,11 @@ The following table lists all the bundles distributed with the `web-vitals` pack

Most developers will generally want to use the "standard" bundle (either the ES module or UMD version, depending on your build system), as it's the easiest to use out of the box and integrate into existing build tools.

However, there are a few good reasons to consider using the "base+polyfill" version, for example:

- FID can be measured in all browsers.
- FCP, FID, and LCP will be more accurate in some cases (since the polyfill detects the page's initial `visibilityState` earlier).
However, developers willing to manage the additional usage complexity should consider the "base+polyfill" bundle if they would like to measure FID in all browsers.

### How the polyfill works

The `polyfill.js` script adds event listeners (to track FID cross-browser), and it records initial page visibility state as well as the timestamp of the first visibility change to hidden (to improve the accuracy of FCP, LCP, and FID).
The `polyfill.js` script adds event listeners that record the event processing delay of the first input, and then removes those event listeners after the first input occurs.

In order for it to work properly, the script must be the first script added to the page, and it must run before the browser renders any content to the screen. This is why it needs to be added to the `<head>` of the document.

Expand Down Expand Up @@ -530,7 +527,6 @@ If using the "base+polyfill" build, the `polyfill.js` script creates the global
interface WebVitalsGlobal {
firstInputPolyfill: (onFirstInput: FirstInputPolyfillCallback) => void;
resetFirstInputPolyfill: () => void;
firstHiddenTime: number;
}
```

Expand Down
20 changes: 18 additions & 2 deletions src/getCLS.ts
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

import {getVisibilityWatcher} from './lib/getVisibilityWatcher.js';
import {initMetric} from './lib/initMetric.js';
import {observe, PerformanceEntryHandler} from './lib/observe.js';
import {onHidden} from './lib/onHidden.js';
Expand All @@ -29,6 +30,20 @@ interface LayoutShift extends PerformanceEntry {
}

export const getCLS = (onReport: ReportHandler, reportAllChanges?: boolean) => {
const visibilityWatcher = getVisibilityWatcher();

const onReportWrapped: ReportHandler = (arg) => {
// Only report if the page was visible at some point in its lifecycle.
// Note: this doesn't technically match the current behavior of CrUX, which
// only includes pages that report FCP. However, we plan to change the
// behavior of CrUX in the future, and matching it would couple CLS to FCP
// in an awkward way, so in this library we only ignore CLS if the page
// was never visible (which should be the same as CrUX in most)
if (visibilityWatcher.firstVisibleTime < performance.now()) {
onReport(arg);
}
};

let metric = initMetric('CLS', 0);
let report: ReturnType<typeof bindReporter>;

Expand Down Expand Up @@ -56,14 +71,15 @@ export const getCLS = (onReport: ReportHandler, reportAllChanges?: boolean) => {
if (sessionValue > metric.value) {
metric.value = sessionValue;
metric.entries = sessionEntries;

report();
}
}
};

const po = observe('layout-shift', entryHandler as PerformanceEntryHandler);
if (po) {
report = bindReporter(onReport, metric, reportAllChanges);
report = bindReporter(onReportWrapped, metric, reportAllChanges);

onHidden(() => {
po.takeRecords().map(entryHandler as PerformanceEntryHandler);
Expand All @@ -73,7 +89,7 @@ export const getCLS = (onReport: ReportHandler, reportAllChanges?: boolean) => {
onBFCacheRestore(() => {
sessionValue = 0;
metric = initMetric('CLS', 0);
report = bindReporter(onReport, metric, reportAllChanges);
report = bindReporter(onReportWrapped, metric, reportAllChanges);
});
}
};
6 changes: 3 additions & 3 deletions src/getFCP.ts
Expand Up @@ -16,15 +16,15 @@

import {bindReporter} from './lib/bindReporter.js';
import {finalMetrics} from './lib/finalMetrics.js';
import {getFirstHidden} from './lib/getFirstHidden.js';
import {getVisibilityWatcher} from './lib/getVisibilityWatcher.js';
import {initMetric} from './lib/initMetric.js';
import {observe} from './lib/observe.js';
import {onBFCacheRestore} from './lib/onBFCacheRestore.js';
import {ReportHandler} from './types.js';


export const getFCP = (onReport: ReportHandler, reportAllChanges?: boolean) => {
const firstHidden = getFirstHidden();
const visibilityWatcher = getVisibilityWatcher();
let metric = initMetric('FCP');
let report: ReturnType<typeof bindReporter>;

Expand All @@ -35,7 +35,7 @@ export const getFCP = (onReport: ReportHandler, reportAllChanges?: boolean) => {
}

// Only report if the page wasn't hidden prior to the first paint.
if (entry.startTime < firstHidden.timeStamp) {
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
metric.value = entry.startTime;
metric.entries.push(entry);
finalMetrics.add(metric);
Expand Down
6 changes: 3 additions & 3 deletions src/getFID.ts
Expand Up @@ -16,7 +16,7 @@

import {bindReporter} from './lib/bindReporter.js';
import {finalMetrics} from './lib/finalMetrics.js';
import {getFirstHidden} from './lib/getFirstHidden.js';
import {getVisibilityWatcher} from './lib/getVisibilityWatcher.js';
import {initMetric} from './lib/initMetric.js';
import {observe, PerformanceEntryHandler} from './lib/observe.js';
import {onBFCacheRestore} from './lib/onBFCacheRestore.js';
Expand All @@ -26,13 +26,13 @@ import {FirstInputPolyfillCallback, PerformanceEventTiming, ReportHandler} from


export const getFID = (onReport: ReportHandler, reportAllChanges?: boolean) => {
const firstHidden = getFirstHidden();
const visibilityWatcher = getVisibilityWatcher();
let metric = initMetric('FID');
let report: ReturnType<typeof bindReporter>;

const entryHandler = (entry: PerformanceEventTiming) => {
// Only report if the page wasn't hidden prior to the first input.
if (entry.startTime < firstHidden.timeStamp) {
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
metric.value = entry.processingStart - entry.startTime;
metric.entries.push(entry);
finalMetrics.add(metric);
Expand Down
6 changes: 3 additions & 3 deletions src/getLCP.ts
Expand Up @@ -16,7 +16,7 @@

import {bindReporter} from './lib/bindReporter.js';
import {finalMetrics} from './lib/finalMetrics.js';
import {getFirstHidden} from './lib/getFirstHidden.js';
import {getVisibilityWatcher} from './lib/getVisibilityWatcher.js';
import {initMetric} from './lib/initMetric.js';
import {observe, PerformanceEntryHandler} from './lib/observe.js';
import {onBFCacheRestore} from './lib/onBFCacheRestore.js';
Expand All @@ -25,7 +25,7 @@ import {ReportHandler} from './types.js';


export const getLCP = (onReport: ReportHandler, reportAllChanges?: boolean) => {
const firstHidden = getFirstHidden();
const visibilityWatcher = getVisibilityWatcher();
let metric = initMetric('LCP');
let report: ReturnType<typeof bindReporter>;

Expand All @@ -36,7 +36,7 @@ export const getLCP = (onReport: ReportHandler, reportAllChanges?: boolean) => {

// If the page was hidden prior to paint time of the entry,
// ignore it and mark the metric as final, otherwise add the entry.
if (value < firstHidden.timeStamp) {
if (value < visibilityWatcher.firstHiddenTime) {
metric.value = value;
metric.entries.push(entry);
}
Expand Down
65 changes: 0 additions & 65 deletions src/lib/getFirstHidden.ts

This file was deleted.

78 changes: 78 additions & 0 deletions src/lib/getVisibilityWatcher.ts
@@ -0,0 +1,78 @@
/*
* 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 {onBFCacheRestore} from './onBFCacheRestore.js';


interface TimeStamps {
hidden?: number;
visible?: number;
}

interface VisibilityWatcher {
firstHiddenTime: number;
firstVisibleTime: number;
}


let timeStamps: TimeStamps;

const initTimeStamps = () => {
// Assume the visibilityState when this code is run was the visibilityState
// since page load. This isn't a perfect heuristic, but it's the best we can
// do until an API is available to support querying past visibilityState.
timeStamps = {
'visible': document.visibilityState === 'visible' ? 0 : Infinity,
'hidden': document.visibilityState === 'hidden' ? 0 : Infinity,
};
}

const onVisibilityChange = (event: Event) => {
timeStamps[document.visibilityState] = event.timeStamp;
if (timeStamps.hidden! + timeStamps.visible! > 0) {
removeEventListener('visibilitychange', onVisibilityChange, true);
}
}

const trackChanges = () => {
addEventListener('visibilitychange', onVisibilityChange, true);
};

export const getVisibilityWatcher = () : VisibilityWatcher => {
if (!timeStamps) {
initTimeStamps();
trackChanges();

// Reset the time on bfcache restores.
onBFCacheRestore(() => {
// Schedule a task in order to track the `visibilityState` once it's
// had an opportunity to change to visible in all browsers.
// https://bugs.chromium.org/p/chromium/issues/detail?id=1133363
setTimeout(() => {
initTimeStamps();
trackChanges();
}, 0);
});
}
return {
get firstHiddenTime() {
return timeStamps.hidden!;
},
get firstVisibleTime() {
return timeStamps.visible!;
},
};
};
30 changes: 0 additions & 30 deletions src/lib/polyfills/getFirstHiddenTimePolyfill.ts

This file was deleted.

6 changes: 0 additions & 6 deletions src/polyfill.ts
Expand Up @@ -15,15 +15,9 @@
*/

import {firstInputPolyfill, resetFirstInputPolyfill} from './lib/polyfills/firstInputPolyfill.js';
import {getFirstHiddenTime} from './lib/polyfills/getFirstHiddenTimePolyfill.js';

resetFirstInputPolyfill();
self.webVitals = {
firstInputPolyfill: firstInputPolyfill,
resetFirstInputPolyfill: resetFirstInputPolyfill,
// TODO: in v2 this should just be `getFirstHiddenTime()`,
// but in v1 it needs to be a getter to avoid creating a breaking change.
get firstHiddenTime() {
return getFirstHiddenTime();
},
};
1 change: 0 additions & 1 deletion src/types.ts
Expand Up @@ -66,7 +66,6 @@ export type NavigationTimingPolyfillEntry = Omit<PerformanceNavigationTiming,
export interface WebVitalsGlobal {
firstInputPolyfill: (onFirstInput: FirstInputPolyfillCallback) => void;
resetFirstInputPolyfill: () => void;
firstHiddenTime: number;
}

declare global {
Expand Down