diff --git a/lerna.json b/lerna.json index 31c4bf3f15f4940..31b40b561ff671e 100644 --- a/lerna.json +++ b/lerna.json @@ -17,5 +17,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "9.5.6-canary.3" + "version": "9.5.6-canary.4" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 9f80dce7422e27a..cdc4da3a40d3870 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "keywords": [ "react", "next", diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index c121a60d83f4c34..cd567c33134faea 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "description": "ESLint plugin for NextJS.", "main": "lib/index.js", "license": "MIT", diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index b268a61269c967f..68216067b91bf9e 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 1b01e984039d2b1..d65c70694aa3baf 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "license": "MIT", "dependencies": { "chalk": "4.1.0", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index c7e143efbb33344..07fb25f1c4c9db8 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 83c89ecd84c2113..ebe360394957b84 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-google-analytics/package.json b/packages/next-plugin-google-analytics/package.json index 3258e918de964d9..e5f8f584d6a4f74 100644 --- a/packages/next-plugin-google-analytics/package.json +++ b/packages/next-plugin-google-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-google-analytics", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-google-analytics" diff --git a/packages/next-plugin-sentry/package.json b/packages/next-plugin-sentry/package.json index 0e610a43781ba71..c0228d2e404261f 100644 --- a/packages/next-plugin-sentry/package.json +++ b/packages/next-plugin-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-sentry", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-sentry" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 5312f29313c338b..8b859cf6b79d909 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index c0dc3c3f923a0f5..b7d9f3fca664de7 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 9bb9b9a9f43f881..30d72b64e38a718 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 046ad6a69aa74f4..9462c1e5dabcc39 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -1159,6 +1159,15 @@ export default async function build( printCustomRoutes({ redirects, rewrites, headers }) } + if (config.experimental.analyticsId) { + console.log( + chalk.bold.green('Next.js Analytics') + + ' is enabled for this production build. ' + + "You'll receive a Real Experience Score computed by all of your visitors." + ) + console.log('') + } + if (tracer) { const parsedResults = await tracer.profiler.stopProfiling() await new Promise((resolve) => { diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index fdfd07ffe49c297..0df798ee8034364 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1015,6 +1015,9 @@ export default async function getBaseWebpackConfig( 'process.env.__NEXT_i18n_SUPPORT': JSON.stringify( !!config.experimental.i18n ), + 'process.env.__NEXT_ANALYTICS_ID': JSON.stringify( + config.experimental.analyticsId + ), ...(isServer ? { // Fix bad-actors in the npm ecosystem (e.g. `node-formidable`) diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 6274fcb841fb318..7d96a5a886bc672 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -16,14 +16,12 @@ type ImageData = { breakpoints?: number[] } -type ImageProps = { +type ImageProps = Omit & { src: string - host: string - sizes: string - breakpoints: number[] - priority: boolean - unoptimized: boolean - rest: any[] + host?: string + sizes?: string + priority?: boolean + unoptimized?: boolean } let imageData: any = process.env.__NEXT_IMAGE_OPTS @@ -73,15 +71,15 @@ type PreloadData = { src: string host: string widths: number[] - sizes: string - unoptimized: boolean + sizes?: string + unoptimized?: boolean } function generatePreload({ src, host, widths, - unoptimized, + unoptimized = false, sizes, }: PreloadData): ReactElement { // This function generates an image preload that makes use of the "imagesrcset" and "imagesizes" @@ -106,8 +104,8 @@ export default function Image({ src, host, sizes, - unoptimized, - priority, + unoptimized = false, + priority = false, ...rest }: ImageProps) { // Sanity Checks: diff --git a/packages/next/client/performance-relayer.ts b/packages/next/client/performance-relayer.ts index 1a2dfe1cb899c6d..434e4b8dc77d79d 100644 --- a/packages/next/client/performance-relayer.ts +++ b/packages/next/client/performance-relayer.ts @@ -8,6 +8,7 @@ import { ReportHandler, } from 'web-vitals' +const initialHref = location.href let isRegistered = false let userReportHandler: ReportHandler | undefined @@ -15,6 +16,45 @@ function onReport(metric: Metric) { if (userReportHandler) { userReportHandler(metric) } + + // This code is not shipped, executed, or present in the client-side + // JavaScript bundle unless explicitly enabled in your application. + // + // When this feature is enabled, we'll make it very clear by printing a + // message during the build (`next build`). + if ( + process.env.NODE_ENV === 'production' && + // This field is empty unless you explicitly configure it: + process.env.__NEXT_ANALYTICS_ID + ) { + const body: Record = { + dsn: process.env.__NEXT_ANALYTICS_ID, + id: metric.id, + page: window.__NEXT_DATA__.page, + href: initialHref, + event_name: metric.name, + value: metric.value.toString(), + speed: + 'connection' in navigator && + navigator['connection'] && + 'effectiveType' in navigator['connection'] + ? (navigator['connection']['effectiveType'] as string) + : '', + } + + const blob = new Blob([new URLSearchParams(body).toString()], { + // This content type is necessary for `sendBeacon`: + type: 'application/x-www-form-urlencoded', + }) + const vitalsUrl = 'https://vitals.vercel-analytics.com/v1/vitals' + ;(navigator.sendBeacon && navigator.sendBeacon(vitalsUrl, blob)) || + fetch(vitalsUrl, { + body: blob, + method: 'POST', + credentials: 'omit', + keepalive: true, + }) + } } export default (onPerfEntry?: ReportHandler) => { diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index dabf639b15cf5b0..753b0ea0242c8be 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -60,6 +60,7 @@ const defaultConfig: { [key: string]: any } = { optimizeImages: false, scrollRestoration: false, i18n: false, + analyticsId: process.env.VERCEL_ANALYTICS_ID || '', }, future: { excludeDefaultMomentLocales: false, diff --git a/packages/next/package.json b/packages/next/package.json index 9ce71b3008e3de1..b86d18b4ae1c5b6 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -79,10 +79,10 @@ "@babel/runtime": "7.11.2", "@babel/types": "7.11.5", "@hapi/accept": "5.0.1", - "@next/env": "9.5.6-canary.3", - "@next/polyfill-module": "9.5.6-canary.3", - "@next/react-dev-overlay": "9.5.6-canary.3", - "@next/react-refresh-utils": "9.5.6-canary.3", + "@next/env": "9.5.6-canary.4", + "@next/polyfill-module": "9.5.6-canary.4", + "@next/react-dev-overlay": "9.5.6-canary.4", + "@next/react-refresh-utils": "9.5.6-canary.4", "ast-types": "0.13.2", "babel-plugin-transform-define": "2.0.0", "babel-plugin-transform-react-remove-prop-types": "0.4.24", @@ -126,7 +126,7 @@ "react-dom": "^16.6.0" }, "devDependencies": { - "@next/polyfill-nomodule": "9.5.6-canary.3", + "@next/polyfill-nomodule": "9.5.6-canary.4", "@taskr/clear": "1.1.0", "@taskr/esnext": "1.1.0", "@taskr/watch": "1.1.0", diff --git a/packages/react-dev-overlay/package.json b/packages/react-dev-overlay/package.json index e461edcfc8319ff..7a8137645cab0ae 100644 --- a/packages/react-dev-overlay/package.json +++ b/packages/react-dev-overlay/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-dev-overlay", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "description": "A development-only overlay for developing React applications.", "repository": { "url": "vercel/next.js", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index a20aa5ea5a9ff32..6d001559aec28be 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "9.5.6-canary.3", + "version": "9.5.6-canary.4", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/test/integration/relay-analytics-disabled/pages/_app.js b/test/integration/relay-analytics-disabled/pages/_app.js new file mode 100644 index 000000000000000..5cc9a4d48488850 --- /dev/null +++ b/test/integration/relay-analytics-disabled/pages/_app.js @@ -0,0 +1,15 @@ +/* global localStorage */ +/* eslint-disable camelcase */ +import App from 'next/app' + +export default class MyApp extends App {} + +/* + Method is experimental and will eventually be handled in a Next.js plugin +*/ +export function reportWebVitals(data) { + localStorage.setItem( + data.name || data.entryType, + data.value !== undefined ? data.value : data.startTime + ) +} diff --git a/test/integration/relay-analytics-disabled/pages/index.js b/test/integration/relay-analytics-disabled/pages/index.js new file mode 100644 index 000000000000000..46c828a80fd0d44 --- /dev/null +++ b/test/integration/relay-analytics-disabled/pages/index.js @@ -0,0 +1,25 @@ +if (typeof navigator !== 'undefined') { + window.__BEACONS = window.__BEACONS || [] + + navigator.sendBeacon = async function () { + const args = await Promise.all( + [...arguments].map((v) => { + if (v instanceof Blob) { + return v.text() + } + return v + }) + ) + + window.__BEACONS.push(args) + } +} + +export default () => { + return ( +
+

Foo!

+

bar!

+
+ ) +} diff --git a/test/integration/relay-analytics-disabled/test/index.test.js b/test/integration/relay-analytics-disabled/test/index.test.js new file mode 100644 index 000000000000000..9a5edbaa9080472 --- /dev/null +++ b/test/integration/relay-analytics-disabled/test/index.test.js @@ -0,0 +1,72 @@ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { findPort, killApp, nextBuild, nextStart } from 'next-test-utils' +import webdriver from 'next-webdriver' +import path, { join } from 'path' + +const appDir = join(__dirname, '../') +let appPort +let server +jest.setTimeout(1000 * 60 * 2) + +let buildManifest + +describe('Analytics relayer (disabled)', () => { + let stdout + beforeAll(async () => { + appPort = await findPort() + ;({ stdout } = await nextBuild(appDir, [], { + stdout: true, + })) + buildManifest = require(path.join( + appDir, + '.next/build-manifest.json' + ), 'utf8') + server = await nextStart(appDir, appPort) + }) + afterAll(() => killApp(server)) + + it('Does not relay any data', async () => { + const browser = await webdriver(appPort, '/') + await browser.waitForElementByCss('h1') + const h1Text = await browser.elementByCss('h1').text() + const firstContentfulPaint = parseFloat( + await browser.eval('localStorage.getItem("FCP")') + ) + + expect(h1Text).toMatch(/Foo!/) + + expect(firstContentfulPaint).not.toBeNaN() + expect(firstContentfulPaint).toBeGreaterThan(0) + + const beacons = (await browser.eval('window.__BEACONS')).map(([, value]) => + Object.fromEntries(new URLSearchParams(value)) + ) + + expect(beacons.length).toBe(0) + + expect(stdout).not.toMatch('Next.js Analytics') + + await browser.close() + }) + + it('Does not include the code', async () => { + const pageFiles = [ + ...new Set([ + ...buildManifest.pages['/'].filter((file) => file.endsWith('.js')), + ...buildManifest.pages['/_app'].filter((file) => file.endsWith('.js')), + ]), + ] + + expect(pageFiles.length).toBeGreaterThan(1) + + for (const pageFile of pageFiles) { + const content = await fs.readFile( + path.join(appDir, '.next', pageFile), + 'utf8' + ) + expect(content).not.toMatch('vercel-analytics') + } + }) +}) diff --git a/test/integration/relay-analytics/pages/index.js b/test/integration/relay-analytics/pages/index.js index 5730772dc4b9bb4..46c828a80fd0d44 100644 --- a/test/integration/relay-analytics/pages/index.js +++ b/test/integration/relay-analytics/pages/index.js @@ -1,3 +1,20 @@ +if (typeof navigator !== 'undefined') { + window.__BEACONS = window.__BEACONS || [] + + navigator.sendBeacon = async function () { + const args = await Promise.all( + [...arguments].map((v) => { + if (v instanceof Blob) { + return v.text() + } + return v + }) + ) + + window.__BEACONS.push(args) + } +} + export default () => { return (
diff --git a/test/integration/relay-analytics/test/index.test.js b/test/integration/relay-analytics/test/index.test.js index 18adc1501ca9c6f..38c87d816ea5a51 100644 --- a/test/integration/relay-analytics/test/index.test.js +++ b/test/integration/relay-analytics/test/index.test.js @@ -10,9 +10,13 @@ let server jest.setTimeout(1000 * 60 * 2) describe('Analytics relayer', () => { + let stdout beforeAll(async () => { appPort = await findPort() - await nextBuild(appDir) + ;({ stdout } = await nextBuild(appDir, [], { + env: { VERCEL_ANALYTICS_ID: 'test' }, + stdout: true, + })) server = await nextStart(appDir, appPort) }) afterAll(() => killApp(server)) @@ -54,6 +58,35 @@ describe('Analytics relayer', () => { expect(cls).not.toBeNull() expect(largestContentfulPaint).not.toBeNaN() expect(largestContentfulPaint).toBeGreaterThan(0) + + const beacons = (await browser.eval('window.__BEACONS')).map(([, value]) => + Object.fromEntries(new URLSearchParams(value)) + ) + + beacons.sort((a, b) => a.event_name.localeCompare(b.event_name)) + + expect(beacons.length).toBe(2) + expect(beacons[0]).toMatchObject({ + dsn: 'test', + event_name: 'FCP', + href: expect.stringMatching('http://'), + id: expect.stringContaining('-'), + page: '/', + speed: '4g', + value: expect.stringContaining('.'), + }) + expect(beacons[1]).toMatchObject({ + dsn: 'test', + event_name: 'TTFB', + href: expect.stringMatching('http://'), + id: expect.stringContaining('-'), + page: '/', + speed: '4g', + value: expect.stringContaining('.'), + }) + + expect(stdout).toMatch('Next.js Analytics') + await browser.close() }) })