diff --git a/packages/nextjs/src/performance/client.ts b/packages/nextjs/src/performance/client.ts index 257c21c429bf..764d11e27fe8 100644 --- a/packages/nextjs/src/performance/client.ts +++ b/packages/nextjs/src/performance/client.ts @@ -1,10 +1,7 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - import { getCurrentHub } from '@sentry/hub'; -import { Primitive, TraceparentData, Transaction, TransactionContext } from '@sentry/types'; +import { Primitive, TraceparentData, Transaction, TransactionContext, TransactionSource } from '@sentry/types'; import { extractTraceparentData, - fill, getGlobalObject, logger, parseBaggageHeader, @@ -14,7 +11,13 @@ import type { NEXT_DATA as NextData } from 'next/dist/next-server/lib/utils'; import { default as Router } from 'next/router'; import type { ParsedUrlQuery } from 'querystring'; -const global = getGlobalObject(); +const global = getGlobalObject< + Window & { + __BUILD_MANIFEST?: { + sortedPages?: string[]; + }; + } +>(); type StartTransactionCb = (context: TransactionContext) => Transaction | undefined; @@ -76,6 +79,8 @@ function extractNextDataTagInformation(): NextDataTagInfo { // `nextData.page` always contains the parameterized route - except for when an error occurs in a data fetching // function, then it is "/_error", but that isn't a problem since users know which route threw by looking at the // parent transaction + // TODO: Actually this is a problem (even though it is not that big), because the DSC and the transaction payload will contain + // a different transaction name. Maybe we can fix this. Idea: Also send transaction name via pageProps when available. nextDataTagInfo.route = page; nextDataTagInfo.params = query; @@ -96,20 +101,12 @@ const DEFAULT_TAGS = { 'routing.instrumentation': 'next-router', } as const; +// We keep track of the active transaction so we can finish it when we start a navigation transaction. let activeTransaction: Transaction | undefined = undefined; -let startTransaction: StartTransactionCb | undefined = undefined; -// We keep track of the previous page location so we can avoid creating transactions when navigating to the same page. -// This variable should always contain a pathname. (without query string or fragment) -// We are making a tradeoff by not starting transactions when just the query string changes. One could argue that we -// should in fact start transactions when the query changes, however, in some cases (for example when typing in a search -// box) the query might change multiple times a second, resulting in way too many transactions. -// Because we currently don't have a real way of preventing transactions to be created in this case (except for the -// shotgun approach `startTransactionOnLocationChange: false`), we won't start transactions when *just* the query changes. -let previousLocation: string | undefined = undefined; - -// We keep track of the previous transaction name so we can set the `from` field on navigation transactions. -let prevTransactionName: string | undefined = undefined; +// We keep track of the previous location name so we can set the `from` field on navigation transactions. +// This is either a route or a pathname. +let prevLocationName: string | undefined = undefined; const client = getCurrentHub().getClient(); @@ -126,18 +123,14 @@ export function nextRouterInstrumentation( startTransactionOnPageLoad: boolean = true, startTransactionOnLocationChange: boolean = true, ): void { - startTransaction = startTransactionCb; + const { route, traceParentData, baggage, params } = extractNextDataTagInformation(); + prevLocationName = route || global.location.pathname; if (startTransactionOnPageLoad) { - const { route, traceParentData, baggage, params } = extractNextDataTagInformation(); - - prevTransactionName = route || global.location.pathname; - previousLocation = global.location.pathname; - const source = route ? 'route' : 'url'; activeTransaction = startTransactionCb({ - name: prevTransactionName, + name: prevLocationName, op: 'pageload', tags: DEFAULT_TAGS, ...(params && client && client.getOptions().sendDefaultPii && { data: params }), @@ -149,78 +142,111 @@ export function nextRouterInstrumentation( }); } - Router.ready(() => { - // Spans that aren't attached to any transaction are lost; so if transactions aren't - // created (besides potentially the onpageload transaction), no need to wrap the router. - if (!startTransactionOnLocationChange) return; - - // `withRouter` uses `useRouter` underneath: - // https://github.com/vercel/next.js/blob/de42719619ae69fbd88e445100f15701f6e1e100/packages/next/client/with-router.tsx#L21 - // Router events also use the router: - // https://github.com/vercel/next.js/blob/de42719619ae69fbd88e445100f15701f6e1e100/packages/next/client/router.ts#L92 - // `Router.changeState` handles the router state changes, so it may be enough to only wrap it - // (instead of wrapping all of the Router's functions). - const routerPrototype = Object.getPrototypeOf(Router.router); - fill(routerPrototype, 'changeState', changeStateWrapper); - }); -} + if (startTransactionOnLocationChange) { + Router.events.on('routeChangeStart', (navigationTarget: string) => { + const matchedRoute = getNextRouteFromPathname(stripUrlQueryAndFragment(navigationTarget)); -type RouterChangeState = ( - method: string, - url: string, - as: string, - options: Record, - ...args: any[] -) => void; -type WrappedRouterChangeState = RouterChangeState; + let transactionName: string; + let transactionSource: TransactionSource; -/** - * Wraps Router.changeState() - * https://github.com/vercel/next.js/blob/da97a18dafc7799e63aa7985adc95f213c2bf5f3/packages/next/next-server/lib/router/router.ts#L1204 - * Start a navigation transaction every time the router changes state. - */ -function changeStateWrapper(originalChangeStateWrapper: RouterChangeState): WrappedRouterChangeState { - return function wrapper( - this: any, - method: string, - // The parameterized url, ex. posts/[id]/[comment] - url: string, - // The actual url, ex. posts/85/my-comment - as: string, - options: Record, - // At the moment there are no additional arguments (meaning the rest parameter is empty). - // This is meant to protect from future additions to Next.js API, especially since this is an - // internal API. - ...args: any[] - ): Promise { - const newTransactionName = stripUrlQueryAndFragment(url); - - // do not start a transaction if it's from the same page - if (startTransaction !== undefined && previousLocation !== as) { - previousLocation = as; - - if (activeTransaction) { - activeTransaction.finish(); + if (matchedRoute) { + transactionName = matchedRoute; + transactionSource = 'route'; + } else { + transactionName = navigationTarget; + transactionSource = 'url'; } const tags: Record = { ...DEFAULT_TAGS, - method, - ...options, + from: prevLocationName, }; - if (prevTransactionName) { - tags.from = prevTransactionName; + prevLocationName = transactionName; + + if (activeTransaction) { + activeTransaction.finish(); } - prevTransactionName = newTransactionName; - activeTransaction = startTransaction({ - name: prevTransactionName, + const navigationTransaction = startTransactionCb({ + name: transactionName, op: 'navigation', tags, - metadata: { source: 'route' }, + metadata: { source: transactionSource }, }); - } - return originalChangeStateWrapper.call(this, method, url, as, options, ...args); - }; + + if (navigationTransaction) { + // In addition to the navigation transaction we're also starting a span to mark Next.js's `routeChangeStart` + // and `routeChangeComplete` events. + // We don't want to finish the navigation transaction on `routeChangeComplete`, since users might want to attach + // spans to that transaction even after `routeChangeComplete` is fired (eg. HTTP requests in some useEffect + // hooks). Instead, we'll simply let the navigation transaction finish itself (it's an `IdleTransaction`). + const nextRouteChangeSpan = navigationTransaction.startChild({ + op: 'ui.nextjs.route-change', + description: 'Next.js Route Change', + }); + + const finishRouteChangeSpan = (): void => { + nextRouteChangeSpan.finish(); + Router.events.off('routeChangeComplete', finishRouteChangeSpan); + }; + + Router.events.on('routeChangeComplete', finishRouteChangeSpan); + } + }); + } +} + +function getNextRouteFromPathname(pathname: string): string | undefined { + const pageRoutes = (global.__BUILD_MANIFEST || {}).sortedPages; + + // Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here + if (!pageRoutes) { + return; + } + + return pageRoutes.find(route => { + const routeRegExp = convertNextRouteToRegExp(route); + return pathname.match(routeRegExp); + }); +} + +/** + * Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments). + * + * In general this involves replacing any instances of square brackets in a route with a wildcard: + * e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/ + * + * Some additional edgecases need to be considered: + * - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or + * "/users/[id]/info/" - both will be resolved to "/users/[id]/info". + * - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]"). + * - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]"). + * + * @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages` + */ +function convertNextRouteToRegExp(route: string): RegExp { + // We can assume a route is at least "/". + const routeParts = route.split('/'); + + let optionalCatchallWildcardRegex = ''; + if (routeParts[routeParts.length - 1].match(/^\[\[\.\.\..+\]\]$/)) { + // If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing + // slash that would come before it if we didn't pop it. + routeParts.pop(); + optionalCatchallWildcardRegex = '(?:/(.+?))?'; + } + + const rejoinedRouteParts = routeParts + .map( + routePart => + routePart + .replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard + .replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards + ) + .join('/'); + + return new RegExp( + `^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end + ); } diff --git a/packages/nextjs/test/integration/pages/[id]/withInitialProps.tsx b/packages/nextjs/test/integration/pages/[id]/withInitialProps.tsx index 01b557bdd09f..12558228b23c 100644 --- a/packages/nextjs/test/integration/pages/[id]/withInitialProps.tsx +++ b/packages/nextjs/test/integration/pages/[id]/withInitialProps.tsx @@ -1,4 +1,13 @@ -const WithInitialPropsPage = ({ data }: { data: string }) =>

WithInitialPropsPage {data}

; +import Link from 'next/link'; + +const WithInitialPropsPage = ({ data }: { data: string }) => ( + <> +

WithInitialPropsPage {data}

+ + Go to withServerSideProps + + +); WithInitialPropsPage.getInitialProps = () => { return { data: '[some getInitialProps data]' }; diff --git a/packages/nextjs/test/integration/pages/[id]/withServerSideProps.tsx b/packages/nextjs/test/integration/pages/[id]/withServerSideProps.tsx index 486313a9b9c3..f5b5a3b3465c 100644 --- a/packages/nextjs/test/integration/pages/[id]/withServerSideProps.tsx +++ b/packages/nextjs/test/integration/pages/[id]/withServerSideProps.tsx @@ -1,4 +1,13 @@ -const WithServerSidePropsPage = ({ data }: { data: string }) =>

WithServerSidePropsPage {data}

; +import Link from 'next/link'; + +const WithServerSidePropsPage = ({ data }: { data: string }) => ( + <> +

WithServerSidePropsPage {data}

+ + Go to withInitialProps + + +); export async function getServerSideProps() { return { props: { data: '[some getServerSideProps data]' } }; diff --git a/packages/nextjs/test/integration/test/client/tracingNavigate.js b/packages/nextjs/test/integration/test/client/tracingNavigate.js index 65894f857177..327d03de58ae 100644 --- a/packages/nextjs/test/integration/test/client/tracingNavigate.js +++ b/packages/nextjs/test/integration/test/client/tracingNavigate.js @@ -2,11 +2,11 @@ const { sleep } = require('../utils/common'); const { expectRequestCount, isTransactionRequest, expectTransaction } = require('../utils/client'); module.exports = async ({ page, url, requests }) => { - await page.goto(`${url}/healthy`); + await page.goto(`${url}/42/withInitialProps/`); await page.waitForRequest(isTransactionRequest); expectTransaction(requests.transactions[0], { - transaction: '/healthy', + transaction: '/[id]/withInitialProps', type: 'transaction', contexts: { trace: { @@ -17,17 +17,17 @@ module.exports = async ({ page, url, requests }) => { await sleep(250); - await page.click('a#alsoHealthy'); + await page.click('a#server-side-props-page'); await page.waitForRequest(isTransactionRequest); expectTransaction(requests.transactions[1], { - transaction: '/alsoHealthy', + transaction: '/[id]/withServerSideProps', type: 'transaction', contexts: { trace: { op: 'navigation', tags: { - from: '/healthy', + from: '/[id]/withInitialProps', }, }, }, @@ -35,17 +35,17 @@ module.exports = async ({ page, url, requests }) => { await sleep(250); - await page.click('a#healthy'); + await page.click('a#initial-props-page'); await page.waitForRequest(isTransactionRequest); expectTransaction(requests.transactions[2], { - transaction: '/healthy', + transaction: '/[id]/withInitialProps', type: 'transaction', contexts: { trace: { op: 'navigation', tags: { - from: '/alsoHealthy', + from: '/[id]/withServerSideProps', }, }, }, diff --git a/packages/nextjs/test/performance/client.test.ts b/packages/nextjs/test/performance/client.test.ts index 279c39ed90fc..01494fd7c519 100644 --- a/packages/nextjs/test/performance/client.test.ts +++ b/packages/nextjs/test/performance/client.test.ts @@ -1,3 +1,4 @@ +import { Transaction } from '@sentry/types'; import { getGlobalObject } from '@sentry/utils'; import { JSDOM } from 'jsdom'; import { NEXT_DATA as NextData } from 'next/dist/next-server/lib/utils'; @@ -5,84 +6,122 @@ import { default as Router } from 'next/router'; import { nextRouterInstrumentation } from '../../src/performance/client'; -let readyCalled = false; +const globalObject = getGlobalObject< + Window & { + __BUILD_MANIFEST?: { + sortedPages?: string[]; + }; + } +>(); + +const originalBuildManifest = globalObject.__BUILD_MANIFEST; +const originalBuildManifestRoutes = globalObject.__BUILD_MANIFEST?.sortedPages; + +let eventHandlers: { [eventName: string]: Set<(...args: any[]) => void> } = {}; + jest.mock('next/router', () => { - const router = {}; - Object.setPrototypeOf(router, { changeState: () => undefined }); return { default: { - router, - route: '/[user]/posts/[id]', - readyCallbacks: [], - ready(cb: () => void) { - readyCalled = true; - return cb(); + events: { + on(type: string, handler: (...args: any[]) => void) { + if (!eventHandlers[type]) { + eventHandlers[type] = new Set(); + } + + eventHandlers[type].add(handler); + }, + off: jest.fn((type: string, handler: (...args: any[]) => void) => { + if (eventHandlers[type]) { + eventHandlers[type].delete(handler); + } + }), + emit(type: string, ...eventArgs: any[]) { + if (eventHandlers[type]) { + eventHandlers[type].forEach(eventHandler => { + eventHandler(...eventArgs); + }); + } + }, }, }, }; }); -describe('client', () => { - beforeEach(() => { - readyCalled = false; - if (Router.router) { - Router.router.changeState('pushState', '/[user]/posts/[id]', '/abhi/posts/123', {}); - } - }); +function createMockStartTransaction() { + return jest.fn( + () => + ({ + startChild: () => ({ + finish: () => undefined, + }), + finish: () => undefined, + } as Transaction), + ); +} + +describe('nextRouterInstrumentation', () => { + const originalGlobalDocument = getGlobalObject().document; + const originalGlobalLocation = getGlobalObject().location; - describe('nextRouterInstrumentation', () => { - const originalGlobalDocument = getGlobalObject().document; - const originalGlobalLocation = getGlobalObject().location; + function setUpNextPage(pageProperties: { + url: string; + route: string; + query?: any; + props?: any; + navigatableRoutes?: string[]; + hasNextData: boolean; + }) { + const nextDataContent: NextData = { + props: pageProperties.props, + page: pageProperties.route, + query: pageProperties.query, + buildId: 'y76hvndNJBAithejdVGLW', + isFallback: false, + gssp: true, + appGip: true, + scriptLoader: [], + }; + + const dom = new JSDOM( + // Just an example of what a __NEXT_DATA__ tag might look like + pageProperties.hasNextData + ? `` + : '

No next data :(

', + { url: pageProperties.url }, + ); - function setUpNextPage(pageProperties: { - url: string; - route: string; - query: any; - props: any; - hasNextData: boolean; - }) { - const nextDataContent: NextData = { - props: pageProperties.props, - page: pageProperties.route, - query: pageProperties.query, - buildId: 'y76hvndNJBAithejdVGLW', - isFallback: false, - gssp: true, - appGip: true, - scriptLoader: [], - }; + // The Next.js routing instrumentations requires a few things to be present on pageload: + // 1. Access to window.document API for `window.document.getElementById` + // 2. Access to window.location API for `window.location.pathname` + Object.defineProperty(global, 'document', { value: dom.window.document, writable: true }); + Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true }); - const dom = new JSDOM( - // Just an example of what a __NEXT_DATA__ tag might look like - pageProperties.hasNextData - ? `` - : '

No next data :(

', - { url: pageProperties.url }, - ); + // Define Next.js clientside build manifest with navigatable routes + (global as any).__BUILD_MANIFEST = { + ...(global as any).__BUILD_MANIFEST, + sortedPages: pageProperties.navigatableRoutes, + }; + } - // The Next.js routing instrumentations requires a few things to be present on pageload: - // 1. Access to window.document API for `window.document.getElementById` - // 2. Access to window.location API for `window.location.pathname` - Object.defineProperty(global, 'document', { value: dom.window.document, writable: true }); - Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true }); + afterEach(() => { + // Clean up JSDom + Object.defineProperty(global, 'document', { value: originalGlobalDocument }); + Object.defineProperty(global, 'location', { value: originalGlobalLocation }); + + // Reset Next.js' __BUILD_MANIFEST + (global as any).__BUILD_MANIFEST = originalBuildManifest; + if ((global as any).__BUILD_MANIFEST) { + (global as any).__BUILD_MANIFEST.sortedPages = originalBuildManifestRoutes; } - afterEach(() => { - // Clean up JSDom - Object.defineProperty(global, 'document', { value: originalGlobalDocument }); - Object.defineProperty(global, 'location', { value: originalGlobalLocation }); - }); + // Clear all event handlers + eventHandlers = {}; - it('waits for Router.ready()', () => { - setUpNextPage({ url: 'https://example.com/', route: '/', query: {}, props: {}, hasNextData: false }); - const mockStartTransaction = jest.fn(); - expect(readyCalled).toBe(false); - nextRouterInstrumentation(mockStartTransaction); - expect(readyCalled).toBe(true); - }); + // Necessary to clear all Router.events.off() mock call numbers + jest.clearAllMocks(); + }); + describe('pageload transactions', () => { it.each([ [ 'https://example.com/lforst/posts/1337?q=42', @@ -173,7 +212,7 @@ describe('client', () => { ])( 'creates a pageload transaction (#%#)', (url, route, query, props, hasNextData, expectedStartTransactionArgument) => { - const mockStartTransaction = jest.fn(); + const mockStartTransaction = createMockStartTransaction(); setUpNextPage({ url, route, query, props, hasNextData }); nextRouterInstrumentation(mockStartTransaction); expect(mockStartTransaction).toHaveBeenCalledTimes(1); @@ -182,104 +221,95 @@ describe('client', () => { ); it('does not create a pageload transaction if option not given', () => { - const mockStartTransaction = jest.fn(); + const mockStartTransaction = createMockStartTransaction(); + setUpNextPage({ url: 'https://example.com/', route: '/', hasNextData: false }); nextRouterInstrumentation(mockStartTransaction, false); expect(mockStartTransaction).toHaveBeenCalledTimes(0); }); + }); - describe('navigation transactions', () => { - // [name, in, out] - const table: Array<[string, [string, string, string, Record], Record]> = [ - [ - 'creates parameterized transaction', - ['pushState', '/posts/[id]', '/posts/32', {}], - { - name: '/posts/[id]', - op: 'navigation', - tags: { - from: '/[user]/posts/[id]', - method: 'pushState', - 'routing.instrumentation': 'next-router', - }, - metadata: { - source: 'route', - }, - }, - ], - [ - 'strips query parameters', - ['replaceState', '/posts/[id]?name=cat', '/posts/32?name=cat', {}], - { - name: '/posts/[id]', - op: 'navigation', - tags: { - from: '/[user]/posts/[id]', - method: 'replaceState', - 'routing.instrumentation': 'next-router', - }, - metadata: { - source: 'route', - }, - }, - ], - [ - 'creates regular transactions', - ['pushState', '/about', '/about', {}], - { - name: '/about', + describe('new navigation transactions', () => { + it.each([ + ['/news', '/news', 'route'], + ['/news/', '/news', 'route'], + ['/some-route-that-is-not-defined-12332', '/some-route-that-is-not-defined-12332', 'url'], // unknown route + ['/posts/42', '/posts/[id]', 'route'], + ['/posts/42/', '/posts/[id]', 'route'], + ['/posts/42?someParam=1', '/posts/[id]', 'route'], // query params are ignored + ['/posts/42/details', '/posts/[id]/details', 'route'], + ['/users/1337/friends/closeby/good', '/users/[id]/friends/[...filters]', 'route'], + ['/users/1337/friends', '/users/1337/friends', 'url'], + ['/statistics/page-visits', '/statistics/[[...parameters]]', 'route'], + ['/statistics', '/statistics/[[...parameters]]', 'route'], + ['/a/b/c/d', '/[a]/b/[c]/[...d]', 'route'], + ['/a/b/c/d/e', '/[a]/b/[c]/[...d]', 'route'], + ['/a/b/c', '/a/b/c', 'url'], + ['/e/f/g/h', '/e/[f]/[g]/[[...h]]', 'route'], + ['/e/f/g/h/i', '/e/[f]/[g]/[[...h]]', 'route'], + ['/e/f/g', '/e/[f]/[g]/[[...h]]', 'route'], + ])( + 'should create a parameterized transaction on route change (%s)', + (targetLocation, expectedTransactionName, expectedTransactionSource) => { + const mockStartTransaction = createMockStartTransaction(); + + setUpNextPage({ + url: 'https://example.com/home', + route: '/home', + hasNextData: true, + navigatableRoutes: [ + '/home', + '/news', + '/posts/[id]', + '/posts/[id]/details', + '/users/[id]/friends/[...filters]', + '/statistics/[[...parameters]]', + // just some complicated routes to see if we get the matching right + '/[a]/b/[c]/[...d]', + '/e/[f]/[g]/[[...h]]', + ], + }); + + nextRouterInstrumentation(mockStartTransaction, false, true); + + Router.events.emit('routeChangeStart', targetLocation); + + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + expect(mockStartTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + name: expectedTransactionName, op: 'navigation', - tags: { - from: '/[user]/posts/[id]', - method: 'pushState', + tags: expect.objectContaining({ 'routing.instrumentation': 'next-router', - }, - metadata: { - source: 'route', - }, - }, - ], - ]; - - it.each(table)('%s', (...test) => { - const mockStartTransaction = jest.fn(); - nextRouterInstrumentation(mockStartTransaction, false); - expect(mockStartTransaction).toHaveBeenCalledTimes(0); + }), + metadata: expect.objectContaining({ + source: expectedTransactionSource, + }), + }), + ); - // @ts-ignore we can spread into test - Router.router?.changeState(...test[1]); - expect(mockStartTransaction).toHaveBeenLastCalledWith(test[2]); - }); - }); + Router.events.emit('routeChangeComplete', targetLocation); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(Router.events.off).toHaveBeenCalledWith('routeChangeComplete', expect.anything()); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(Router.events.off).toHaveBeenCalledTimes(1); + }, + ); - it('does not create navigation transaction with the same name', () => { - const mockStartTransaction = jest.fn(); - nextRouterInstrumentation(mockStartTransaction, false); - expect(mockStartTransaction).toHaveBeenCalledTimes(0); + it('should not create transaction when navigation transactions are disabled', () => { + const mockStartTransaction = createMockStartTransaction(); - Router.router?.changeState('pushState', '/login', '/login', {}); - expect(mockStartTransaction).toHaveBeenCalledTimes(1); - expect(mockStartTransaction).toHaveBeenLastCalledWith({ - name: '/login', - op: 'navigation', - tags: { from: '/[user]/posts/[id]', method: 'pushState', 'routing.instrumentation': 'next-router' }, - metadata: { - source: 'route', - }, + setUpNextPage({ + url: 'https://example.com/home', + route: '/home', + hasNextData: true, + navigatableRoutes: ['/home', '/posts/[id]'], }); - Router.router?.changeState('pushState', '/login', '/login', {}); - expect(mockStartTransaction).toHaveBeenCalledTimes(1); + nextRouterInstrumentation(mockStartTransaction, false, false); - Router.router?.changeState('pushState', '/posts/[id]', '/posts/123', {}); - expect(mockStartTransaction).toHaveBeenCalledTimes(2); - expect(mockStartTransaction).toHaveBeenLastCalledWith({ - name: '/posts/[id]', - op: 'navigation', - tags: { from: '/login', method: 'pushState', 'routing.instrumentation': 'next-router' }, - metadata: { - source: 'route', - }, - }); + Router.events.emit('routeChangeStart', '/posts/42'); + + expect(mockStartTransaction).not.toHaveBeenCalled(); }); }); });