Skip to content

Commit

Permalink
Defer all observers until after activation
Browse files Browse the repository at this point in the history
  • Loading branch information
philipwalton committed Nov 15, 2022
1 parent f009cd2 commit 7a15a64
Show file tree
Hide file tree
Showing 11 changed files with 427 additions and 491 deletions.
277 changes: 72 additions & 205 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
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
49 changes: 38 additions & 11 deletions src/lib/getVisibilityWatcher.ts
Expand Up @@ -15,24 +15,51 @@
*/

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 a 'prerenderingchange' event and
// the document is 'hidden', assume the tab was activated in a background
// state and 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 +69,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 +83,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
@@ -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
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
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);
});
});
});
});
}
}
});
};

0 comments on commit 7a15a64

Please sign in to comment.