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() }) -}) +}