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

Defer all observers until after activation #282

Merged
merged 2 commits into from
Nov 15, 2022
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
277 changes: 72 additions & 205 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@
"@wdio/selenium-standalone-service": "^7.25.1",
"@wdio/spec-reporter": "^7.25.1",
"body-parser": "^1.20.0",
"chromedriver": "^106.0.1",
"chromedriver": "^107.0.3",
"eslint": "^8.24.0",
"eslint-config-google": "^0.14.0",
"express": "^4.18.1",
Expand Down
51 changes: 40 additions & 11 deletions src/lib/getVisibilityWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,53 @@
*/

import {onBFCacheRestore} from './bfcache.js';
import {onHidden} from './onHidden.js';


let firstHiddenTime = -1;

const initHiddenTime = () => {
// If the document is hidden and not prerendering, assume it was always
// hidden and the page was loaded in the background.
// If the document is hidden when this code runs, assume it was always
// hidden and the page was loaded in the background, with the one exception
// that visibility state is always 'hidden' during prerendering, so we have
// to ignore that case until prerendering finishes (see: `prerenderingchange`
// event logic below).
return document.visibilityState === 'hidden' &&
!document.prerendering ? 0 : Infinity;
}

const trackChanges = () => {
// Update the time if/when the document becomes hidden.
onHidden(({timeStamp}) => {
firstHiddenTime = timeStamp
}, true);
const onVisibilityUpdate = (event: Event) => {
// If the document is 'hidden' and no previous hidden timestamp has been
// set, update it based on the current event data.
if (document.visibilityState === 'hidden' && firstHiddenTime > -1) {
// If the event is a 'visibilitychange' event, it means the page was
// visible prior to this change, so the event timestamp is the first
// hidden time.
// However, if the event is not a 'visibilitychange' event, then it must
// be a 'prerenderingchange' event, and the fact that the document is
// still 'hidden' from the above check means the tab was activated
// in a background state and so has always been hidden.
firstHiddenTime = event.type === 'visibilitychange' ? event.timeStamp : 0;

// Remove all listeners now that a `firstHiddenTime` value has been set.
removeChangeListeners();
}
}

const addChangeListeners = () => {
addEventListener('visibilitychange', onVisibilityUpdate, true);
// IMPORTANT: when a page is prerendering, its `visibilityState` is
// 'hidden', so in order to account for cases where this module checks for
// visibility during prerendering, an additional check after prerendering
// completes is also required.
addEventListener('prerenderingchange', onVisibilityUpdate, true);
};

const removeChangeListeners = () => {
removeEventListener('visibilitychange', onVisibilityUpdate, true);
removeEventListener('prerenderingchange', onVisibilityUpdate, true);
};


export const getVisibilityWatcher = () => {
if (firstHiddenTime < 0) {
// If the document is hidden when this code runs, assume it was hidden
Expand All @@ -42,11 +71,11 @@ export const getVisibilityWatcher = () => {
if (window.__WEB_VITALS_POLYFILL__) {
firstHiddenTime = window.webVitals.firstHiddenTime;
if (firstHiddenTime === Infinity) {
trackChanges();
addChangeListeners();
}
} else {
firstHiddenTime = initHiddenTime();
trackChanges();
addChangeListeners();
}

// Reset the time on bfcache restores.
Expand All @@ -56,7 +85,7 @@ export const getVisibilityWatcher = () => {
// https://bugs.chromium.org/p/chromium/issues/detail?id=1133363
setTimeout(() => {
firstHiddenTime = initHiddenTime();
trackChanges();
addChangeListeners();
}, 0);
});
}
Expand Down
24 changes: 24 additions & 0 deletions src/lib/whenActivated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* 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.
*/


export const whenActivated = (callback: () => void) => {
if (document.prerendering) {
addEventListener('prerenderingchange', () => callback(), true);
} else {
callback();
}
}
134 changes: 69 additions & 65 deletions src/onCLS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {initMetric} from './lib/initMetric.js';
import {observe} from './lib/observe.js';
import {onHidden} from './lib/onHidden.js';
import {bindReporter} from './lib/bindReporter.js';
import {whenActivated} from './lib/whenActivated.js';
import {onFCP} from './onFCP.js';
import {CLSMetric, CLSReportCallback, ReportOpts} from './types.js';

Expand Down Expand Up @@ -51,80 +52,83 @@ export const onCLS = (onReport: CLSReportCallback, opts?: ReportOpts) => {
// Set defaults
opts = opts || {};

// https://web.dev/cls/#what-is-a-good-cls-score
const thresholds = [0.1, 0.25];
whenActivated(() => {
// https://web.dev/cls/#what-is-a-good-cls-score
const thresholds = [0.1, 0.25];

// Start monitoring FCP so we can only report CLS if FCP is also reported.
// Note: this is done to match the current behavior of CrUX.
if (!isMonitoringFCP) {
onFCP((metric) => {
fcpValue = metric.value;
});
isMonitoringFCP = true;
}

const onReportWrapped: CLSReportCallback = (arg) => {
if (fcpValue > -1) {
onReport(arg);
// Start monitoring FCP so we can only report CLS if FCP is also reported.
// Note: this is done to match the current behavior of CrUX.
if (!isMonitoringFCP) {
onFCP((metric) => {
fcpValue = metric.value;
});
isMonitoringFCP = true;
}
};

let metric = initMetric('CLS', 0);
let report: ReturnType<typeof bindReporter>;
const onReportWrapped: CLSReportCallback = (arg) => {
if (fcpValue > -1) {
onReport(arg);
}
};

let sessionValue = 0;
let sessionEntries: PerformanceEntry[] = [];
let metric = initMetric('CLS', 0);
let report: ReturnType<typeof bindReporter>;

// const handleEntries = (entries: Metric['entries']) => {
const handleEntries = (entries: LayoutShift[]) => {
entries.forEach((entry) => {
// Only count layout shifts without recent user input.
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
let sessionValue = 0;
let sessionEntries: PerformanceEntry[] = [];

// If the entry occurred less than 1 second after the previous entry and
// less than 5 seconds after the first entry in the session, include the
// entry in the current session. Otherwise, start a new session.
if (sessionValue &&
entry.startTime - lastSessionEntry.startTime < 1000 &&
entry.startTime - firstSessionEntry.startTime < 5000) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}

// If the current session value is larger than the current CLS value,
// update CLS and the entries contributing to it.
if (sessionValue > metric.value) {
metric.value = sessionValue;
metric.entries = sessionEntries;
report();
}
}
});
};
// const handleEntries = (entries: Metric['entries']) => {
const handleEntries = (entries: LayoutShift[]) => {
entries.forEach((entry) => {
// Only count layout shifts without recent user input.
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

const po = observe('layout-shift', handleEntries);
if (po) {
report = bindReporter(
onReportWrapped, metric, thresholds, opts.reportAllChanges);
// If the entry occurred less than 1 second after the previous entry
// and less than 5 seconds after the first entry in the session,
// include the entry in the current session. Otherwise, start a new
// session.
if (sessionValue &&
entry.startTime - lastSessionEntry.startTime < 1000 &&
entry.startTime - firstSessionEntry.startTime < 5000) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}

onHidden(() => {
handleEntries(po.takeRecords() as CLSMetric['entries']);
report(true);
});
// If the current session value is larger than the current CLS value,
// update CLS and the entries contributing to it.
if (sessionValue > metric.value) {
metric.value = sessionValue;
metric.entries = sessionEntries;
report();
}
}
});
};

// Only report after a bfcache restore if the `PerformanceObserver`
// successfully registered.
onBFCacheRestore(() => {
sessionValue = 0;
fcpValue = -1;
metric = initMetric('CLS', 0);
const po = observe('layout-shift', handleEntries);
if (po) {
report = bindReporter(
onReportWrapped, metric, thresholds, opts!.reportAllChanges);
});
}

onHidden(() => {
handleEntries(po.takeRecords() as CLSMetric['entries']);
report(true);
});

// Only report after a bfcache restore if the `PerformanceObserver`
// successfully registered.
onBFCacheRestore(() => {
sessionValue = 0;
fcpValue = -1;
metric = initMetric('CLS', 0);
report = bindReporter(
onReportWrapped, metric, thresholds, opts!.reportAllChanges);
});
}
});
};
73 changes: 38 additions & 35 deletions src/onFCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {getActivationStart} from './lib/getActivationStart.js';
import {getVisibilityWatcher} from './lib/getVisibilityWatcher.js';
import {initMetric} from './lib/initMetric.js';
import {observe} from './lib/observe.js';
import {whenActivated} from './lib/whenActivated.js';
import {FCPMetric, FCPReportCallback, ReportOpts} from './types.js';

