diff --git a/packages/next/client/index.js b/packages/next/client/index.js index 124b8ab35dc1efc..1e1721ae1abe3a9 100644 --- a/packages/next/client/index.js +++ b/packages/next/client/index.js @@ -11,6 +11,11 @@ import { HeadManagerContext } from '../next-server/lib/head-manager-context' import { RouterContext } from '../next-server/lib/router-context' import { parse as parseQs, stringify as stringifyQs } from 'querystring' import { isDynamicRoute } from '../next-server/lib/router/utils/is-dynamic' +import { + observeLayoutShift, + observeLargestContentfulPaint, + observePaint, +} from './performance-relayer' /// @@ -161,8 +166,14 @@ export default async ({ webpackHMR: passedWebpackHMR } = {}) => { const { page: app, mod } = await pageLoader.loadPageScript('/_app') App = app if (mod && mod.unstable_onPerformanceData) { - onPerfEntry = function({ name, startTime, value, duration }) { - mod.unstable_onPerformanceData({ name, startTime, value, duration }) + onPerfEntry = function({ name, startTime, value, duration, entryType }) { + mod.unstable_onPerformanceData({ + name, + startTime, + value, + duration, + entryType, + }) } } @@ -313,14 +324,9 @@ function renderReactElement(reactEl, domEl) { if (onPerfEntry && ST) { try { - const observer = new PerformanceObserver(list => { - list.getEntries().forEach(onPerfEntry) - }) - // Start observing paint entry types. - observer.observe({ - type: 'paint', - buffered: true, - }) + observeLayoutShift(onPerfEntry) + observeLargestContentfulPaint(onPerfEntry) + observePaint(onPerfEntry) } catch (e) { window.addEventListener('load', () => { performance.getEntriesByType('paint').forEach(onPerfEntry) diff --git a/packages/next/client/performance-relayer.js b/packages/next/client/performance-relayer.js new file mode 100644 index 000000000000000..2d2c9dae326c3b9 --- /dev/null +++ b/packages/next/client/performance-relayer.js @@ -0,0 +1,78 @@ +function isTypeSupported(type) { + if (self.PerformanceObserver && PerformanceObserver.supportedEntryTypes) { + return PerformanceObserver.supportedEntryTypes.includes(type) + } + return false +} + +export function observeLayoutShift(onPerfEntry) { + if (isTypeSupported('layout-shift')) { + let cumulativeScore = 0 + const observer = new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + // Only count layout shifts without recent user input. + if (!entry.hadRecentInput) { + cumulativeScore += entry.value + } + } + }) + observer.observe({ type: 'layout-shift', buffered: true }) + + document.addEventListener( + 'visibilitychange', + function clsObserver() { + if (document.visibilityState === 'hidden') { + // Force any pending records to be dispatched. + observer.takeRecords() + observer.disconnect() + removeEventListener('visibilitychange', clsObserver, true) + onPerfEntry({ + name: 'cumulative-layout-shift', + value: cumulativeScore, + }) + } + }, + true + ) + } +} + +export function observeLargestContentfulPaint(onPerfEntry) { + if (isTypeSupported('largest-contentful-paint')) { + // Create a variable to hold the latest LCP value (since it can change). + let lcp + + // Create the PerformanceObserver instance. + const observer = new PerformanceObserver(entryList => { + const entries = entryList.getEntries() + const lastEntry = entries[entries.length - 1] + lcp = lastEntry.renderTime || lastEntry.loadTime + }) + + observer.observe({ type: 'largest-contentful-paint', buffered: true }) + + document.addEventListener( + 'visibilitychange', + function lcpObserver() { + if (lcp && document.visibilityState === 'hidden') { + removeEventListener('visibilitychange', lcpObserver, true) + onPerfEntry({ + name: 'largest-contentful-paint', + value: lcp, + }) + } + }, + true + ) + } +} + +export function observePaint(onPerfEntry) { + const observer = new PerformanceObserver(list => { + list.getEntries().forEach(onPerfEntry) + }) + observer.observe({ + type: 'paint', + buffered: true, + }) +} diff --git a/test/integration/relay-analytics/pages/_app.js b/test/integration/relay-analytics/pages/_app.js index d8536b65b7a5bb7..c9a40c8672c2cde 100644 --- a/test/integration/relay-analytics/pages/_app.js +++ b/test/integration/relay-analytics/pages/_app.js @@ -8,5 +8,8 @@ export default class MyApp extends App {} Method is experimental and will eventually be handled in a Next.js plugin */ export function unstable_onPerformanceData(data) { - localStorage.setItem(data.name, data.value || data.startTime) + localStorage.setItem( + data.name || data.entryType, + data.value !== undefined ? data.value : data.startTime + ) } diff --git a/test/integration/relay-analytics/pages/index.js b/test/integration/relay-analytics/pages/index.js index 7a227b55ed81ce8..5730772dc4b9bb4 100644 --- a/test/integration/relay-analytics/pages/index.js +++ b/test/integration/relay-analytics/pages/index.js @@ -1,3 +1,8 @@ export default () => { - return

