From 5b23b25e28ad6fe25a5ec0129010b288701595ce Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Tue, 23 Jan 2024 12:05:53 +0100 Subject: [PATCH 01/15] add route support --- apps/nextjs/app/blog/[slug]/page.tsx | 7 + apps/nextjs/app/blog/page.tsx | 10 ++ apps/nextjs/app/layout.tsx | 7 +- apps/nextjs/components/withAnalytics.tsx | 2 +- apps/nextjs/next.config.js | 1 + apps/nextjs/package.json | 2 +- apps/nextjs/pages/_app.tsx | 11 ++ apps/nextjs/pages/api/test.ts | 11 +- apps/nextjs/pages/before-send/second.tsx | 2 +- apps/nextjs/pages/navigation/first.tsx | 2 +- apps/nextjs/pages/navigation/second.tsx | 2 +- packages/web/package.json | 27 +++- packages/web/src/generic.ts | 28 +++- packages/web/src/nextjs/index.tsx | 26 ++++ packages/web/src/nextjs/utils.ts | 22 +++ packages/web/src/react.tsx | 32 ++-- packages/web/src/types.ts | 11 +- packages/web/src/utils.ts | 32 ++++ packages/web/tsup.config.js | 14 ++ pnpm-lock.yaml | 186 +++++++++++++++++++++-- 20 files changed, 393 insertions(+), 42 deletions(-) create mode 100644 apps/nextjs/app/blog/[slug]/page.tsx create mode 100644 apps/nextjs/app/blog/page.tsx create mode 100644 apps/nextjs/pages/_app.tsx create mode 100644 packages/web/src/nextjs/index.tsx create mode 100644 packages/web/src/nextjs/utils.ts diff --git a/apps/nextjs/app/blog/[slug]/page.tsx b/apps/nextjs/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..a4e438d --- /dev/null +++ b/apps/nextjs/app/blog/[slug]/page.tsx @@ -0,0 +1,7 @@ +export default function BlogPage({ params }: { params: { slug: string } }) { + return ( +
+

{params.slug}

+
+ ); +} diff --git a/apps/nextjs/app/blog/page.tsx b/apps/nextjs/app/blog/page.tsx new file mode 100644 index 0000000..ec90e7b --- /dev/null +++ b/apps/nextjs/app/blog/page.tsx @@ -0,0 +1,10 @@ +import Link from 'next/link'; + +export default function Blog() { + return ( +
+ My first blog post + Feature just got released +
+ ); +} diff --git a/apps/nextjs/app/layout.tsx b/apps/nextjs/app/layout.tsx index 27c627c..2aaf10b 100644 --- a/apps/nextjs/app/layout.tsx +++ b/apps/nextjs/app/layout.tsx @@ -1,3 +1,5 @@ +import { Analytics } from '@vercel/analytics/next'; + export const metadata = { title: 'Next.js', description: 'Generated by Next.js', @@ -10,7 +12,10 @@ export default function RootLayout({ }) { return ( - {children} + + + {children} + ); } diff --git a/apps/nextjs/components/withAnalytics.tsx b/apps/nextjs/components/withAnalytics.tsx index b1862fa..f74a8a4 100644 --- a/apps/nextjs/components/withAnalytics.tsx +++ b/apps/nextjs/components/withAnalytics.tsx @@ -1,4 +1,4 @@ -import { Analytics, AnalyticsProps } from '@vercel/analytics/react'; +import { Analytics, AnalyticsProps } from '@vercel/analytics/next'; import React from 'react'; export function withAnalytics

>( diff --git a/apps/nextjs/next.config.js b/apps/nextjs/next.config.js index 3b56536..e1c2c4b 100644 --- a/apps/nextjs/next.config.js +++ b/apps/nextjs/next.config.js @@ -3,6 +3,7 @@ const nextConfig = { experimental: { serverActions: true, }, + // reactStrictMode: false, }; module.exports = nextConfig; diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 9337ee9..87cd57b 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@vercel/analytics": "workspace:*", - "next": "13.5.4", + "next": "14.1.0", "react": "18.2.0", "react-dom": "18.2.0" }, diff --git a/apps/nextjs/pages/_app.tsx b/apps/nextjs/pages/_app.tsx new file mode 100644 index 0000000..39d6b0c --- /dev/null +++ b/apps/nextjs/pages/_app.tsx @@ -0,0 +1,11 @@ +import { Analytics } from '@vercel/analytics/next'; +import type { AppProps } from 'next/app'; + +export default function App({ Component, pageProps }: AppProps) { + return ( + <> + + + + ); +} diff --git a/apps/nextjs/pages/api/test.ts b/apps/nextjs/pages/api/test.ts index 2c4e4f9..cc4af1d 100644 --- a/apps/nextjs/pages/api/test.ts +++ b/apps/nextjs/pages/api/test.ts @@ -1,16 +1,15 @@ import { track } from '@vercel/analytics/server'; import { NextFetchEvent, NextRequest } from 'next/server'; + export const config = { runtime: 'edge', }; async function handler(request: NextRequest, event: NextFetchEvent) { - event.waitUntil( - track('Pages Api Route', { - runtime: 'edge', - router: 'pages', - }) - ); + track('Pages Api Route', { + runtime: 'edge', + router: 'pages', + }); return new Response('OK'); } diff --git a/apps/nextjs/pages/before-send/second.tsx b/apps/nextjs/pages/before-send/second.tsx index 0e0865e..fae6c88 100644 --- a/apps/nextjs/pages/before-send/second.tsx +++ b/apps/nextjs/pages/before-send/second.tsx @@ -8,4 +8,4 @@ function Page() { ); } -export default withAnalytics(Page); +export default Page; diff --git a/apps/nextjs/pages/navigation/first.tsx b/apps/nextjs/pages/navigation/first.tsx index 38064af..9ffec37 100644 --- a/apps/nextjs/pages/navigation/first.tsx +++ b/apps/nextjs/pages/navigation/first.tsx @@ -10,4 +10,4 @@ function Page() { ); } -export default withAnalytics(Page); +export default Page; diff --git a/apps/nextjs/pages/navigation/second.tsx b/apps/nextjs/pages/navigation/second.tsx index 0e0865e..fae6c88 100644 --- a/apps/nextjs/pages/navigation/second.tsx +++ b/apps/nextjs/pages/navigation/second.tsx @@ -8,4 +8,4 @@ function Page() { ); } -export default withAnalytics(Page); +export default Page; diff --git a/packages/web/package.json b/packages/web/package.json index e4a70e6..0913656 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/analytics", - "version": "1.1.2", + "version": "1.2.0-beta.1", "description": "Gain real-time traffic insights with Vercel Web Analytics", "keywords": [ "analytics", @@ -11,7 +11,6 @@ "directory": "packages/web" }, "license": "MPL-2.0", - "type": "module", "exports": { "./package.json": "./package.json", ".": { @@ -24,6 +23,11 @@ "import": "./dist/react/index.js", "require": "./dist/react/index.cjs" }, + "./next": { + "browser": "./dist/next/index.mjs", + "import": "./dist/next/index.mjs", + "require": "./dist/next/index.js" + }, "./server": { "node": "./dist/server/index.js", "edge-light": "./dist/server/index.js", @@ -32,8 +36,8 @@ "default": "./dist/server/index.cjs" } }, - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "typesVersions": { "*": { "*": [ @@ -44,6 +48,9 @@ ], "server": [ "dist/server/index.d.ts" + ], + "next": [ + "dist/next/index.d.ts" ] } }, @@ -84,5 +91,17 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "tsup": "7.1.0" + }, + "peerDependencies": { + "next": ">= 13", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "next": { + "optional": true + }, + "react": { + "optional": true + } } } diff --git a/packages/web/src/generic.ts b/packages/web/src/generic.ts index 5bd7237..3bf94d1 100644 --- a/packages/web/src/generic.ts +++ b/packages/web/src/generic.ts @@ -9,6 +9,10 @@ import { isProduction, } from './utils'; +export const DEV_SCRIPT_URL = + 'https://va.vercel-scripts.com/v1/script.debug.js'; +export const PROD_SCRIPT_URL = '/_vercel/insights/script.js'; + /** * Injects the Vercel Web Analytics script into the page head and starts tracking page views. Read more in our [documentation](https://vercel.com/docs/concepts/analytics/package). * @param [props] - Analytics options. @@ -20,7 +24,9 @@ import { * @param [props.beforeSend] - A middleware function to modify events before they are sent. Should return the event object or `null` to cancel the event. */ function inject( - props: AnalyticsProps = { + props: AnalyticsProps & { + framework?: string; + } = { debug: true, } ): void { @@ -34,18 +40,22 @@ function inject( window.va?.('beforeSend', props.beforeSend); } - const src = isDevelopment() - ? 'https://va.vercel-scripts.com/v1/script.debug.js' - : '/_vercel/insights/script.js'; + const src = + props.scriptSrc || (isDevelopment() ? DEV_SCRIPT_URL : PROD_SCRIPT_URL); if (document.head.querySelector(`script[src*="${src}"]`)) return; const script = document.createElement('script'); script.src = src; script.defer = true; - script.setAttribute('data-sdkn', packageName); + script.dataset.sdkn = + packageName + (props.framework ? `/${props.framework}` : ''); script.setAttribute('data-sdkv', version); + if (props.disableAutoTrack) { + script.setAttribute('data-disable-auto-track', '1'); + } + script.onerror = (): void => { const errorMessage = isDevelopment() ? 'Please check if any ad blockers are enabled and try again.' @@ -110,7 +120,13 @@ function track( } } -export { inject, track }; +function pageview({ route }: { route?: string }): void { + window.va?.('pageview', { + route, + }); +} + +export { inject, track, pageview }; export type { AnalyticsProps }; // eslint-disable-next-line import/no-default-export -- Default export is intentional diff --git a/packages/web/src/nextjs/index.tsx b/packages/web/src/nextjs/index.tsx new file mode 100644 index 0000000..3f2166b --- /dev/null +++ b/packages/web/src/nextjs/index.tsx @@ -0,0 +1,26 @@ +import React, { Suspense } from 'react'; +import { Analytics as AnalyticsScript } from '../react'; +import type { AnalyticsProps } from '../types'; +import { useRoute } from './utils'; + +type Props = Omit; + +function AnalyticsComponent(props: Props): React.ReactElement { + const route = useRoute(); + + return ( + + + + ); +} + +export function Analytics(props: Props): React.ReactElement { + return ( + + + + ); +} + +export type { AnalyticsProps }; diff --git a/packages/web/src/nextjs/utils.ts b/packages/web/src/nextjs/utils.ts new file mode 100644 index 0000000..0e3a03c --- /dev/null +++ b/packages/web/src/nextjs/utils.ts @@ -0,0 +1,22 @@ +'use client'; +import { useMemo } from 'react'; +import { useParams, usePathname, useSearchParams } from 'next/navigation.js'; +import { computeRoute } from '../utils'; + +export const useRoute = (): string | null => { + const params = useParams(); + const searchParams = useSearchParams(); + const path = usePathname(); + + const finalParams = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- can be null on pages router + if (!params) return null; + if (Object.keys(params).length !== 0) { + return params; + } + // For pages router, we need to use `searchParams` because `params` is an empty object + return { ...Object.fromEntries(searchParams.entries()) }; + }, [params, searchParams]); + + return computeRoute(path, finalParams); +}; diff --git a/packages/web/src/react.tsx b/packages/web/src/react.tsx index 367cb21..85fb1e0 100644 --- a/packages/web/src/react.tsx +++ b/packages/web/src/react.tsx @@ -1,5 +1,5 @@ -import { useEffect } from 'react'; -import { inject, track } from './generic'; +import { useEffect, useRef, StrictMode } from 'react'; +import { inject, track, pageview } from './generic'; import type { AnalyticsProps } from './types'; /** @@ -25,14 +25,28 @@ import type { AnalyticsProps } from './types'; * } * ``` */ -function Analytics({ - beforeSend, - debug = true, - mode = 'auto', -}: AnalyticsProps): null { +function Analytics( + props: AnalyticsProps & { + framework?: string; + } +): null { useEffect(() => { - inject({ beforeSend, debug, mode }); - }, [beforeSend, debug, mode]); + inject({ + framework: props.framework || 'react', + ...(props.route && { disableAutoTrack: true }), + ...props, + }); + }, []); + + useEffect(() => { + if (props.route) { + console.log(`Tracking custom pageview ${props.route}`); + pageview({ + route: props.route, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- only update on route change + }, [props.route]); return null; } diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index a705154..440cba2 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -17,11 +17,20 @@ export interface AnalyticsProps { beforeSend?: BeforeSend; debug?: boolean; mode?: Mode; + route?: string | null; + + disableAutoTrack?: boolean; + + scriptSrc?: string; + endpoint?: string; } declare global { interface Window { // Base interface - va?: (event: 'beforeSend' | 'event', properties?: unknown) => void; + va?: ( + event: 'beforeSend' | 'event' | 'pageview', + properties?: unknown + ) => void; // Queue for actions, before the library is loaded vaq?: [string, unknown?][]; vai?: boolean; diff --git a/packages/web/src/utils.ts b/packages/web/src/utils.ts index 00c1f82..e0d4ccb 100644 --- a/packages/web/src/utils.ts +++ b/packages/web/src/utils.ts @@ -73,3 +73,35 @@ export function parseProperties( } return props as Record; } + +export function computeRoute( + pathname: string | null, + pathParams: Record | null +): string | null { + if (!pathname || !pathParams) { + return pathname; + } + + let result = pathname; + + try { + for (const [key, valueOrArray] of Object.entries(pathParams)) { + const isValueArray = Array.isArray(valueOrArray); + const value = isValueArray ? valueOrArray.join('/') : valueOrArray; + const expr = isValueArray ? `...${key}` : key; + + const matcher = new RegExp(`/${escapeRegExp(value)}(?=[/?#]|$)`); + if (matcher.test(result)) { + result = result.replace(matcher, `/[${expr}]`); + } + } + + return result; + } catch (e) { + return pathname; + } +} + +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/web/tsup.config.js b/packages/web/tsup.config.js index da49f44..dbca63a 100644 --- a/packages/web/tsup.config.js +++ b/packages/web/tsup.config.js @@ -17,6 +17,20 @@ export default defineConfig([ }, outDir: 'dist', }, + { + ...cfg, + entry: { + index: 'src/nextjs/index.tsx', + }, + external: ['react', 'next'], + outDir: 'dist/next', + esbuildOptions: (options) => { + // Append "use client" to the top of the react entry point + options.banner = { + js: '"use client";', + }; + }, + }, { ...cfg, entry: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3ecfb4..1f66acc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: workspace:* version: link:../../packages/web next: - specifier: 13.5.4 - version: 13.5.4(@babel/core@7.23.0)(react-dom@18.2.0)(react@18.2.0) + specifier: 14.1.0 + version: 14.1.0(@babel/core@7.23.0)(react-dom@18.2.0)(react@18.2.0) react: specifier: 18.2.0 version: 18.2.0 @@ -134,6 +134,9 @@ importers: packages/web: dependencies: + next: + specifier: '>= 13' + version: 13.5.4(@babel/core@7.23.0)(react-dom@18.2.0)(react@18.2.0) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -2063,6 +2066,13 @@ packages: } dev: false + /@next/env@14.1.0: + resolution: + { + integrity: sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==, + } + dev: false + /@next/eslint-plugin-next@13.4.7: resolution: { @@ -2083,6 +2093,18 @@ packages: dev: false optional: true + /@next/swc-darwin-arm64@14.1.0: + resolution: + { + integrity: sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==, + } + engines: { node: '>= 10' } + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /@next/swc-darwin-x64@13.5.4: resolution: { @@ -2095,6 +2117,18 @@ packages: dev: false optional: true + /@next/swc-darwin-x64@14.1.0: + resolution: + { + integrity: sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g==, + } + engines: { node: '>= 10' } + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-arm64-gnu@13.5.4: resolution: { @@ -2107,6 +2141,18 @@ packages: dev: false optional: true + /@next/swc-linux-arm64-gnu@14.1.0: + resolution: + { + integrity: sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ==, + } + engines: { node: '>= 10' } + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-arm64-musl@13.5.4: resolution: { @@ -2119,6 +2165,18 @@ packages: dev: false optional: true + /@next/swc-linux-arm64-musl@14.1.0: + resolution: + { + integrity: sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g==, + } + engines: { node: '>= 10' } + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-x64-gnu@13.5.4: resolution: { @@ -2131,6 +2189,18 @@ packages: dev: false optional: true + /@next/swc-linux-x64-gnu@14.1.0: + resolution: + { + integrity: sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ==, + } + engines: { node: '>= 10' } + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-x64-musl@13.5.4: resolution: { @@ -2143,6 +2213,18 @@ packages: dev: false optional: true + /@next/swc-linux-x64-musl@14.1.0: + resolution: + { + integrity: sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg==, + } + engines: { node: '>= 10' } + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-win32-arm64-msvc@13.5.4: resolution: { @@ -2155,6 +2237,18 @@ packages: dev: false optional: true + /@next/swc-win32-arm64-msvc@14.1.0: + resolution: + { + integrity: sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ==, + } + engines: { node: '>= 10' } + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@next/swc-win32-ia32-msvc@13.5.4: resolution: { @@ -2167,6 +2261,18 @@ packages: dev: false optional: true + /@next/swc-win32-ia32-msvc@14.1.0: + resolution: + { + integrity: sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw==, + } + engines: { node: '>= 10' } + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@next/swc-win32-x64-msvc@13.5.4: resolution: { @@ -2179,6 +2285,18 @@ packages: dev: false optional: true + /@next/swc-win32-x64-msvc@14.1.0: + resolution: + { + integrity: sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg==, + } + engines: { node: '>= 10' } + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1: resolution: { @@ -2813,7 +2931,7 @@ packages: integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==, } dependencies: - tslib: 2.4.0 + tslib: 2.6.2 dev: false /@swc/jest@0.2.26(@swc/core@1.3.66): @@ -4860,18 +4978,18 @@ packages: engines: { node: '>=10' } dev: true - /caniuse-lite@1.0.30001423: + /caniuse-lite@1.0.30001546: resolution: { - integrity: sha512-09iwWGOlifvE1XuHokFMP7eR38a0JnajoyL3/i87c8ZjRWRrdKo1fqjNfugfBD0UDBIOz0U+jtNhJ0EPm1VleQ==, + integrity: sha512-zvtSJwuQFpewSyRrI3AsftF6rM0X80mZkChIt1spBGEvRglCrjTniXvinc8JKRoqTwXAgvqTImaN9igfSMtUBw==, } - dev: false - /caniuse-lite@1.0.30001546: + /caniuse-lite@1.0.30001579: resolution: { - integrity: sha512-zvtSJwuQFpewSyRrI3AsftF6rM0X80mZkChIt1spBGEvRglCrjTniXvinc8JKRoqTwXAgvqTImaN9igfSMtUBw==, + integrity: sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==, } + dev: false /ccount@2.0.1: resolution: @@ -7994,6 +8112,13 @@ packages: integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==, } + /graceful-fs@4.2.11: + resolution: + { + integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, + } + dev: false + /graphemer@1.4.0: resolution: { @@ -10889,7 +11014,7 @@ packages: '@next/env': 13.5.4 '@swc/helpers': 0.5.2 busboy: 1.6.0 - caniuse-lite: 1.0.30001423 + caniuse-lite: 1.0.30001546 postcss: 8.4.31 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -10910,6 +11035,48 @@ packages: - babel-plugin-macros dev: false + /next@14.1.0(@babel/core@7.23.0)(react-dom@18.2.0)(react@18.2.0): + resolution: + { + integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==, + } + engines: { node: '>=18.17.0' } + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + sass: + optional: true + dependencies: + '@next/env': 14.1.0 + '@swc/helpers': 0.5.2 + busboy: 1.6.0 + caniuse-lite: 1.0.30001579 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + styled-jsx: 5.1.1(@babel/core@7.23.0)(react@18.2.0) + optionalDependencies: + '@next/swc-darwin-arm64': 14.1.0 + '@next/swc-darwin-x64': 14.1.0 + '@next/swc-linux-arm64-gnu': 14.1.0 + '@next/swc-linux-arm64-musl': 14.1.0 + '@next/swc-linux-x64-gnu': 14.1.0 + '@next/swc-linux-x64-musl': 14.1.0 + '@next/swc-win32-arm64-msvc': 14.1.0 + '@next/swc-win32-ia32-msvc': 14.1.0 + '@next/swc-win32-x64-msvc': 14.1.0 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + dev: false + /node-fetch@2.7.0: resolution: { @@ -13536,7 +13703,6 @@ packages: { integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==, } - dev: true /tsup@7.1.0(@swc/core@1.3.66)(typescript@5.1.6): resolution: From 6b7a4fdcba870c9f7c087b1ee017b3061c212161 Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Tue, 23 Jan 2024 12:07:11 +0100 Subject: [PATCH 02/15] cleanup --- packages/web/src/react.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/web/src/react.tsx b/packages/web/src/react.tsx index 85fb1e0..8e22b8d 100644 --- a/packages/web/src/react.tsx +++ b/packages/web/src/react.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, StrictMode } from 'react'; +import { useEffect } from 'react'; import { inject, track, pageview } from './generic'; import type { AnalyticsProps } from './types'; @@ -36,16 +36,14 @@ function Analytics( ...(props.route && { disableAutoTrack: true }), ...props, }); - }, []); + }, [props]); useEffect(() => { if (props.route) { - console.log(`Tracking custom pageview ${props.route}`); pageview({ route: props.route, }); } - // eslint-disable-next-line react-hooks/exhaustive-deps -- only update on route change }, [props.route]); return null; From 3385062eaacbc3e65e32b97fd1f5166ce5985453 Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Tue, 23 Jan 2024 14:59:18 +0100 Subject: [PATCH 03/15] Push version --- .github/composite-actions/install/action.yml | 2 +- .nvmrc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/composite-actions/install/action.yml b/.github/composite-actions/install/action.yml index b628f01..b77ef0c 100644 --- a/.github/composite-actions/install/action.yml +++ b/.github/composite-actions/install/action.yml @@ -4,7 +4,7 @@ runs: using: composite steps: - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.nvmrc b/.nvmrc index 3876fd4..a9d0873 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.16.1 +18.19.0 From 9876cc9b3308eb3af6e0015bae2a318ff8c75b72 Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Wed, 24 Jan 2024 09:18:54 +0100 Subject: [PATCH 04/15] Update jest.config.js --- packages/web/jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/jest.config.js b/packages/web/jest.config.js index d76e84d..f45b9dd 100644 --- a/packages/web/jest.config.js +++ b/packages/web/jest.config.js @@ -1,4 +1,4 @@ -export default { +module.exports = { testEnvironment: 'jsdom', transform: { '^.+\\.(t|j)sx?$': '@swc/jest', From 23d3223436ceff11652663798e351cdc4bfff057 Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Thu, 25 Jan 2024 09:44:15 +0100 Subject: [PATCH 05/15] Fix tests --- apps/nextjs/e2e/production/beforeSend.spec.ts | 4 ++-- apps/nextjs/e2e/production/pageview.spec.ts | 4 ++-- apps/nextjs/pages/_app.tsx | 13 ++++++++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/nextjs/e2e/production/beforeSend.spec.ts b/apps/nextjs/e2e/production/beforeSend.spec.ts index 061ecc1..03bc806 100644 --- a/apps/nextjs/e2e/production/beforeSend.spec.ts +++ b/apps/nextjs/e2e/production/beforeSend.spec.ts @@ -30,7 +30,7 @@ test.describe('beforeSend', () => { payload: { o: 'http://localhost:3000/before-send/first', sv: expect.any(String), - sdkn: '@vercel/analytics', + sdkn: '@vercel/analytics/next', sdkv: expect.any(String), ts: expect.any(Number), r: '', @@ -42,7 +42,7 @@ test.describe('beforeSend', () => { o: 'http://localhost:3000/before-send/second?secret=REDACTED', ts: expect.any(Number), sv: expect.any(String), - sdkn: '@vercel/analytics', + sdkn: '@vercel/analytics/next', sdkv: expect.any(String), }, }, diff --git a/apps/nextjs/e2e/production/pageview.spec.ts b/apps/nextjs/e2e/production/pageview.spec.ts index 0e10c8d..25b0cd4 100644 --- a/apps/nextjs/e2e/production/pageview.spec.ts +++ b/apps/nextjs/e2e/production/pageview.spec.ts @@ -32,7 +32,7 @@ test.describe('pageview', () => { ts: expect.any(Number), r: '', sv: expect.any(String), - sdkn: '@vercel/analytics', + sdkn: '@vercel/analytics/next', sdkv: expect.any(String), }, }, @@ -42,7 +42,7 @@ test.describe('pageview', () => { o: 'http://localhost:3000/navigation/second', ts: expect.any(Number), sv: expect.any(String), - sdkn: '@vercel/analytics', + sdkn: '@vercel/analytics/next', sdkv: expect.any(String), }, }, diff --git a/apps/nextjs/pages/_app.tsx b/apps/nextjs/pages/_app.tsx index 39d6b0c..95a2026 100644 --- a/apps/nextjs/pages/_app.tsx +++ b/apps/nextjs/pages/_app.tsx @@ -4,7 +4,18 @@ import type { AppProps } from 'next/app'; export default function App({ Component, pageProps }: AppProps) { return ( <> - + { + const url = new URL(event.url); + if (url.searchParams.has('secret')) { + url.searchParams.set('secret', 'REDACTED'); + } + return { + ...event, + url: url.toString(), + }; + }} + /> ); From 9ca54043b6c246e4be66808b344849dd5780f191 Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Thu, 25 Jan 2024 11:55:25 +0100 Subject: [PATCH 06/15] Fix tests --- apps/nextjs/app/blog/[slug]/page.tsx | 4 + apps/nextjs/app/layout.tsx | 2 +- apps/nextjs/components/withAnalytics.tsx | 25 ------ .../nextjs/e2e/development/beforeSend.spec.ts | 7 +- apps/nextjs/e2e/development/pageview.spec.ts | 12 ++- apps/nextjs/e2e/production/pageview.spec.ts | 80 +++++++++++++++++++ apps/nextjs/e2e/utils.ts | 7 +- apps/nextjs/pages/before-send/first.tsx | 14 +--- apps/nextjs/pages/before-send/second.tsx | 2 - apps/nextjs/pages/navigation/first.tsx | 1 - apps/nextjs/pages/navigation/second.tsx | 2 - packages/web/src/generic.ts | 13 ++- packages/web/src/types.ts | 2 + 13 files changed, 118 insertions(+), 53 deletions(-) delete mode 100644 apps/nextjs/components/withAnalytics.tsx diff --git a/apps/nextjs/app/blog/[slug]/page.tsx b/apps/nextjs/app/blog/[slug]/page.tsx index a4e438d..96e007a 100644 --- a/apps/nextjs/app/blog/[slug]/page.tsx +++ b/apps/nextjs/app/blog/[slug]/page.tsx @@ -1,7 +1,11 @@ +import Link from 'next/link'; + export default function BlogPage({ params }: { params: { slug: string } }) { return (

{params.slug}

+ + Back to blog
); } diff --git a/apps/nextjs/app/layout.tsx b/apps/nextjs/app/layout.tsx index 2aaf10b..66bc35c 100644 --- a/apps/nextjs/app/layout.tsx +++ b/apps/nextjs/app/layout.tsx @@ -13,7 +13,7 @@ export default function RootLayout({ return ( - + {children} diff --git a/apps/nextjs/components/withAnalytics.tsx b/apps/nextjs/components/withAnalytics.tsx deleted file mode 100644 index f74a8a4..0000000 --- a/apps/nextjs/components/withAnalytics.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Analytics, AnalyticsProps } from '@vercel/analytics/next'; -import React from 'react'; - -export function withAnalytics

>( - Component: React.ComponentType, - props?: P -) { - function WithAnalytics(props?: P) { - return ( - <> - - - - ); - } - - return () => WithAnalytics(props); -} diff --git a/apps/nextjs/e2e/development/beforeSend.spec.ts b/apps/nextjs/e2e/development/beforeSend.spec.ts index 712d611..2d7fb8c 100644 --- a/apps/nextjs/e2e/development/beforeSend.spec.ts +++ b/apps/nextjs/e2e/development/beforeSend.spec.ts @@ -6,6 +6,11 @@ test.describe('beforeSend', () => { page, }) => { const messages: string[] = []; + await useMockForProductionScript({ + page, + onPageView: () => {}, + debug: true, + }); page.on('console', (msg) => { const message = msg.text(); @@ -28,6 +33,6 @@ test.describe('beforeSend', () => { await page.waitForLoadState('networkidle'); - expect(messages).toHaveLength(5); + expect(messages).toHaveLength(6); }); }); diff --git a/apps/nextjs/e2e/development/pageview.spec.ts b/apps/nextjs/e2e/development/pageview.spec.ts index 97e987b..419ba13 100644 --- a/apps/nextjs/e2e/development/pageview.spec.ts +++ b/apps/nextjs/e2e/development/pageview.spec.ts @@ -1,16 +1,20 @@ import { test, expect } from '@playwright/test'; +import { useMockForProductionScript } from '../utils'; test.describe('pageview', () => { test('should track page views when navigating between pages', async ({ page, }) => { const messages: string[] = []; + await useMockForProductionScript({ + page, + onPageView: () => {}, + debug: true, + }); page.on('console', (msg) => { const message = msg.text(); - console.log(message); - if ( message.includes('[Vercel Web Analytics]') || message.includes('[Vercel Analytics]') @@ -20,7 +24,7 @@ test.describe('pageview', () => { }); await page.goto('/navigation/first'); - await page.waitForTimeout(200); + await page.waitForTimeout(800); await page.click('text=Next'); @@ -29,6 +33,6 @@ test.describe('pageview', () => { await page.waitForTimeout(200); - expect(messages).toHaveLength(3); + expect(messages).toHaveLength(6); }); }); diff --git a/apps/nextjs/e2e/production/pageview.spec.ts b/apps/nextjs/e2e/production/pageview.spec.ts index 25b0cd4..fae658d 100644 --- a/apps/nextjs/e2e/production/pageview.spec.ts +++ b/apps/nextjs/e2e/production/pageview.spec.ts @@ -34,6 +34,7 @@ test.describe('pageview', () => { sv: expect.any(String), sdkn: '@vercel/analytics/next', sdkv: expect.any(String), + dp: '/navigation/first', }, }, { @@ -44,6 +45,85 @@ test.describe('pageview', () => { sv: expect.any(String), sdkn: '@vercel/analytics/next', sdkv: expect.any(String), + dp: '/navigation/second', + }, + }, + ]); + }); + + test('should properly send dynamic route', async ({ page }) => { + const payloads: { page: string; payload: Object }[] = []; + + await useMockForProductionScript({ + page, + onPageView: (page, payload) => { + payloads.push({ page, payload }); + }, + }); + + await page.goto('/blog'); + await page.waitForLoadState('networkidle'); + + await page.click('text=My first blog post'); + + await expect(page).toHaveURL('/blog/my-first-blogpost'); + await expect(page.locator('h2')).toContainText('my-first-blogpost'); + + await page.waitForLoadState('networkidle'); + + await page.click('text=Back to blog'); + + await page.waitForLoadState('networkidle'); + await expect(page).toHaveURL('/blog'); + + await page.click('text=Feature just got released'); + + await expect(page.locator('h2')).toContainText('new-feature-release'); + + expect(payloads).toEqual([ + { + page: 'http://localhost:3000/blog', + payload: { + dp: '/blog', + o: 'http://localhost:3000/blog', + r: '', + sdkn: '@vercel/analytics/next', + sdkv: expect.any(String), + sv: expect.any(String), + ts: expect.any(Number), + }, + }, + { + page: 'http://localhost:3000/blog/my-first-blogpost', + payload: { + dp: '/blog/[slug]', + o: 'http://localhost:3000/blog/my-first-blogpost', + sdkn: '@vercel/analytics/next', + sdkv: expect.any(String), + sv: expect.any(String), + ts: expect.any(Number), + }, + }, + { + page: 'http://localhost:3000/blog', + payload: { + dp: '/blog', + o: 'http://localhost:3000/blog', + sdkn: '@vercel/analytics/next', + sdkv: expect.any(String), + sv: expect.any(String), + ts: expect.any(Number), + }, + }, + { + page: 'http://localhost:3000/blog/new-feature-release', + payload: { + dp: '/blog/[slug]', + o: 'http://localhost:3000/blog/new-feature-release', + sdkn: '@vercel/analytics/next', + sdkv: expect.any(String), + sv: expect.any(String), + ts: expect.any(Number), }, }, ]); diff --git a/apps/nextjs/e2e/utils.ts b/apps/nextjs/e2e/utils.ts index e3b6702..9aeeaf5 100644 --- a/apps/nextjs/e2e/utils.ts +++ b/apps/nextjs/e2e/utils.ts @@ -3,11 +3,16 @@ import { Page } from '@playwright/test'; export async function useMockForProductionScript(props: { page: Page; onPageView: (page: string, payload: Object) => void; + debug?: boolean; }) { await props.page.route('**/_vercel/insights/script.js', async (route, _) => { return route.fulfill({ status: 301, - headers: { location: 'https://cdn.vercel-insights.com/v1/script.js' }, + headers: { + location: props.debug + ? 'https://cdn.vercel-insights.com/v1/script.debug.js' + : 'https://cdn.vercel-insights.com/v1/script.js', + }, }); }); diff --git a/apps/nextjs/pages/before-send/first.tsx b/apps/nextjs/pages/before-send/first.tsx index 8786969..816ea17 100644 --- a/apps/nextjs/pages/before-send/first.tsx +++ b/apps/nextjs/pages/before-send/first.tsx @@ -1,5 +1,4 @@ import Link from 'next/link'; -import { withAnalytics } from '../../components/withAnalytics'; function Page() { return ( @@ -10,15 +9,4 @@ function Page() { ); } -export default withAnalytics(Page, { - beforeSend: (event) => { - const url = new URL(event.url); - if (url.searchParams.has('secret')) { - url.searchParams.set('secret', 'REDACTED'); - } - return { - ...event, - url: url.toString(), - }; - }, -}); +export default Page; diff --git a/apps/nextjs/pages/before-send/second.tsx b/apps/nextjs/pages/before-send/second.tsx index fae6c88..f88025a 100644 --- a/apps/nextjs/pages/before-send/second.tsx +++ b/apps/nextjs/pages/before-send/second.tsx @@ -1,5 +1,3 @@ -import { withAnalytics } from '../../components/withAnalytics'; - function Page() { return (

diff --git a/apps/nextjs/pages/navigation/first.tsx b/apps/nextjs/pages/navigation/first.tsx index 9ffec37..dfcdf0f 100644 --- a/apps/nextjs/pages/navigation/first.tsx +++ b/apps/nextjs/pages/navigation/first.tsx @@ -1,5 +1,4 @@ import Link from 'next/link'; -import { withAnalytics } from '../../components/withAnalytics'; function Page() { return ( diff --git a/apps/nextjs/pages/navigation/second.tsx b/apps/nextjs/pages/navigation/second.tsx index fae6c88..f88025a 100644 --- a/apps/nextjs/pages/navigation/second.tsx +++ b/apps/nextjs/pages/navigation/second.tsx @@ -1,5 +1,3 @@ -import { withAnalytics } from '../../components/withAnalytics'; - function Page() { return (
diff --git a/packages/web/src/generic.ts b/packages/web/src/generic.ts index 3bf94d1..9ce6a45 100644 --- a/packages/web/src/generic.ts +++ b/packages/web/src/generic.ts @@ -22,6 +22,7 @@ export const PROD_SCRIPT_URL = '/_vercel/insights/script.js'; * - `development` - Always use the development script. (Logs events to the console) * @param [props.debug] - Whether to enable debug logging in development. Defaults to `true`. * @param [props.beforeSend] - A middleware function to modify events before they are sent. Should return the event object or `null` to cancel the event. + * @param [props.dsn] - The DSN of the project to send events to. Only required when self-hosting. */ function inject( props: AnalyticsProps & { @@ -50,10 +51,16 @@ function inject( script.defer = true; script.dataset.sdkn = packageName + (props.framework ? `/${props.framework}` : ''); - script.setAttribute('data-sdkv', version); + script.dataset.sdkv = version; if (props.disableAutoTrack) { - script.setAttribute('data-disable-auto-track', '1'); + script.dataset.disableAutoTrack = '1'; + } + if (props.endpoint) { + script.dataset.endpoint = props.endpoint; + } + if (props.dsn) { + script.dataset.dsn = props.dsn; } script.onerror = (): void => { @@ -68,7 +75,7 @@ function inject( }; if (isDevelopment() && props.debug === false) { - script.setAttribute('data-debug', 'false'); + script.dataset.debug = 'false'; } document.head.appendChild(script); diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index 440cba2..aab7ee0 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -23,6 +23,8 @@ export interface AnalyticsProps { scriptSrc?: string; endpoint?: string; + + dsn?: string; } declare global { interface Window { From a2f87080f981c9f29c7a281c0a730d0b5141fa64 Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Thu, 25 Jan 2024 12:08:19 +0100 Subject: [PATCH 07/15] Update index.tsx --- packages/web/src/nextjs/index.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/web/src/nextjs/index.tsx b/packages/web/src/nextjs/index.tsx index 3f2166b..33b0c11 100644 --- a/packages/web/src/nextjs/index.tsx +++ b/packages/web/src/nextjs/index.tsx @@ -8,11 +8,7 @@ type Props = Omit; function AnalyticsComponent(props: Props): React.ReactElement { const route = useRoute(); - return ( - - - - ); + return ; } export function Analytics(props: Props): React.ReactElement { From 512f149f4890da4fef437c75725fa7ada4a2fa6b Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Thu, 25 Jan 2024 13:51:14 +0100 Subject: [PATCH 08/15] Improve soft navigations --- apps/nextjs/app/blog/layout.tsx | 11 +++++ apps/nextjs/app/blog/page.tsx | 9 +--- apps/nextjs/e2e/production/pageview.spec.ts | 46 +++++++++++++++++++++ packages/web/package.json | 2 +- packages/web/src/nextjs/index.tsx | 6 ++- packages/web/src/nextjs/utils.ts | 22 +++++----- packages/web/src/react.tsx | 3 +- 7 files changed, 75 insertions(+), 24 deletions(-) create mode 100644 apps/nextjs/app/blog/layout.tsx diff --git a/apps/nextjs/app/blog/layout.tsx b/apps/nextjs/app/blog/layout.tsx new file mode 100644 index 0000000..3c9c95f --- /dev/null +++ b/apps/nextjs/app/blog/layout.tsx @@ -0,0 +1,11 @@ +import Link from 'next/link'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ My first blog post + Feature just got released +
{children}
+
+ ); +} diff --git a/apps/nextjs/app/blog/page.tsx b/apps/nextjs/app/blog/page.tsx index ec90e7b..d0888e0 100644 --- a/apps/nextjs/app/blog/page.tsx +++ b/apps/nextjs/app/blog/page.tsx @@ -1,10 +1,3 @@ -import Link from 'next/link'; - export default function Blog() { - return ( -
- My first blog post - Feature just got released -
- ); + return
Welcome on the blog
; } diff --git a/apps/nextjs/e2e/production/pageview.spec.ts b/apps/nextjs/e2e/production/pageview.spec.ts index fae658d..7eb25c1 100644 --- a/apps/nextjs/e2e/production/pageview.spec.ts +++ b/apps/nextjs/e2e/production/pageview.spec.ts @@ -128,4 +128,50 @@ test.describe('pageview', () => { }, ]); }); + + test('should send pageviews when route doesnt change but path does', async ({ + page, + }) => { + const payloads: { page: string; payload: Object }[] = []; + + await useMockForProductionScript({ + page, + onPageView: (page, payload) => { + payloads.push({ page, payload }); + }, + }); + + await page.goto('/blog/my-first-blogpost'); + await page.waitForLoadState('networkidle'); + + await page.click('text=Feature just got released'); + + await expect(page.locator('h2')).toContainText('new-feature-release'); + + expect(payloads).toEqual([ + { + page: 'http://localhost:3000/blog/my-first-blogpost', + payload: { + dp: '/blog/[slug]', + o: 'http://localhost:3000/blog/my-first-blogpost', + sdkn: '@vercel/analytics/next', + sdkv: expect.any(String), + sv: expect.any(String), + ts: expect.any(Number), + r: '', + }, + }, + { + page: 'http://localhost:3000/blog/new-feature-release', + payload: { + dp: '/blog/[slug]', + o: 'http://localhost:3000/blog/new-feature-release', + sdkn: '@vercel/analytics/next', + sdkv: expect.any(String), + sv: expect.any(String), + ts: expect.any(Number), + }, + }, + ]); + }); }); diff --git a/packages/web/package.json b/packages/web/package.json index 0913656..a1f6bdc 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/analytics", - "version": "1.2.0-beta.1", + "version": "1.2.0-beta.2", "description": "Gain real-time traffic insights with Vercel Web Analytics", "keywords": [ "analytics", diff --git a/packages/web/src/nextjs/index.tsx b/packages/web/src/nextjs/index.tsx index 33b0c11..a242caa 100644 --- a/packages/web/src/nextjs/index.tsx +++ b/packages/web/src/nextjs/index.tsx @@ -6,9 +6,11 @@ import { useRoute } from './utils'; type Props = Omit; function AnalyticsComponent(props: Props): React.ReactElement { - const route = useRoute(); + const { route, path } = useRoute(); - return ; + return ( + + ); } export function Analytics(props: Props): React.ReactElement { diff --git a/packages/web/src/nextjs/utils.ts b/packages/web/src/nextjs/utils.ts index 0e3a03c..9a68603 100644 --- a/packages/web/src/nextjs/utils.ts +++ b/packages/web/src/nextjs/utils.ts @@ -1,22 +1,20 @@ 'use client'; -import { useMemo } from 'react'; import { useParams, usePathname, useSearchParams } from 'next/navigation.js'; import { computeRoute } from '../utils'; -export const useRoute = (): string | null => { +export const useRoute = (): { + route: string | null; + path: string; +} => { const params = useParams(); const searchParams = useSearchParams(); const path = usePathname(); - const finalParams = useMemo(() => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- can be null on pages router - if (!params) return null; - if (Object.keys(params).length !== 0) { - return params; - } - // For pages router, we need to use `searchParams` because `params` is an empty object - return { ...Object.fromEntries(searchParams.entries()) }; - }, [params, searchParams]); + const finalParams = { + ...Object.fromEntries(searchParams.entries()), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- can be empty in pages router + ...(params || {}), + }; - return computeRoute(path, finalParams); + return { route: computeRoute(path, finalParams), path }; }; diff --git a/packages/web/src/react.tsx b/packages/web/src/react.tsx index 8e22b8d..e0730aa 100644 --- a/packages/web/src/react.tsx +++ b/packages/web/src/react.tsx @@ -28,6 +28,7 @@ import type { AnalyticsProps } from './types'; function Analytics( props: AnalyticsProps & { framework?: string; + path?: string; } ): null { useEffect(() => { @@ -44,7 +45,7 @@ function Analytics( route: props.route, }); } - }, [props.route]); + }, [props.route, props.path]); return null; } From 80571536288a36409c5ba9ab4916972cfc7de6ea Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Fri, 26 Jan 2024 13:22:16 +0100 Subject: [PATCH 09/15] Only set `route` when params are not `null` --- packages/web/src/nextjs/utils.ts | 6 +++++- packages/web/src/react.tsx | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/web/src/nextjs/utils.ts b/packages/web/src/nextjs/utils.ts index 9a68603..071e421 100644 --- a/packages/web/src/nextjs/utils.ts +++ b/packages/web/src/nextjs/utils.ts @@ -16,5 +16,9 @@ export const useRoute = (): { ...(params || {}), }; - return { route: computeRoute(path, finalParams), path }; + return { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- can be empty in pages router + route: params ? computeRoute(path, finalParams) : null, + path, + }; }; diff --git a/packages/web/src/react.tsx b/packages/web/src/react.tsx index e0730aa..0976fa0 100644 --- a/packages/web/src/react.tsx +++ b/packages/web/src/react.tsx @@ -28,13 +28,13 @@ import type { AnalyticsProps } from './types'; function Analytics( props: AnalyticsProps & { framework?: string; - path?: string; + path?: string | null; } ): null { useEffect(() => { inject({ framework: props.framework || 'react', - ...(props.route && { disableAutoTrack: true }), + ...(props.route !== undefined && { disableAutoTrack: true }), ...props, }); }, [props]); From fd576f480f33ae87c47d59d60ff54c99f8764aa8 Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Fri, 26 Jan 2024 13:22:27 +0100 Subject: [PATCH 10/15] Update package.json --- packages/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/package.json b/packages/web/package.json index a1f6bdc..b2abcec 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/analytics", - "version": "1.2.0-beta.2", + "version": "1.2.0-beta.3", "description": "Gain real-time traffic insights with Vercel Web Analytics", "keywords": [ "analytics", From 2a1a6aa3b73090a2d1acb2b6d843962bd5f7e0f0 Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Wed, 31 Jan 2024 11:46:11 +0100 Subject: [PATCH 11/15] Add tests --- packages/web/src/utils.test.ts | 97 +++++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/web/src/utils.test.ts b/packages/web/src/utils.test.ts index 0bd7e5f..c132bc1 100644 --- a/packages/web/src/utils.test.ts +++ b/packages/web/src/utils.test.ts @@ -1,4 +1,4 @@ -import { getMode, parseProperties, setMode } from './utils'; +import { computeRoute, getMode, parseProperties, setMode } from './utils'; describe('utils', () => { describe('parse properties', () => { @@ -99,4 +99,99 @@ describe('utils', () => { }); }); }); + + describe('computeRoute()', () => { + it('returns unchanged pathname if no pathParams provided', () => { + expect(computeRoute('/vercel/next-site/analytics', null)).toBe( + '/vercel/next-site/analytics' + ); + }); + + it('returns null for null pathname', () => { + expect(computeRoute(null, {})).toBe(null); + }); + + it('replaces segments', () => { + const input = '/vercel/next-site/analytics'; + const params = { + teamSlug: 'vercel', + project: 'next-site', + }; + const expected = '/[teamSlug]/[project]/analytics'; + expect(computeRoute(input, params)).toBe(expected); + }); + + it('replaces segments even one param is not used', () => { + const input = '/vercel/next-site/analytics'; + const params = { + lang: 'en', + teamSlug: 'vercel', + project: 'next-site', + }; + const expected = '/[teamSlug]/[project]/analytics'; + expect(computeRoute(input, params)).toBe(expected); + }); + + it('must not replace partial segments', () => { + const input = '/next-site/vercel-site'; + const params = { + teamSlug: 'vercel', + }; + const expected = '/next-site/vercel-site'; // remains unchanged because "vercel" is a partial match + expect(computeRoute(input, params)).toBe(expected); + }); + + it('handles array segments', () => { + const input = '/en/us/next-site'; + const params = { + langs: ['en', 'us'], + teamSlug: 'vercel', + }; + const expected = '/[...langs]/next-site'; + expect(computeRoute(input, params)).toBe(expected); + }); + + it('handles special characters in url', () => { + const input = '/123/test(test'; + const params = { + teamSlug: '123', + project: 'test(test', + }; + + const expected = '/[teamSlug]/[project]'; + expect(computeRoute(input, params)).toBe(expected); + }); + + it('handles special more characters', () => { + const input = '/123/tes\\t(test/3.*'; + const params = { + teamSlug: '123', + }; + + const expected = '/[teamSlug]/tes\\t(test/3.*'; + expect(computeRoute(input, params)).toBe(expected); + }); + + describe('edge case handling (same values for multiple params)', () => { + it('replaces based on the priority of the pathParams keys', () => { + const input = '/test/test'; + const params = { + teamSlug: 'test', + project: 'test', + }; + const expected = '/[teamSlug]/[project]'; // 'teamSlug' takes priority over 'project' based on their order in the params object + expect(computeRoute(input, params)).toBe(expected); + }); + + it('handles reversed priority', () => { + const input = '/test/test'; + const params = { + project: 'test', + teamSlug: 'test', + }; + const expected = '/[project]/[teamSlug]'; // 'project' takes priority over 'teamSlug' here due to the reversed order in the params object + expect(computeRoute(input, params)).toBe(expected); + }); + }); + }); }); From ebf3eb15b37d2ee50be7e4a72bcc31eaa8774cd6 Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Wed, 31 Jan 2024 16:24:30 +0100 Subject: [PATCH 12/15] Also forward path --- packages/web/src/generic.ts | 3 ++- packages/web/src/react.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/web/src/generic.ts b/packages/web/src/generic.ts index 9ce6a45..a0bf1ea 100644 --- a/packages/web/src/generic.ts +++ b/packages/web/src/generic.ts @@ -127,9 +127,10 @@ function track( } } -function pageview({ route }: { route?: string }): void { +function pageview({ route, path }: { route?: string; path?: string }): void { window.va?.('pageview', { route, + path, }); } diff --git a/packages/web/src/react.tsx b/packages/web/src/react.tsx index 0976fa0..ddc8ecb 100644 --- a/packages/web/src/react.tsx +++ b/packages/web/src/react.tsx @@ -40,9 +40,10 @@ function Analytics( }, [props]); useEffect(() => { - if (props.route) { + if (props.route && props.path) { pageview({ route: props.route, + path: props.path, }); } }, [props.route, props.path]); From b0f7dec0d58c77d0164104adb7eb0c1e8ec8448d Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Thu, 1 Feb 2024 13:01:21 +0100 Subject: [PATCH 13/15] Update react.tsx --- packages/web/src/react.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/web/src/react.tsx b/packages/web/src/react.tsx index ddc8ecb..c0d32e0 100644 --- a/packages/web/src/react.tsx +++ b/packages/web/src/react.tsx @@ -37,7 +37,8 @@ function Analytics( ...(props.route !== undefined && { disableAutoTrack: true }), ...props, }); - }, [props]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- only run once + }, []); useEffect(() => { if (props.route && props.path) { From 79ebfc1aee473b293845ab166f21eddb1956464b Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Thu, 1 Feb 2024 13:08:15 +0100 Subject: [PATCH 14/15] Better tests --- apps/nextjs/e2e/development/beforeSend.spec.ts | 15 ++++++++++++++- apps/nextjs/e2e/development/pageview.spec.ts | 11 ++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/nextjs/e2e/development/beforeSend.spec.ts b/apps/nextjs/e2e/development/beforeSend.spec.ts index 2d7fb8c..8ba5fea 100644 --- a/apps/nextjs/e2e/development/beforeSend.spec.ts +++ b/apps/nextjs/e2e/development/beforeSend.spec.ts @@ -33,6 +33,19 @@ test.describe('beforeSend', () => { await page.waitForLoadState('networkidle'); - expect(messages).toHaveLength(6); + expect( + messages.find((m) => + m.includes('[pageview] http://localhost:3000/before-send/first') + ) + ).toBeDefined(); + expect( + messages.find((m) => + m.includes( + '[pageview] http://localhost:3000/before-send/second?secret=REDACTED' + ) + ) + ).toBeDefined(); + + expect(messages.find((m) => m.includes('secret=vercel'))).toBeUndefined(); }); }); diff --git a/apps/nextjs/e2e/development/pageview.spec.ts b/apps/nextjs/e2e/development/pageview.spec.ts index 419ba13..e585707 100644 --- a/apps/nextjs/e2e/development/pageview.spec.ts +++ b/apps/nextjs/e2e/development/pageview.spec.ts @@ -33,6 +33,15 @@ test.describe('pageview', () => { await page.waitForTimeout(200); - expect(messages).toHaveLength(6); + expect( + messages.find((m) => + m.includes('[pageview] http://localhost:3000/navigation/first') + ) + ).toBeDefined(); + expect( + messages.find((m) => + m.includes('[pageview] http://localhost:3000/navigation/second') + ) + ).toBeDefined(); }); }); From 0b83e9ded8193861f0e95cc52f58941ec5c8f8e1 Mon Sep 17 00:00:00 2001 From: Tobias Lins Date: Thu, 1 Feb 2024 13:50:32 +0100 Subject: [PATCH 15/15] Push version --- packages/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/package.json b/packages/web/package.json index b2abcec..4b63fd6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/analytics", - "version": "1.2.0-beta.3", + "version": "1.2.0-beta.4", "description": "Gain real-time traffic insights with Vercel Web Analytics", "keywords": [ "analytics",