diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx
index 86bdc3f6a275b1..e4e62b483ce031 100644
--- a/packages/next/client/index.tsx
+++ b/packages/next/client/index.tsx
@@ -18,13 +18,20 @@ 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 { webVitalsCallbacks } from '../vitals/vitals'
///
@@ -271,7 +278,8 @@ export async function initNext(opts: { webpackHMR?: any } = {}) {
const { component: app, exports: mod } = appEntrypoint
CachedApp = app as AppComponent
- if (mod && mod.reportWebVitals) {
+ const exportedReportWebVitals = mod && mod.reportWebVitals
+ if (exportedReportWebVitals) {
onPerfEntry = ({
id,
name,
@@ -291,7 +299,7 @@ export async function initNext(opts: { webpackHMR?: any } = {}) {
perfStartEntry = entries[0].startTime
}
- mod.reportWebVitals({
+ const webVitals: NextWebVitalsMetric = {
id: id || uniqueID,
name,
startTime: startTime || perfStartEntry,
@@ -300,7 +308,9 @@ export async function initNext(opts: { webpackHMR?: any } = {}) {
entryType === 'mark' || entryType === 'measure'
? 'custom'
: 'web-vital',
- })
+ }
+ exportedReportWebVitals?.(webVitals)
+ webVitalsCallbacks.forEach((callback) => callback(webVitals))
}
}
diff --git a/packages/next/taskfile.js b/packages/next/taskfile.js
index af761177fe7fe4..85bc0580844968 100644
--- a/packages/next/taskfile.js
+++ b/packages/next/taskfile.js
@@ -938,6 +938,7 @@ export async function compile(task, opts) {
'pages',
'lib',
'client',
+ 'vitals',
'telemetry',
'shared',
'server_wasm',
@@ -999,6 +1000,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
@@ -1055,6 +1064,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)
diff --git a/packages/next/vitals.d.ts b/packages/next/vitals.d.ts
new file mode 100644
index 00000000000000..de7874c7a584ff
--- /dev/null
+++ b/packages/next/vitals.d.ts
@@ -0,0 +1 @@
+export * from './dist/vitals/index'
diff --git a/packages/next/vitals.js b/packages/next/vitals.js
new file mode 100644
index 00000000000000..6b6964b06d9381
--- /dev/null
+++ b/packages/next/vitals.js
@@ -0,0 +1 @@
+module.exports = require('./dist/vitals/index')
diff --git a/packages/next/vitals/index.tsx b/packages/next/vitals/index.tsx
new file mode 100644
index 00000000000000..c7298ecc671dee
--- /dev/null
+++ b/packages/next/vitals/index.tsx
@@ -0,0 +1 @@
+export { useWebVitalsReport } from './vitals'
diff --git a/packages/next/vitals/vitals.tsx b/packages/next/vitals/vitals.tsx
new file mode 100644
index 00000000000000..857e285a2b3e4b
--- /dev/null
+++ b/packages/next/vitals/vitals.tsx
@@ -0,0 +1,18 @@
+import { useEffect, useState } from 'react'
+import { NextWebVitalsMetric } from '../pages/_app'
+
+type ReportWebVitalsCallback = (webVitals: NextWebVitalsMetric) => any
+export const webVitalsCallbacks = new Set()
+
+export function useWebVitalsReport(callback: ReportWebVitalsCallback) {
+ // call on initial render, in a very early phase
+ useState(() => {
+ webVitalsCallbacks.add(callback)
+ })
+
+ useEffect(() => {
+ return () => {
+ webVitalsCallbacks.delete(callback)
+ }
+ })
+}
diff --git a/test/integration/relay-analytics/test/hook-impl-app.js b/test/integration/relay-analytics/test/hook-impl-app.js
new file mode 100644
index 00000000000000..bc45abcbb42615
--- /dev/null
+++ b/test/integration/relay-analytics/test/hook-impl-app.js
@@ -0,0 +1,14 @@
+/* global localStorage */
+import { useWebVitalsReport } from 'next/vitals'
+
+export default function MyApp({ Component, pageProps }) {
+ useWebVitalsReport((data) => {
+ localStorage.setItem(
+ data.name || data.entryType,
+ data.value !== undefined ? data.value : data.startTime
+ )
+ })
+ return
+}
+
+export function reportWebVitals() {}
diff --git a/test/integration/relay-analytics/test/index.test.js b/test/integration/relay-analytics/test/index.test.js
index 495cdfc974da4d..c2130602653d8d 100644
--- a/test/integration/relay-analytics/test/index.test.js
+++ b/test/integration/relay-analytics/test/index.test.js
@@ -1,29 +1,67 @@
/* eslint-env jest */
import { join } from 'path'
+import fs from 'fs-extra'
import webdriver from 'next-webdriver'
-import { killApp, findPort, nextBuild, nextStart, check } from 'next-test-utils'
+import {
+ File,
+ killApp,
+ findPort,
+ nextBuild,
+ nextStart,
+ check,
+ waitFor,
+} from 'next-test-utils'
const appDir = join(__dirname, '../')
+const customApp = new File(join(appDir, 'pages/_app.js'))
+const hookImplCustomAppContent = fs.readFileSync(
+ join(__dirname, 'hook-impl-app.js'),
+ { encoding: 'utf-8' }
+)
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', () => {
- let stdout
- beforeAll(async () => {
- appPort = await findPort()
- ;({ stdout } = await nextBuild(appDir, [], {
- env: { VERCEL_ANALYTICS_ID: 'test' },
- stdout: true,
- }))
- server = await nextStart(appDir, appPort)
+ describe('exported analytics', () => {
+ beforeAll(async () => await buildApp())
+ afterAll(async () => await killServer())
+ runTest()
})
- afterAll(() => killApp(server))
+ describe('Hook based analytics', () => {
+ beforeAll(async () => {
+ customApp.write(hookImplCustomAppContent)
+ await buildApp()
+ })
+
+ 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")')
@@ -87,4 +125,4 @@ describe('Analytics relayer', () => {
expect(stdout).toMatch('Next.js Analytics')
await browser.close()
})
-})
+}