Skip to content

Commit

Permalink
Experimental: hook version of reportWebVitals (#28769)
Browse files Browse the repository at this point in the history
### Experimental

Introduce `next/vitals` and `useExperimentalWebVitalsReport` API which is not limited by `_app`.

`pages/index.js`
```jsx
import { useExperimentalWebVitalsReport } from 'next/vitals'

export default function Index() {
   useExperimentalWebVitalsReport((metric) => {
    // handle metric...
  })
  return 'sup'
}
```
  • Loading branch information
huozhi committed Sep 24, 2021
1 parent e2e747e commit 741ef90
Show file tree
Hide file tree
Showing 10 changed files with 176 additions and 42 deletions.
69 changes: 39 additions & 30 deletions packages/next/client/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,21 @@ import {
assign,
} from '../shared/lib/router/utils/querystring'
import { setConfig } from '../shared/lib/runtime-config'
import { getURL, loadGetInitialProps, NEXT_DATA, ST } from '../shared/lib/utils'
import {
getURL,
loadGetInitialProps,
NextWebVitalsMetric,
NEXT_DATA,
ST,
} from '../shared/lib/utils'
import { Portal } from './portal'
import initHeadManager from './head-manager'
import PageLoader, { StyleSheetTuple } from './page-loader'
import measureWebVitals from './performance-relayer'
import { RouteAnnouncer } from './route-announcer'
import { createRouter, makePublicRouterInstance } from './router'
import isError from '../lib/is-error'
import { trackWebVitalMetric } from '../vitals/vitals'

/// <reference types="react-dom/experimental" />

Expand Down Expand Up @@ -273,37 +280,38 @@ export async function initNext(opts: { webpackHMR?: any } = {}) {

const { component: app, exports: mod } = appEntrypoint
CachedApp = app as AppComponent
if (mod && mod.reportWebVitals) {
onPerfEntry = ({
id,
name,
startTime,
value,
duration,
entryType,
entries,
}): void => {
// Combines timestamp with random number for unique ID
const uniqueID: string = `${Date.now()}-${
Math.floor(Math.random() * (9e12 - 1)) + 1e12
}`
let perfStartEntry: string | undefined

if (entries && entries.length) {
perfStartEntry = entries[0].startTime
}
const exportedReportWebVitals = mod && mod.reportWebVitals
onPerfEntry = ({
id,
name,
startTime,
value,
duration,
entryType,
entries,
}: any): void => {
// Combines timestamp with random number for unique ID
const uniqueID: string = `${Date.now()}-${
Math.floor(Math.random() * (9e12 - 1)) + 1e12
}`
let perfStartEntry: string | undefined

if (entries && entries.length) {
perfStartEntry = entries[0].startTime
}

mod.reportWebVitals({
id: id || uniqueID,
name,
startTime: startTime || perfStartEntry,
value: value == null ? duration : value,
label:
entryType === 'mark' || entryType === 'measure'
? 'custom'
: 'web-vital',
})
const webVitals: NextWebVitalsMetric = {
id: id || uniqueID,
name,
startTime: startTime || perfStartEntry,
value: value == null ? duration : value,
label:
entryType === 'mark' || entryType === 'measure'
? 'custom'
: 'web-vital',
}
exportedReportWebVitals?.(webVitals)
trackWebVitalMetric(webVitals)
}

const pageEntrypoint =
Expand Down Expand Up @@ -576,6 +584,7 @@ function markRenderComplete(): void {
.getEntriesByName('Next.js-route-change-to-render')
.forEach(onPerfEntry)
}

clearMarks()
;['Next.js-route-change-to-render', 'Next.js-render'].forEach((measure) =>
performance.clearMeasures(measure)
Expand Down
1 change: 1 addition & 0 deletions packages/next/client/performance-relayer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* global location */
import {
getCLS,
getFCP,
Expand Down
10 changes: 10 additions & 0 deletions packages/next/taskfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,7 @@ export async function compile(task, opts) {
'pages',
'lib',
'client',
'vitals',
'telemetry',
'trace',
'shared',
Expand Down Expand Up @@ -992,6 +993,14 @@ export async function client(task, opts) {
notify('Compiled client files')
}

export async function vitals(task, opts) {
await task
.source(opts.src || 'vitals/**/*.+(js|ts|tsx)')
.swc('vitals', { dev: opts.dev })
.target('dist/vitals')
notify('Compiled vitals files')
}

// export is a reserved keyword for functions
export async function nextbuildstatic(task, opts) {
await task
Expand Down Expand Up @@ -1056,6 +1065,7 @@ export default async function (task) {
await task.watch('build/**/*.+(js|ts|tsx)', 'nextbuild', opts)
await task.watch('export/**/*.+(js|ts|tsx)', 'nextbuildstatic', opts)
await task.watch('client/**/*.+(js|ts|tsx)', 'client', opts)
await task.watch('vitals/**/*.+(js|ts|tsx)', 'vitals', opts)
await task.watch('lib/**/*.+(js|ts|tsx)', 'lib', opts)
await task.watch('cli/**/*.+(js|ts|tsx)', 'cli', opts)
await task.watch('telemetry/**/*.+(js|ts|tsx)', 'telemetry', opts)
Expand Down
1 change: 1 addition & 0 deletions packages/next/vitals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './dist/vitals/index'
1 change: 1 addition & 0 deletions packages/next/vitals.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('./dist/vitals/index')
1 change: 1 addition & 0 deletions packages/next/vitals/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useExperimentalWebVitalsReport } from './vitals'
33 changes: 33 additions & 0 deletions packages/next/vitals/vitals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useEffect, useRef } from 'react'
import { NextWebVitalsMetric } from '../pages/_app'

type ReportWebVitalsCallback = (webVitals: NextWebVitalsMetric) => any
export const webVitalsCallbacks = new Set<ReportWebVitalsCallback>()
const metrics: NextWebVitalsMetric[] = []

export function trackWebVitalMetric(metric: NextWebVitalsMetric) {
metrics.push(metric)
webVitalsCallbacks.forEach((callback) => callback(metric))
}

export function useExperimentalWebVitalsReport(
callback: ReportWebVitalsCallback
) {
const metricIndexRef = useRef(0)

useEffect(() => {
// Flush calculated metrics
const reportMetric = (metric: NextWebVitalsMetric) => {
callback(metric)
metricIndexRef.current = metrics.length
}
for (let i = metricIndexRef.current; i < metrics.length; i++) {
reportMetric(metrics[i])
}

webVitalsCallbacks.add(reportMetric)
return () => {
webVitalsCallbacks.delete(reportMetric)
}
}, [callback])
}
9 changes: 8 additions & 1 deletion test/integration/relay-analytics/pages/_app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@ export default class MyApp extends App {}
/*
Method is experimental and will eventually be handled in a Next.js plugin
*/

// Below comment will be used for replacing exported report method with hook based one.
///* reportWebVitals
export function reportWebVitals(data) {
const name = data.name || data.entryType
localStorage.setItem(
data.name || data.entryType,
name,
data.value !== undefined ? data.value : data.startTime
)
const countMap = window.__BEACONS_COUNT
countMap.set(name, (countMap.get(name) || 0) + 1)
}
// reportWebVitals */
17 changes: 17 additions & 0 deletions test/integration/relay-analytics/pages/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/* global localStorage */
import { useExperimentalWebVitalsReport } from 'next/vitals'

if (typeof navigator !== 'undefined') {
window.__BEACONS = window.__BEACONS || []
window.__BEACONS_COUNT = new Map()

navigator.sendBeacon = async function () {
const args = await Promise.all(
Expand All @@ -16,6 +20,19 @@ if (typeof navigator !== 'undefined') {
}

export default () => {
// Below comment will be used for replacing exported report method with hook based one.
///* useExperimentalWebVitalsReport
useExperimentalWebVitalsReport((data) => {
const name = data.name || data.entryType
localStorage.setItem(
name,
data.value !== undefined ? data.value : data.startTime
)
const countMap = window.__BEACONS_COUNT
countMap.set(name, (countMap.get(name) || 0) + 1)
})
// useExperimentalWebVitalsReport */

return (
<div>
<h1>Foo!</h1>
Expand Down
76 changes: 65 additions & 11 deletions test/integration/relay-analytics/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,70 @@

import { join } from 'path'
import webdriver from 'next-webdriver'
import { killApp, findPort, nextBuild, nextStart, check } from 'next-test-utils'
import {
File,
killApp,
findPort,
nextBuild,
nextStart,
check,
} from 'next-test-utils'

const appDir = join(__dirname, '../')
const customApp = new File(join(appDir, 'pages/_app.js'))
const indexPage = new File(join(appDir, 'pages/index.js'))

let appPort
let server
let stdout
jest.setTimeout(1000 * 60 * 2)

async function buildApp() {
appPort = await findPort()
;({ stdout } = await nextBuild(appDir, [], {
env: { VERCEL_ANALYTICS_ID: 'test' },
stdout: true,
}))
server = await nextStart(appDir, appPort)
}
async function killServer() {
await killApp(server)
}

describe('Analytics relayer with exported method', () => {
beforeAll(async () => {
// Keep app exported reporting and comment the hook one
indexPage.replace('///* useExperimentalWebVitalsReport', '/*')
indexPage.replace('// useExperimentalWebVitalsReport */', '*/')
await buildApp()
})
afterAll(async () => {
indexPage.restore()
await killServer()
})
runTest()
})

describe('Analytics relayer', () => {
let stdout
describe('Analytics relayer with hook', () => {
beforeAll(async () => {
appPort = await findPort()
;({ stdout } = await nextBuild(appDir, [], {
env: { VERCEL_ANALYTICS_ID: 'test' },
stdout: true,
}))
server = await nextStart(appDir, appPort)
// Keep hook reporting and comment the app exported one
customApp.replace('///* reportWebVitals', '/*')
customApp.replace('// reportWebVitals */', '*/')
await buildApp()
})
afterAll(() => killApp(server))

afterAll(async () => {
customApp.restore()
await killServer()
})
runTest()
})

function runTest() {
it('Relays the data to user code', async () => {
const browser = await webdriver(appPort, '/')
await browser.waitForElementByCss('h1')

const h1Text = await browser.elementByCss('h1').text()
const data = parseFloat(
await browser.eval('localStorage.getItem("Next.js-hydration")')
Expand All @@ -46,6 +89,12 @@ describe('Analytics relayer', () => {
expect(firstContentfulPaint).toBeGreaterThan(0)
expect(largestContentfulPaint).toBeNull()
expect(cls).toBeNull()

const beaconsCountBeforeCLS = await browser.eval('window.__BEACONS_COUNT')
expect(
Object.values(beaconsCountBeforeCLS).every((value) => value === 1)
).toBe(true)

// Create an artificial layout shift
await browser.eval('document.querySelector("h1").style.display = "none"')
await browser.refresh()
Expand All @@ -69,6 +118,11 @@ describe('Analytics relayer', () => {
Object.fromEntries(new URLSearchParams(value))
)

const beaconsCountAfterCLS = await browser.eval('window.__BEACONS_COUNT')
expect(
Object.values(beaconsCountAfterCLS).every((value) => value === 2)
).toBe(true)

expect(beacons.length).toBe(2)

for (const beacon of beacons) {
Expand All @@ -86,4 +140,4 @@ describe('Analytics relayer', () => {
expect(stdout).toMatch('Next.js Analytics')
await browser.close()
})
})
}

0 comments on commit 741ef90

Please sign in to comment.