Hello!

+ return ( +
+

Foo!

+

bar!

+
+ ) } diff --git a/test/integration/relay-analytics/test/index.test.js b/test/integration/relay-analytics/test/index.test.js index d5c02861ec2b9ce..91263c21d0768fa 100644 --- a/test/integration/relay-analytics/test/index.test.js +++ b/test/integration/relay-analytics/test/index.test.js @@ -30,13 +30,34 @@ describe('Analytics relayer', () => { const firstContentfulPaint = parseFloat( await browser.eval('localStorage.getItem("first-contentful-paint")') ) - expect(h1Text).toMatch(/Hello!/) + let largestContentfulPaint = await browser.eval( + 'localStorage.getItem("largest-contentful-paint")' + ) + let cls = await browser.eval( + 'localStorage.getItem("cumulative-layout-shift")' + ) + expect(h1Text).toMatch(/Foo!/) expect(data).not.toBeNaN() expect(data).toBeGreaterThan(0) expect(firstPaint).not.toBeNaN() expect(firstPaint).toBeGreaterThan(0) expect(firstContentfulPaint).not.toBeNaN() expect(firstContentfulPaint).toBeGreaterThan(0) + expect(largestContentfulPaint).toBeNull() + expect(cls).toBeNull() + // Create an artificial layout shift + await browser.eval('document.querySelector("h1").style.display = "none"') + await browser.refresh() + await browser.waitForElementByCss('h1') + largestContentfulPaint = parseFloat( + await browser.eval('localStorage.getItem("largest-contentful-paint")') + ) + cls = parseFloat( + await browser.eval('localStorage.getItem("cumulative-layout-shift")') + ) + expect(cls).not.toBeNull() + expect(largestContentfulPaint).not.toBeNaN() + expect(largestContentfulPaint).toBeGreaterThan(0) await browser.close() }) }) diff --git a/test/integration/size-limit/test/index.test.js b/test/integration/size-limit/test/index.test.js index 08a1262ded5a73e..0acf2859cfc5069 100644 --- a/test/integration/size-limit/test/index.test.js +++ b/test/integration/size-limit/test/index.test.js @@ -80,7 +80,7 @@ describe('Production response size', () => { ) // These numbers are without gzip compression! - const delta = responseSizesBytes - 230 * 1024 + const delta = responseSizesBytes - 231 * 1024 expect(delta).toBeLessThanOrEqual(1024) // don't increase size more than 1kb expect(delta).toBeGreaterThanOrEqual(-1024) // don't decrease size more than 1kb without updating target }) @@ -100,7 +100,7 @@ describe('Production response size', () => { ) // These numbers are without gzip compression! - const delta = responseSizesBytes - 163 * 1024 + const delta = responseSizesBytes - 164 * 1024 expect(delta).toBeLessThanOrEqual(1024) // don't increase size more than 1kb expect(delta).toBeGreaterThanOrEqual(-1024) // don't decrease size more than 1kb without updating target })