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

Add support for prerendered pages #233

Merged
merged 1 commit into from Jun 19, 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
23 changes: 23 additions & 0 deletions src/lib/getActivationStart.ts
@@ -0,0 +1,23 @@
/*
* 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.
*/

import {getNavigationEntry} from './getNavigationEntry.js';


export const getActivationStart = (): number => {
const navEntry = getNavigationEntry();
return navEntry && navEntry.activationStart || 0;
};
5 changes: 4 additions & 1 deletion src/lib/getVisibilityWatcher.ts
Expand Up @@ -20,7 +20,10 @@ import {onHidden} from './onHidden.js';
let firstHiddenTime = -1;

const initHiddenTime = () => {
return document.visibilityState === 'hidden' ? 0 : Infinity;
// If the document is hidden and not prerendering, assume it was always
// hidden and the page was loaded in the background.
return document.visibilityState === 'hidden' &&
!document.prerendering ? 0 : Infinity;
}

const trackChanges = () => {
Expand Down
18 changes: 15 additions & 3 deletions src/lib/initMetric.ts
Expand Up @@ -16,19 +16,31 @@

import {isBFCacheRestore} from './bfcache.js';
import {generateUniqueID} from './generateUniqueID.js';
import {getActivationStart} from './getActivationStart.js';
import {getNavigationEntry} from './getNavigationEntry.js';
import {Metric} from '../types.js';


export const initMetric = (name: Metric['name'], value?: number): Metric => {
const navigationEntry = getNavigationEntry();
const navEntry = getNavigationEntry();
let navigationType: Metric['navigationType'];

if (isBFCacheRestore()) {
navigationType = 'back_forward_cache';
} else if (navEntry) {
if (document.prerendering || getActivationStart() > 0) {
navigationType = 'prerender';
} else {
navigationType = navEntry.type;
}
}

return {
name,
value: typeof value === 'undefined' ? -1 : value,
delta: 0,
entries: [],
id: generateUniqueID(),
navigationType: isBFCacheRestore() ? 'back_forward_cache' :
navigationEntry && navigationEntry.type,
navigationType,
};
};
8 changes: 6 additions & 2 deletions src/onFCP.ts
Expand Up @@ -16,6 +16,7 @@

import {onBFCacheRestore} from './lib/bfcache.js';
import {bindReporter} from './lib/bindReporter.js';
import {getActivationStart} from './lib/getActivationStart.js';
import {getVisibilityWatcher} from './lib/getVisibilityWatcher.js';
import {initMetric} from './lib/initMetric.js';
import {observe} from './lib/observe.js';
Expand All @@ -31,15 +32,18 @@ export const onFCP = (onReport: ReportCallback, opts?: ReportOpts) => {
let report: ReturnType<typeof bindReporter>;

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

// Only report if the page wasn't hidden prior to the first paint.
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
metric.value = entry.startTime;
// The activationStart reference is used because FCP should be
// relative to page activation rather than navigation start if the
// page was prerendered.
metric.value = entry.startTime - getActivationStart();
metric.entries.push(entry);
report(true);
}
Expand Down
14 changes: 8 additions & 6 deletions src/onLCP.ts
Expand Up @@ -16,11 +16,12 @@

import {onBFCacheRestore} from './lib/bfcache.js';
import {bindReporter} from './lib/bindReporter.js';
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 {onHidden} from './lib/onHidden.js';
import {Metric, ReportCallback, ReportOpts} from './types.js';
import {Metric, LargestContentfulPaint, ReportCallback, ReportOpts} from './types.js';


const reportedMetricIDs: Record<string, boolean> = {};
Expand All @@ -34,14 +35,15 @@ export const onLCP = (onReport: ReportCallback, opts?: ReportOpts) => {
let report: ReturnType<typeof bindReporter>;

const handleEntries = (entries: Metric['entries']) => {
const lastEntry = entries[entries.length - 1];
const lastEntry = (entries[entries.length - 1] as LargestContentfulPaint);
if (lastEntry) {
// The startTime attribute returns the value of the renderTime if it is
// not 0, and the value of the loadTime otherwise.
const value = lastEntry.startTime;
// 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.
const value = lastEntry.startTime - getActivationStart();

// If the page was hidden prior to paint time of the entry,
// ignore it and mark the metric as final, otherwise add the entry.
// Only report if the page wasn't hidden prior to LCP.
if (value < visibilityWatcher.firstHiddenTime) {
metric.value = value;
metric.entries = [lastEntry];
Expand Down
30 changes: 20 additions & 10 deletions src/onTTFB.ts
Expand Up @@ -19,15 +19,21 @@ import {initMetric} from './lib/initMetric.js';
import {onBFCacheRestore} from './lib/bfcache.js';
import {getNavigationEntry} from './lib/getNavigationEntry.js';
import {ReportCallback, ReportOpts} from './types.js';
import { getActivationStart } from './lib/getActivationStart.js';


const afterLoad = (callback: () => void) => {
if (document.readyState === 'complete') {
// Queue a task so the callback runs after `loadEventEnd`.
setTimeout(callback, 0);
/**
* Runs in the next task after the page is done loading and/or prerendering.
* @param callback
*/
const whenReady = (callback: () => void) => {
if (document.prerendering) {
addEventListener('prerenderingchange', () => whenReady(callback), true);
} else if (document.readyState !== 'complete') {
addEventListener('load', () => whenReady(callback), true);
} else {
// Queue a task so the callback runs after `loadEventEnd`.
addEventListener('load', () => setTimeout(callback, 0));
setTimeout(callback, 0);
}
}

Expand All @@ -38,19 +44,23 @@ export const onTTFB = (onReport: ReportCallback, opts?: ReportOpts) => {
let metric = initMetric('TTFB');
let report = bindReporter(onReport, metric, opts.reportAllChanges);

afterLoad(() => {
const navigationEntry = getNavigationEntry();
whenReady(() => {
const navEntry = getNavigationEntry();

if (navigationEntry) {
metric.value = navigationEntry.responseStart;
if (navEntry) {
// The activationStart reference is used because TTFB should be
// relative to page activation rather than navigation start if the
// page was prerendered. But in cases where `activationStart` occurs
// after the first byte is received, this time should be clamped at 0.
metric.value = Math.max(navEntry.responseStart - getActivationStart(), 0);

// In some cases the value reported is negative or is larger
// than the current page time. Ignore these cases:
// https://github.com/GoogleChrome/web-vitals/issues/137
// https://github.com/GoogleChrome/web-vitals/issues/162
if (metric.value < 0 || metric.value > performance.now()) return;

metric.entries = [navigationEntry];
metric.entries = [navEntry];

report(true);
}
Expand Down
29 changes: 25 additions & 4 deletions src/types.ts
Expand Up @@ -43,7 +43,7 @@ export interface Metric {
// by the Navigation Timing API (or `undefined` if the browser doesn't
// support that API). For pages that are restored from the bfcache, this
// value will be 'back_forward_cache'.
navigationType: NavigationTimingType | 'back_forward_cache' | undefined;
navigationType: NavigationTimingType | 'back_forward_cache' | 'prerender' | undefined;
}

export interface ReportCallback {
Expand All @@ -61,10 +61,23 @@ interface PerformanceEntryMap {
'paint': PerformancePaintTiming;
}

// Update built-in types to be more accurate.
declare global {
// https://wicg.github.io/nav-speculation/prerendering.html#document-prerendering
interface Document {
prerendering?: boolean
}
interface Performance {
getEntriesByType<K extends keyof PerformanceEntryMap>(type: K): PerformanceEntryMap[K][]
}
// https://w3c.github.io/event-timing/#sec-modifications-perf-timeline
interface PerformanceObserverInit {
durationThreshold?: number;
}
// https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension
interface PerformanceNavigationTiming {
activationStart?: number;
}
}

// https://wicg.github.io/event-timing/#sec-performance-event-timing
Expand All @@ -83,8 +96,14 @@ export interface LayoutShift extends PerformanceEntry {
hadRecentInput: boolean;
}

export interface PerformanceObserverInit {
durationThreshold?: number;
// https://w3c.github.io/largest-contentful-paint/#sec-largest-contentful-paint-interface
export interface LargestContentfulPaint extends PerformanceEntry {
renderTime: DOMHighResTimeStamp;
loadTime: DOMHighResTimeStamp;
size: number;
id: string;
url: string;
element?: Element;
}

export type FirstInputPolyfillEntry =
Expand All @@ -96,7 +115,9 @@ export interface FirstInputPolyfillCallback {

export type NavigationTimingPolyfillEntry = Omit<PerformanceNavigationTiming,
'initiatorType' | 'nextHopProtocol' | 'redirectCount' | 'transferSize' |
'encodedBodySize' | 'decodedBodySize'>
'encodedBodySize' | 'decodedBodySize' | 'type'> & {
type?: PerformanceNavigationTiming['type'];
}

export interface WebVitalsGlobal {
firstInputPolyfill: (onFirstInput: FirstInputPolyfillCallback) => void;
Expand Down
22 changes: 22 additions & 0 deletions test/e2e/onFCP-test.js
Expand Up @@ -49,6 +49,28 @@ describe('onFCP()', async function() {
assert.match(fcp.navigationType, /navigate|reload/);
});

it('accounts for time prerendering the page', async function() {
if (!browserSupportsFCP) this.skip();

await browser.url('/test/fcp?prerender=1');

await beaconCountIs(1);

const [fcp] = await getBeacons();

const activationStart = await browser.execute(() => {
return performance.getEntriesByType('navigation')[0].activationStart;
});

assert(fcp.value >= 0);
assert(fcp.id.match(/^v2-\d+-\d+$/));
assert.strictEqual(fcp.name, 'FCP');
assert.strictEqual(fcp.value, fcp.delta);
assert.strictEqual(fcp.entries.length, 1);
assert.strictEqual(fcp.entries[0].startTime - activationStart, fcp.value);
assert.strictEqual(fcp.navigationType, 'prerender');
});

it('does not report if the browser does not support FCP (including bfcache restores)', async function() {
if (browserSupportsFCP) this.skip();

Expand Down
28 changes: 26 additions & 2 deletions test/e2e/onLCP-test.js
Expand Up @@ -101,6 +101,28 @@ describe('onLCP()', async function() {
assertFullReportsAreCorrect(await getBeacons());
});

it('accounts for time prerendering the page', async function() {
if (!browserSupportsLCP) this.skip();

await browser.url('/test/lcp?prerender=1');

// Wait until all images are loaded and fully rendered.
await imagesPainted();

const activationStart = await browser.execute(() => {
return performance.getEntriesByType('navigation')[0].activationStart;
});

// Load a new page to trigger the hidden state.
await browser.url('about:blank');

await beaconCountIs(1);

const [lcp] = await getBeacons();
assert.strictEqual(lcp.entries[0].startTime - activationStart, lcp.value);
assert.strictEqual(lcp.navigationType, 'prerender');
});

it('does not report if the browser does not support LCP (including bfcache restores)', async function() {
if (browserSupportsLCP) this.skip();

Expand All @@ -117,8 +139,10 @@ describe('onLCP()', async function() {
const footer = await $('footer');
await footer.scrollIntoView();

// Load a new page to trigger the hidden state.
await browser.url('about:blank');
// Simulate a tab switch and switch back, which triggers reporting in
// browsers that support the API.
await stubVisibilityChange('hidden');
await stubVisibilityChange('visible');

// Wait a bit to ensure no beacons were sent.
await browser.pause(1000);
Expand Down
45 changes: 45 additions & 0 deletions test/e2e/onTTFB-test.js
Expand Up @@ -113,6 +113,51 @@ describe('onTTFB()', async function() {
assertValidEntry(ttfb.entries[0]);
});

it('accounts for time prerendering the page', async function() {
await browser.url('/test/ttfb?prerender=1');

const ttfb = await getTTFBBeacon();

if (browser.capabilities.browserName === 'firefox' && !ttfb) {
// Skipping test in Firefox due to entry not reported.
this.skip();
}

assert(ttfb.value >= 0);
assert.strictEqual(ttfb.value, ttfb.delta);
assert.strictEqual(ttfb.entries.length, 1);
assert.strictEqual(ttfb.navigationType, 'prerender');
assert.strictEqual(ttfb.value, Math.max(
0, ttfb.entries[0].responseStart - ttfb.entries[0].activationStart));

assertValidEntry(ttfb.entries[0]);
});

it('reports the correct value when run while prerendering', async function() {
// Use 500 so prerendering finishes before load but after the module runs.
await browser.url('/test/ttfb?prerender=500&imgDelay=1000');

const ttfb = await getTTFBBeacon();

if (browser.capabilities.browserName === 'firefox' && !ttfb) {
// Skipping test in Firefox due to entry not reported.
this.skip();
}

// Assert that prerendering finished after responseStart and before load.
assert(ttfb.entries[0].activationStart >= ttfb.entries[0].responseStart);
assert(ttfb.entries[0].activationStart <= ttfb.entries[0].loadEventEnd);

assert(ttfb.value >= 0);
assert.strictEqual(ttfb.value, ttfb.delta);
assert.strictEqual(ttfb.entries.length, 1);
assert.strictEqual(ttfb.navigationType, 'prerender');
assert.strictEqual(ttfb.value, Math.max(
0, ttfb.entries[0].responseStart - ttfb.entries[0].activationStart));

assertValidEntry(ttfb.entries[0]);
});

it('reports after a bfcache restore', async function() {
await browser.url('/test/ttfb');

Expand Down