Skip to content

Commit

Permalink
Merge pull request #233 from GoogleChrome/prerender
Browse files Browse the repository at this point in the history
Add support for prerendered pages
  • Loading branch information
philipwalton committed Jun 19, 2022
2 parents 1caa058 + 30279d0 commit 11b6df7
Show file tree
Hide file tree
Showing 14 changed files with 294 additions and 65 deletions.
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

0 comments on commit 11b6df7

Please sign in to comment.