/**
Expand All @@ -32,51 +33,53 @@ export const onFCP = (onReport: FCPReportCallback, opts?: ReportOpts) => {
// Set defaults
opts = opts || {};

// https://web.dev/fcp/#what-is-a-good-fcp-score
const thresholds = [1800, 3000];
whenActivated(() => {
// https://web.dev/fcp/#what-is-a-good-fcp-score
const thresholds = [1800, 3000];

const visibilityWatcher = getVisibilityWatcher();
let metric = initMetric('FCP');
let report: ReturnType<typeof bindReporter>;
const visibilityWatcher = getVisibilityWatcher();
let metric = initMetric('FCP');
let report: ReturnType<typeof bindReporter>;

const handleEntries = (entries: FCPMetric['entries']) => {
(entries as PerformancePaintTiming[]).forEach((entry) => {
if (entry.name === 'first-contentful-paint') {
po!.disconnect();
const handleEntries = (entries: FCPMetric['entries']) => {
(entries as PerformancePaintTiming[]).forEach((entry) => {
if (entry.name === 'first-contentful-paint') {
po!.disconnect();

// Only report if the page wasn't hidden prior to the first paint.
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
// The activationStart reference is used because FCP should be
// relative to page activation rather than navigation start if the
// page was prerendered. But in cases where `activationStart` occurs
// after the FCP, this time should be clamped at 0.
metric.value = Math.max(entry.startTime - getActivationStart(), 0);
metric.entries.push(entry);
report(true);
// Only report if the page wasn't hidden prior to the first paint.
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
// The activationStart reference is used because FCP should be
// relative to page activation rather than navigation start if the
// page was prerendered. But in cases where `activationStart` occurs
// after the FCP, this time should be clamped at 0.
metric.value = Math.max(entry.startTime - getActivationStart(), 0);
metric.entries.push(entry);
report(true);
}
}
}
});
};

const po = observe('paint', handleEntries);
});
};

if (po) {
report = bindReporter(
onReport, metric, thresholds, opts!.reportAllChanges);
const po = observe('paint', handleEntries);

// Only report after a bfcache restore if the `PerformanceObserver`
// successfully registered or the `paint` entry exists.
onBFCacheRestore((event) => {
metric = initMetric('FCP');
if (po) {
report = bindReporter(
onReport, metric, thresholds, opts!.reportAllChanges);

requestAnimationFrame(() => {
// Only report after a bfcache restore if the `PerformanceObserver`
// successfully registered or the `paint` entry exists.
onBFCacheRestore((event) => {
metric = initMetric('FCP');
report = bindReporter(
onReport, metric, thresholds, opts!.reportAllChanges);

requestAnimationFrame(() => {
metric.value = performance.now() - event.timeStamp;
report(true);
requestAnimationFrame(() => {
metric.value = performance.now() - event.timeStamp;
report(true);
});
});
});
});
}
}
});
};