From 3473447aa704826d65650571a77d2dc7c9b3fbb1 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 12 Dec 2024 16:48:15 +0000 Subject: [PATCH] fix(react): Add React Router Descendant Routes support. (#14304) --- .../.gitignore | 29 + .../react-router-6-descendant-routes/.npmrc | 2 + .../package.json | 55 ++ .../playwright.config.mjs | 7 + .../public/index.html | 24 + .../server/app.js | 47 ++ .../src/globals.d.ts | 5 + .../src/index.tsx | 73 ++ .../src/pages/Index.tsx | 15 + .../src/react-app-env.d.ts | 1 + .../start-event-proxy.mjs | 6 + .../tests/transactions.test.ts | 67 ++ .../tsconfig.json | 20 + packages/react/package.json | 3 +- .../react/src/reactrouterv6-compat-utils.tsx | 194 ++++- packages/react/test/reactrouterv6.4.test.tsx | 676 ------------------ packages/react/test/reactrouterv6.test.tsx | 299 +++++++- yarn.lock | 25 +- 18 files changed, 798 insertions(+), 750 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/package.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/public/index.html create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/server/app.js create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/react-app-env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tsconfig.json delete mode 100644 packages/react/test/reactrouterv6.4.test.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/.gitignore @@ -0,0 +1,29 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/package.json b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/package.json new file mode 100644 index 000000000000..4fc913833ce5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/package.json @@ -0,0 +1,55 @@ +{ + "name": "react-router-6-descendant-routes", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "latest || *", + "@types/react": "18.0.0", + "@types/react-dom": "18.0.0", + "express": "4.19.2", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "^6.28.0", + "react-scripts": "5.0.1", + "typescript": "4.9.5" + }, + "scripts": { + "build": "react-scripts build", + "start": "run-p start:client start:server", + "start:client": "node server/app.js", + "start:server": "serve -s build", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:build-ts3.8": "pnpm install && pnpm add typescript@3.8 && npx playwright install && pnpm build", + "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && npx playwright install && pnpm build", + "test:assert": "pnpm test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils", + "serve": "14.0.1", + "npm-run-all2": "^6.2.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/public/index.html b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/public/index.html new file mode 100644 index 000000000000..39da76522bea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/public/index.html @@ -0,0 +1,24 @@ + + + + + + + + React App + + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/server/app.js b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/server/app.js new file mode 100644 index 000000000000..5a8cdb3929a1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/server/app.js @@ -0,0 +1,47 @@ +const express = require('express'); + +const app = express(); +const PORT = 8080; + +const wait = time => { + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, time); + }); +}; + +async function sseHandler(request, response, timeout = false) { + response.headers = { + 'Content-Type': 'text/event-stream', + Connection: 'keep-alive', + 'Cache-Control': 'no-cache', + 'Access-Control-Allow-Origin': '*', + }; + + response.setHeader('Cache-Control', 'no-cache'); + response.setHeader('Content-Type', 'text/event-stream'); + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader('Connection', 'keep-alive'); + + response.flushHeaders(); + + await wait(2000); + + for (let index = 0; index < 10; index++) { + response.write(`data: ${new Date().toISOString()}\n\n`); + if (timeout) { + await wait(10000); + } + } + + response.end(); +} + +app.get('/sse', (req, res) => sseHandler(req, res)); + +app.get('/sse-timeout', (req, res) => sseHandler(req, res, true)); + +app.listen(PORT, () => { + console.log(`SSE service listening at http://localhost:${PORT}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/globals.d.ts @@ -0,0 +1,5 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + sentryReplayId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx new file mode 100644 index 000000000000..f6694a954915 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx @@ -0,0 +1,73 @@ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { + BrowserRouter, + Route, + Routes, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router-dom'; +import Index from './pages/Index'; + +const replay = Sentry.replayIntegration(); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.REACT_APP_E2E_TEST_DSN, + integrations: [ + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + trackFetchStreamPerformance: true, + }), + replay, + ], + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + release: 'e2e-test', + + // Always capture replays, so we can test this properly + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + tunnel: 'http://localhost:3031', +}); + +const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); + +const DetailsRoutes = () => ( + + Details} /> + +); + +const ViewsRoutes = () => ( + + Views} /> + } /> + +); + +const ProjectsRoutes = () => ( + + }> + No Match Page} /> + +); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render( + + + } /> + }> + + , +); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx new file mode 100644 index 000000000000..aa99b61f89ea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx @@ -0,0 +1,15 @@ +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; +import { Link } from 'react-router-dom'; + +const Index = () => { + return ( + <> + + navigate + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/react-app-env.d.ts new file mode 100644 index 000000000000..6431bc5fc6b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/start-event-proxy.mjs new file mode 100644 index 000000000000..abd4db1ea605 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-6-descendant-routes', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts new file mode 100644 index 000000000000..23bc0aaabe95 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/projects/123/views/234/567`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.react.reactrouter_v6', + }, + }, + transaction: '/projects/:projectId/views/:viewId/:detailId', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + const pageloadTxn = await pageloadTxnPromise; + + expect(pageloadTxn).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.react.reactrouter_v6', + }, + }, + transaction: '/', + transaction_info: { + source: 'route', + }, + }); + + const linkElement = page.locator('id=navigation'); + + const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react.reactrouter_v6', + }, + }, + transaction: '/projects/:projectId/views/:viewId/:detailId', + transaction_info: { + source: 'route', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tsconfig.json new file mode 100644 index 000000000000..4cc95dc2689a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "include": ["src", "tests"] +} diff --git a/packages/react/package.json b/packages/react/package.json index 1ac944b84493..b031e7cb4576 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -67,8 +67,7 @@ "react-router-3": "npm:react-router@3.2.0", "react-router-4": "npm:react-router@4.1.0", "react-router-5": "npm:react-router@5.0.0", - "react-router-6": "npm:react-router@6.3.0", - "react-router-6.4": "npm:react-router@6.4.2", + "react-router-6": "npm:react-router@6.28.0", "redux": "^4.0.5" }, "scripts": { diff --git a/packages/react/src/reactrouterv6-compat-utils.tsx b/packages/react/src/reactrouterv6-compat-utils.tsx index 7752e49d69a1..c17ea1bb190f 100644 --- a/packages/react/src/reactrouterv6-compat-utils.tsx +++ b/packages/react/src/reactrouterv6-compat-utils.tsx @@ -100,7 +100,13 @@ export function createV6CompatibleWrapCreateBrowserRouter< router.subscribe((state: RouterState) => { const location = state.location; if (state.historyAction === 'PUSH' || state.historyAction === 'POP') { - handleNavigation(location, routes, state.historyAction, version, undefined, basename); + handleNavigation({ + location, + routes, + navigationType: state.historyAction, + version, + basename, + }); } }); @@ -174,13 +180,14 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio return origUseRoutes; } - let isMountRenderPass: boolean = true; + const allRoutes: RouteObject[] = []; const SentryRoutes: React.FC<{ children?: React.ReactNode; routes: RouteObject[]; locationArg?: Partial | string; }> = (props: { children?: React.ReactNode; routes: RouteObject[]; locationArg?: Partial | string }) => { + const isMountRenderPass = React.useRef(true); const { routes, locationArg } = props; const Routes = origUseRoutes(routes, locationArg); @@ -198,11 +205,21 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio const normalizedLocation = typeof stableLocationParam === 'string' ? { pathname: stableLocationParam } : stableLocationParam; - if (isMountRenderPass) { - updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes); - isMountRenderPass = false; + if (isMountRenderPass.current) { + routes.forEach(route => { + allRoutes.push(...getChildRoutesRecursively(route)); + }); + + updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes, undefined, undefined, allRoutes); + isMountRenderPass.current = false; } else { - handleNavigation(normalizedLocation, routes, navigationType, version); + handleNavigation({ + location: normalizedLocation, + routes, + navigationType, + version, + allRoutes, + }); } }, [navigationType, stableLocationParam]); @@ -215,14 +232,17 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio }; } -export function handleNavigation( - location: Location, - routes: RouteObject[], - navigationType: Action, - version: V6CompatibleVersion, - matches?: AgnosticDataRouteMatch, - basename?: string, -): void { +export function handleNavigation(opts: { + location: Location; + routes: RouteObject[]; + navigationType: Action; + version: V6CompatibleVersion; + matches?: AgnosticDataRouteMatch; + basename?: string; + allRoutes?: RouteObject[]; +}): void { + const { location, routes, navigationType, version, matches, basename, allRoutes } = opts; + const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename); const client = getClient(); @@ -231,7 +251,18 @@ export function handleNavigation( } if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) { - const [name, source] = getNormalizedName(routes, location, branches, basename); + let name, + source: TransactionSource = 'url'; + const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes || routes); + + if (isInDescendantRoute) { + name = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes || routes, location)); + source = 'route'; + } + + if (!isInDescendantRoute || !name) { + [name, source] = getNormalizedName(routes, location, branches, basename); + } startBrowserTracingNavigationSpan(client, { name, @@ -286,12 +317,91 @@ function sendIndexPath(pathBuilder: string, pathname: string, basename: string): return [formattedPath, 'route']; } -function pathEndsWithWildcard(path: string, branch: RouteMatch): boolean { - return (path.slice(-2) === '/*' && branch.route.children && branch.route.children.length > 0) || false; +function pathEndsWithWildcard(path: string): boolean { + return path.endsWith('*'); } function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch): boolean { - return (path === '*' && branch.route.children && branch.route.children.length > 0) || false; + return (pathEndsWithWildcard(path) && branch.route.children && branch.route.children.length > 0) || false; +} + +function routeIsDescendant(route: RouteObject): boolean { + return !!(!route.children && route.element && route.path && route.path.endsWith('/*')); +} + +function locationIsInsideDescendantRoute(location: Location, routes: RouteObject[]): boolean { + const matchedRoutes = _matchRoutes(routes, location) as RouteMatch[]; + + if (matchedRoutes) { + for (const match of matchedRoutes) { + if (routeIsDescendant(match.route) && pickSplat(match)) { + return true; + } + } + } + + return false; +} + +function getChildRoutesRecursively(route: RouteObject, allRoutes: RouteObject[] = []): RouteObject[] { + if (route.children && !route.index) { + route.children.forEach(child => { + allRoutes.push(...getChildRoutesRecursively(child, allRoutes)); + }); + } + + allRoutes.push(route); + + return allRoutes; +} + +function pickPath(match: RouteMatch): string { + return trimWildcard(match.route.path || ''); +} + +function pickSplat(match: RouteMatch): string { + return match.params['*'] || ''; +} + +function trimWildcard(path: string): string { + return path[path.length - 1] === '*' ? path.slice(0, -1) : path; +} + +function trimSlash(path: string): string { + return path[path.length - 1] === '/' ? path.slice(0, -1) : path; +} + +function prefixWithSlash(path: string): string { + return path[0] === '/' ? path : `/${path}`; +} + +function rebuildRoutePathFromAllRoutes(allRoutes: RouteObject[], location: Location): string { + const matchedRoutes = _matchRoutes(allRoutes, location) as RouteMatch[]; + + if (!matchedRoutes || matchedRoutes.length === 0) { + return ''; + } + + for (const match of matchedRoutes) { + if (match.route.path && match.route.path !== '*') { + const path = pickPath(match); + const strippedPath = stripBasenameFromPathname(location.pathname, prefixWithSlash(match.pathnameBase)); + + return trimSlash( + trimSlash(path || '') + + prefixWithSlash( + rebuildRoutePathFromAllRoutes( + allRoutes.filter(route => route !== match.route), + { + pathname: strippedPath, + }, + ), + ), + ); + } + } + + return ''; } function getNormalizedName( @@ -321,7 +431,7 @@ function getNormalizedName( pathBuilder += newPath; // If the path matches the current location, return the path - if (basename + branch.pathname === location.pathname) { + if (location.pathname.endsWith(basename + branch.pathname)) { if ( // If the route defined on the element is something like // Product} /> @@ -330,13 +440,13 @@ function getNormalizedName( // eslint-disable-next-line deprecation/deprecation getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) && // We should not count wildcard operators in the url segments calculation - pathBuilder.slice(-2) !== '/*' + !pathEndsWithWildcard(pathBuilder) ) { return [(_stripBasename ? '' : basename) + newPath, 'route']; } // if the last character of the pathbuilder is a wildcard and there are children, remove the wildcard - if (pathEndsWithWildcard(pathBuilder, branch)) { + if (pathIsWildcardAndHasChildren(pathBuilder, branch)) { pathBuilder = pathBuilder.slice(0, -1); } @@ -347,7 +457,11 @@ function getNormalizedName( } } - return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url']; + const fallbackTransactionName = _stripBasename + ? stripBasenameFromPathname(location.pathname, basename) + : location.pathname || '/'; + + return [fallbackTransactionName, 'url']; } function updatePageloadTransaction( @@ -356,13 +470,25 @@ function updatePageloadTransaction( routes: RouteObject[], matches?: AgnosticDataRouteMatch, basename?: string, + allRoutes?: RouteObject[], ): void { const branches = Array.isArray(matches) ? matches : (_matchRoutes(routes, location, basename) as unknown as RouteMatch[]); if (branches) { - const [name, source] = getNormalizedName(routes, location, branches, basename); + let name, + source: TransactionSource = 'url'; + const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes || routes); + + if (isInDescendantRoute) { + name = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes || routes, location)); + source = 'route'; + } + + if (!isInDescendantRoute || !name) { + [name, source] = getNormalizedName(routes, location, branches, basename); + } getCurrentScope().setTransactionName(name); @@ -387,9 +513,11 @@ export function createV6CompatibleWithSentryReactRouterRouting

= (props: P) => { + const isMountRenderPass = React.useRef(true); + const location = _useLocation(); const navigationType = _useNavigationType(); @@ -397,11 +525,21 @@ export function createV6CompatibleWithSentryReactRouterRouting

{ const routes = _createRoutesFromChildren(props.children) as RouteObject[]; - if (isMountRenderPass) { - updatePageloadTransaction(getActiveRootSpan(), location, routes); - isMountRenderPass = false; + if (isMountRenderPass.current) { + routes.forEach(route => { + allRoutes.push(...getChildRoutesRecursively(route)); + }); + + updatePageloadTransaction(getActiveRootSpan(), location, routes, undefined, undefined, allRoutes); + isMountRenderPass.current = false; } else { - handleNavigation(location, routes, navigationType, version); + handleNavigation({ + location, + routes, + navigationType, + version, + allRoutes, + }); } }, // `props.children` is purposely not included in the dependency array, because we do not want to re-run this effect diff --git a/packages/react/test/reactrouterv6.4.test.tsx b/packages/react/test/reactrouterv6.4.test.tsx deleted file mode 100644 index 3ae6a69bdf56..000000000000 --- a/packages/react/test/reactrouterv6.4.test.tsx +++ /dev/null @@ -1,676 +0,0 @@ -import { - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - createTransport, - getCurrentScope, - setCurrentClient, -} from '@sentry/core'; -import { render } from '@testing-library/react'; -import { Request } from 'node-fetch'; -import * as React from 'react'; -import { - Navigate, - RouterProvider, - createMemoryRouter, - createRoutesFromChildren, - matchRoutes, - useLocation, - useNavigationType, -} from 'react-router-6.4'; - -import { BrowserClient, wrapCreateBrowserRouter } from '../src'; -import { reactRouterV6BrowserTracingIntegration } from '../src/reactrouterv6'; -import type { CreateRouterFunction } from '../src/types'; - -beforeAll(() => { - // @ts-expect-error need to override global Request because it's not in the jest environment (even with an - // `@jest-environment jsdom` directive, for some reason) - global.Request = Request; -}); - -const mockStartBrowserTracingPageLoadSpan = jest.fn(); -const mockStartBrowserTracingNavigationSpan = jest.fn(); - -const mockRootSpan = { - updateName: jest.fn(), - setAttribute: jest.fn(), - getSpanJSON() { - return { op: 'pageload' }; - }, -}; - -jest.mock('@sentry/browser', () => { - const actual = jest.requireActual('@sentry/browser'); - return { - ...actual, - startBrowserTracingNavigationSpan: (...args: unknown[]) => { - mockStartBrowserTracingNavigationSpan(...args); - return actual.startBrowserTracingNavigationSpan(...args); - }, - startBrowserTracingPageLoadSpan: (...args: unknown[]) => { - mockStartBrowserTracingPageLoadSpan(...args); - return actual.startBrowserTracingPageLoadSpan(...args); - }, - }; -}); - -jest.mock('@sentry/core', () => { - const actual = jest.requireActual('@sentry/core'); - return { - ...actual, - getRootSpan: () => { - return mockRootSpan; - }, - }; -}); - -describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { - function createMockBrowserClient(): BrowserClient { - return new BrowserClient({ - integrations: [], - tracesSampleRate: 1, - transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), - stackParser: () => [], - }); - } - - beforeEach(() => { - jest.clearAllMocks(); - getCurrentScope().setClient(undefined); - }); - - describe('wrapCreateBrowserRouter', () => { - it('starts a pageload transaction', () => { - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.addIntegration( - reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - ); - // eslint-disable-next-line deprecation/deprecation - const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); - - const router = sentryCreateBrowserRouter( - [ - { - path: '/', - element:

TEST
, - }, - ], - { - initialEntries: ['/'], - }, - ); - - // @ts-expect-error router is fine - render(); - - expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', - }, - }); - }); - - it("updates the scope's `transactionName` on a pageload", () => { - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.addIntegration( - reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - ); - // eslint-disable-next-line deprecation/deprecation - const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); - - const router = sentryCreateBrowserRouter( - [ - { - path: '/', - element:
TEST
, - }, - ], - { - initialEntries: ['/'], - }, - ); - - // @ts-expect-error router is fine - render(); - - expect(getCurrentScope().getScopeData().transactionName).toEqual('/'); - }); - - it('starts a navigation transaction', () => { - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.addIntegration( - reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - ); - // eslint-disable-next-line deprecation/deprecation - const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); - - const router = sentryCreateBrowserRouter( - [ - { - path: '/', - element: , - }, - { - path: 'about', - element:
About
, - }, - ], - { - initialEntries: ['/'], - }, - ); - - // @ts-expect-error router is fine - render(); - - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/about', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); - }); - - it('works with nested routes', () => { - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.addIntegration( - reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - ); - // eslint-disable-next-line deprecation/deprecation - const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); - - const router = sentryCreateBrowserRouter( - [ - { - path: '/', - element: , - }, - { - path: 'about', - element:
About
, - children: [ - { - path: 'us', - element:
Us
, - }, - ], - }, - ], - { - initialEntries: ['/'], - }, - ); - - // @ts-expect-error router is fine - render(); - - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/about/us', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); - }); - - it('works with parameterized paths', () => { - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.addIntegration( - reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - ); - // eslint-disable-next-line deprecation/deprecation - const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); - - const router = sentryCreateBrowserRouter( - [ - { - path: '/', - element: , - }, - { - path: 'about', - element:
About
, - children: [ - { - path: ':page', - element:
Page
, - }, - ], - }, - ], - { - initialEntries: ['/'], - }, - ); - - // @ts-expect-error router is fine - render(); - - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/about/:page', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); - }); - - it('works with paths with multiple parameters', () => { - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.addIntegration( - reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - ); - // eslint-disable-next-line deprecation/deprecation - const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); - - const router = sentryCreateBrowserRouter( - [ - { - path: '/', - element: , - }, - { - path: 'stores', - element:
Stores
, - children: [ - { - path: ':storeId', - element:
Store
, - children: [ - { - path: 'products', - element:
Products
, - children: [ - { - path: ':productId', - element:
Product
, - }, - ], - }, - ], - }, - ], - }, - ], - { - initialEntries: ['/'], - }, - ); - - // @ts-expect-error router is fine - render(); - - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/stores/:storeId/products/:productId', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); - }); - - it('updates pageload transaction to a parameterized route', () => { - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.addIntegration( - reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - ); - // eslint-disable-next-line deprecation/deprecation - const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); - - const router = sentryCreateBrowserRouter( - [ - { - path: 'about', - element:
About
, - children: [ - { - path: ':page', - element:
page
, - }, - ], - }, - ], - { - initialEntries: ['/about/us'], - }, - ); - - // @ts-expect-error router is fine - render(); - - expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); - expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/about/:page'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - }); - - it('works with `basename` option', () => { - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.addIntegration( - reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - ); - // eslint-disable-next-line deprecation/deprecation - const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); - - const router = sentryCreateBrowserRouter( - [ - { - path: '/', - element: , - }, - { - path: 'about', - element:
About
, - children: [ - { - path: 'us', - element:
Us
, - }, - ], - }, - ], - { - initialEntries: ['/app'], - basename: '/app', - }, - ); - - // @ts-expect-error router is fine - render(); - - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/app/about/us', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); - }); - - it('works with parameterized paths and `basename`', () => { - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.addIntegration( - reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - ); - // eslint-disable-next-line deprecation/deprecation - const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); - - const router = sentryCreateBrowserRouter( - [ - { - path: '/', - element: , - }, - { - path: ':orgId', - children: [ - { - path: 'users', - children: [ - { - path: ':userId', - element:
User
, - }, - ], - }, - ], - }, - ], - { - initialEntries: ['/admin'], - basename: '/admin', - }, - ); - - // @ts-expect-error router is fine - render(); - - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/admin/:orgId/users/:userId', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); - }); - - it('strips `basename` from transaction names of parameterized paths', () => { - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.addIntegration( - reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - stripBasename: true, - }), - ); - // eslint-disable-next-line deprecation/deprecation - const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); - - const router = sentryCreateBrowserRouter( - [ - { - path: '/', - element: , - }, - { - path: ':orgId', - children: [ - { - path: 'users', - children: [ - { - path: ':userId', - element:
User
, - }, - ], - }, - ], - }, - ], - { - initialEntries: ['/admin'], - basename: '/admin', - }, - ); - - // @ts-expect-error router is fine - render(); - - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/:orgId/users/:userId', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); - }); - - it('strips `basename` from transaction names of non-parameterized paths', () => { - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.addIntegration( - reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - stripBasename: true, - }), - ); - // eslint-disable-next-line deprecation/deprecation - const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); - - const router = sentryCreateBrowserRouter( - [ - { - path: '/', - element: , - }, - { - path: 'about', - element:
About
, - children: [ - { - path: 'us', - element:
Us
, - }, - ], - }, - ], - { - initialEntries: ['/app'], - basename: '/app', - }, - ); - - // @ts-expect-error router is fine - render(); - - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/about/us', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); - }); - - it("updates the scope's `transactionName` on a navigation", () => { - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.addIntegration( - reactRouterV6BrowserTracingIntegration({ - useEffect: React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - ); - // eslint-disable-next-line deprecation/deprecation - const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); - - const router = sentryCreateBrowserRouter( - [ - { - path: '/', - element: , - }, - { - path: 'about', - element:
About
, - }, - ], - { - initialEntries: ['/'], - }, - ); - - // @ts-expect-error router is fine - render(); - - expect(getCurrentScope().getScopeData().transactionName).toEqual('/about'); - }); - }); -}); diff --git a/packages/react/test/reactrouterv6.test.tsx b/packages/react/test/reactrouterv6.test.tsx index b9cf4003c330..815b562f08f7 100644 --- a/packages/react/test/reactrouterv6.test.tsx +++ b/packages/react/test/reactrouterv6.test.tsx @@ -25,7 +25,7 @@ import { BrowserClient } from '../src'; import { reactRouterV6BrowserTracingIntegration, withSentryReactRouterV6Routing, - wrapUseRoutes, + wrapUseRoutesV6, } from '../src/reactrouterv6'; const mockStartBrowserTracingPageLoadSpan = jest.fn(); @@ -491,6 +491,109 @@ describe('reactRouterV6BrowserTracingIntegration', () => { }); }); + it('works with descendant wildcard routes - pageload', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const DetailsRoutes = () => ( + + Details} /> + + ); + + const ViewsRoutes = () => ( + + Views} /> + } /> + + ); + + const ProjectsRoutes = () => ( + + }> + No Match Page} /> + + ); + + render( + + + }> + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/projects/:projectId/views/:viewId/:detailId'); + expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('works with descendant wildcard routes - navigation', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const DetailsRoutes = () => ( + + Details} /> + + ); + + const ViewsRoutes = () => ( + + Views} /> + } /> + + ); + + const ProjectsRoutes = () => ( + + }> + No Match Page} /> + + ); + + render( + + + } /> + }> + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/projects/:projectId/views/:viewId/:detailId', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + it("updates the scope's `transactionName` on a navigation", () => { const client = createMockBrowserClient(); setCurrentClient(client); @@ -521,7 +624,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { }); }); - describe('wrapUseRoutes', () => { + describe('wrapUseRoutesV6', () => { it('starts a pageload transaction', () => { const client = createMockBrowserClient(); setCurrentClient(client); @@ -536,8 +639,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ @@ -578,8 +680,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ @@ -613,8 +714,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ @@ -648,8 +748,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ @@ -686,8 +785,8 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ @@ -731,8 +830,8 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ @@ -782,8 +881,8 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ @@ -833,8 +932,8 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ @@ -890,8 +989,8 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ @@ -971,8 +1070,8 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ @@ -1037,6 +1136,150 @@ describe('reactRouterV6BrowserTracingIntegration', () => { }); }); + it('works with descendant wildcard routes - pageload', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); + + const DetailsRoutes = () => + wrappedUseRoutes([ + { + path: ':detailId', + element:
Details
, + }, + ]); + + const ViewsRoutes = () => + wrappedUseRoutes([ + { + index: true, + element:
Views
, + }, + { + path: 'views/:viewId/*', + element: , + }, + ]); + + const ProjectsRoutes = () => + wrappedUseRoutes([ + { + path: 'projects/:projectId/*', + element: , + }, + { + path: '*', + element:
No Match Page
, + }, + ]); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/*', + element: , + }, + ]); + + render( + + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/projects/:projectId/views/:viewId/:detailId'); + expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('works with descendant wildcard routes - navigation', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); + + const DetailsRoutes = () => + wrappedUseRoutes([ + { + path: ':detailId', + element:
Details
, + }, + ]); + + const ViewsRoutes = () => + wrappedUseRoutes([ + { + index: true, + element:
Views
, + }, + { + path: 'views/:viewId/*', + element: , + }, + ]); + + const ProjectsRoutes = () => + wrappedUseRoutes([ + { + path: 'projects/:projectId/*', + element: , + }, + { + path: '*', + element:
No Match Page
, + }, + ]); + + const Routes = () => + wrappedUseRoutes([ + { + index: true, + element: , + }, + { + path: '/*', + element: , + }, + ]); + + render( + + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/projects/:projectId/views/:viewId/:detailId', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + it('does not add double slashes to URLS', () => { const client = createMockBrowserClient(); setCurrentClient(client); @@ -1050,8 +1293,8 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ @@ -1109,8 +1352,8 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ @@ -1167,8 +1410,8 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); - // eslint-disable-next-line deprecation/deprecation - const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const wrappedUseRoutes = wrapUseRoutesV6(useRoutes); const Routes = () => wrappedUseRoutes([ diff --git a/yarn.lock b/yarn.lock index 94e55b188b68..5c2eb8197a5f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8201,10 +8201,10 @@ history "^5.3.0" react-router-dom "^6.2.2" -"@remix-run/router@1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.0.2.tgz#1c17eadb2fa77f80a796ad5ea9bf108e6993ef06" - integrity sha512-GRSOFhJzjGN+d4sKHTMSvNeUPoZiDHWmRnXfzaxrqe7dE/Nzlc8BiMSJdLDESZlndM7jIUrZ/F4yWqVYlI0rwQ== +"@remix-run/router@1.21.0": + version "1.21.0" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.21.0.tgz#c65ae4262bdcfe415dbd4f64ec87676e4a56e2b5" + integrity sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA== "@remix-run/router@1.x": version "1.15.0" @@ -28793,19 +28793,12 @@ react-is@^18.0.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" -"react-router-6.4@npm:react-router@6.4.2": - version "6.4.2" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.4.2.tgz#300628ee9ed81b8ef1597b5cb98b474efe9779b8" - integrity sha512-Rb0BAX9KHhVzT1OKhMvCDMw776aTYM0DtkxqUBP8dNBom3mPXlfNs76JNGK8wKJ1IZEY1+WGj+cvZxHVk/GiKw== +"react-router-6@npm:react-router@6.28.0": + version "6.28.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.28.0.tgz#29247c86d7ba901d7e5a13aa79a96723c3e59d0d" + integrity sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg== dependencies: - "@remix-run/router" "1.0.2" - -"react-router-6@npm:react-router@6.3.0": - version "6.3.0" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" - integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== - dependencies: - history "^5.2.0" + "@remix-run/router" "1.21.0" react-router-dom@^6.2.2: version "6.3.0"