diff --git a/src/lib/getActivationStart.ts b/src/lib/getActivationStart.ts new file mode 100644 index 00000000..03b6446c --- /dev/null +++ b/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; +}; diff --git a/src/lib/getVisibilityWatcher.ts b/src/lib/getVisibilityWatcher.ts index 7e1e4eff..84772956 100644 --- a/src/lib/getVisibilityWatcher.ts +++ b/src/lib/getVisibilityWatcher.ts @@ -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 = () => { diff --git a/src/lib/initMetric.ts b/src/lib/initMetric.ts index 8ec77001..5e65fa26 100644 --- a/src/lib/initMetric.ts +++ b/src/lib/initMetric.ts @@ -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, }; }; diff --git a/src/onFCP.ts b/src/onFCP.ts index d55c5e27..95eab27d 100644 --- a/src/onFCP.ts +++ b/src/onFCP.ts @@ -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'; @@ -31,7 +32,7 @@ export const onFCP = (onReport: ReportCallback, opts?: ReportOpts) => { let report: ReturnType; const handleEntries = (entries: Metric['entries']) => { - entries.forEach((entry) => { + (entries as PerformancePaintTiming[]).forEach((entry) => { if (entry.name === 'first-contentful-paint') { if (po) { po.disconnect(); @@ -39,7 +40,10 @@ export const onFCP = (onReport: ReportCallback, opts?: ReportOpts) => { // 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); } diff --git a/src/onLCP.ts b/src/onLCP.ts index a7b3bd53..23ea0d3e 100644 --- a/src/onLCP.ts +++ b/src/onLCP.ts @@ -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 = {}; @@ -34,14 +35,15 @@ export const onLCP = (onReport: ReportCallback, opts?: ReportOpts) => { let report: ReturnType; 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]; diff --git a/src/onTTFB.ts b/src/onTTFB.ts index 7ac517cd..aaf113c3 100644 --- a/src/onTTFB.ts +++ b/src/onTTFB.ts @@ -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); } } @@ -38,11 +44,15 @@ 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: @@ -50,7 +60,7 @@ export const onTTFB = (onReport: ReportCallback, opts?: ReportOpts) => { // 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); } diff --git a/src/types.ts b/src/types.ts index 6fb74751..e01d79b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { @@ -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(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 @@ -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 = @@ -96,7 +115,9 @@ export interface FirstInputPolyfillCallback { export type NavigationTimingPolyfillEntry = Omit + 'encodedBodySize' | 'decodedBodySize' | 'type'> & { + type?: PerformanceNavigationTiming['type']; +} export interface WebVitalsGlobal { firstInputPolyfill: (onFirstInput: FirstInputPolyfillCallback) => void; diff --git a/test/e2e/onFCP-test.js b/test/e2e/onFCP-test.js index 68c951da..0de0356d 100644 --- a/test/e2e/onFCP-test.js +++ b/test/e2e/onFCP-test.js @@ -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(); diff --git a/test/e2e/onLCP-test.js b/test/e2e/onLCP-test.js index 5d2ac004..7b8f7688 100644 --- a/test/e2e/onLCP-test.js +++ b/test/e2e/onLCP-test.js @@ -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(); @@ -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); diff --git a/test/e2e/onTTFB-test.js b/test/e2e/onTTFB-test.js index ed671439..cd2073f9 100644 --- a/test/e2e/onTTFB-test.js +++ b/test/e2e/onTTFB-test.js @@ -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'); diff --git a/test/utils/stubForwardBack.js b/test/utils/stubForwardBack.js index ed25f56e..5348a6b6 100644 --- a/test/utils/stubForwardBack.js +++ b/test/utils/stubForwardBack.js @@ -22,29 +22,7 @@ */ function stubForwardBack(visibilityStateAfterRestore) { return browser.executeAsync((visibilityStateAfterRestore, done) => { - window.dispatchEvent(new PageTransitionEvent('pagehide', { - persisted: true, - })); - Object.defineProperty(document, 'visibilityState', { - value: 'hidden', - configurable: true, - }); - document.body.hidden = true; - document.dispatchEvent(new Event('visibilitychange')); - - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (visibilityStateAfterRestore !== 'hidden') { - delete document.visibilityState; - document.body.hidden = false; - } - document.dispatchEvent(new Event('visibilitychange')); - window.dispatchEvent(new PageTransitionEvent('pageshow', { - persisted: true, - })); - done(); - }); - }); + self.__stubForwardBack(visibilityStateAfterRestore).then(done); }, visibilityStateAfterRestore); } diff --git a/test/utils/stubVisibilityChange.js b/test/utils/stubVisibilityChange.js index 32424b6d..d031ee0c 100644 --- a/test/utils/stubVisibilityChange.js +++ b/test/utils/stubVisibilityChange.js @@ -22,17 +22,7 @@ */ function stubVisibilityChange(visibilityState) { return browser.execute((visibilityState) => { - if (visibilityState === 'hidden') { - Object.defineProperty(document, 'visibilityState', { - value: visibilityState, - configurable: true, - }); - document.body.hidden = true; - } else { - delete document.visibilityState; - document.body.hidden = false; - } - document.dispatchEvent(new Event('visibilitychange')); + self.__stubVisibilityChange(visibilityState); }, visibilityState); } diff --git a/test/views/layout.njk b/test/views/layout.njk index 221e686e..e69a5ccd 100644 --- a/test/views/layout.njk +++ b/test/views/layout.njk @@ -13,13 +13,93 @@ See the License for the specific language governing permissions and limitations under the License. --> - + Web Vitals Test {% if polyfill %} @@ -73,7 +157,7 @@ } - +
{% block content %}{% endblock %}
diff --git a/test/views/ttfb.njk b/test/views/ttfb.njk index 4feabcae..05b9211e 100644 --- a/test/views/ttfb.njk +++ b/test/views/ttfb.njk @@ -17,7 +17,7 @@ {% block content %}

TTFB Test

- +

Text below the image

@@ -36,6 +36,17 @@ // Log for easier manual testing. console.log(ttfb); + // The stubbed `activationStart` is lost when stringified, so we convert first. + ttfb.entries = ttfb.entries.map((e) => { + const newObj = {}; + for (const k in e) { + if (typeof e[k] !== 'function') { + newObj[k] = e[k]; + } + } + return newObj; + }); + // Test sending the metric to an analytics endpoint. navigator.sendBeacon(`/collect`, JSON.stringify(ttfb)); }, {reportAllChanges: self.__reportAllChanges});