From bd6e1e8b75b3a1bd66e3e81e3f018f35cbaf0654 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 4 Feb 2025 00:31:57 +0000 Subject: [PATCH 1/8] fix(react): Break if path is not changed in recursive rebuild. --- .../react/src/reactrouterv6-compat-utils.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/react/src/reactrouterv6-compat-utils.tsx b/packages/react/src/reactrouterv6-compat-utils.tsx index f9cf76cfc498..99fa4f8c5898 100644 --- a/packages/react/src/reactrouterv6-compat-utils.tsx +++ b/packages/react/src/reactrouterv6-compat-utils.tsx @@ -85,7 +85,11 @@ export function createV6CompatibleWrapCreateBrowserRouter< return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter { routes.forEach(route => { - allRoutes.add(route); + const extractedChildRoutes = getChildRoutesRecursively(route); + + extractedChildRoutes.forEach(r => { + allRoutes.add(r); + }); }); const router = createRouterFunction(routes, opts); @@ -166,7 +170,11 @@ export function createV6CompatibleWrapCreateMemoryRouter< }, ): TRouter { routes.forEach(route => { - allRoutes.add(route); + const extractedChildRoutes = getChildRoutesRecursively(route); + + extractedChildRoutes.forEach(r => { + allRoutes.add(r); + }); }); const router = createRouterFunction(routes, opts); @@ -458,7 +466,9 @@ function getChildRoutesRecursively(route: RouteObject, allRoutes: Set { const childRoutes = getChildRoutesRecursively(child, allRoutes); - childRoutes.forEach(r => allRoutes.add(r)); + childRoutes.forEach(r => { + allRoutes.add(r); + }); }); } } @@ -498,6 +508,11 @@ function rebuildRoutePathFromAllRoutes(allRoutes: RouteObject[], location: Locat const path = pickPath(match); const strippedPath = stripBasenameFromPathname(location.pathname, prefixWithSlash(match.pathnameBase)); + if (location.pathname === strippedPath) { + return trimSlash(strippedPath); + } + + return trimSlash( trimSlash(path || '') + prefixWithSlash( @@ -588,6 +603,8 @@ function updatePageloadTransaction( if (branches) { let name, source: TransactionSource = 'url'; + + debugger; const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes || routes); if (isInDescendantRoute) { From 83f2fb6322de1491b74f3c0e78670f1ec7c18424 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 4 Feb 2025 00:32:53 +0000 Subject: [PATCH 2/8] Lint --- packages/react/src/reactrouterv6-compat-utils.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react/src/reactrouterv6-compat-utils.tsx b/packages/react/src/reactrouterv6-compat-utils.tsx index 99fa4f8c5898..8ec427d8cfdb 100644 --- a/packages/react/src/reactrouterv6-compat-utils.tsx +++ b/packages/react/src/reactrouterv6-compat-utils.tsx @@ -512,7 +512,6 @@ function rebuildRoutePathFromAllRoutes(allRoutes: RouteObject[], location: Locat return trimSlash(strippedPath); } - return trimSlash( trimSlash(path || '') + prefixWithSlash( @@ -604,7 +603,6 @@ function updatePageloadTransaction( let name, source: TransactionSource = 'url'; - debugger; const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes || routes); if (isInDescendantRoute) { From 3b2b90b85be3371738272210ac673679d0e113ed Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 4 Feb 2025 12:55:21 +0000 Subject: [PATCH 3/8] Deduplicate route cross-storing logic. --- .../react/src/reactrouterv6-compat-utils.tsx | 43 +++++++------------ 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/packages/react/src/reactrouterv6-compat-utils.tsx b/packages/react/src/reactrouterv6-compat-utils.tsx index 8ec427d8cfdb..aeb9542c386d 100644 --- a/packages/react/src/reactrouterv6-compat-utils.tsx +++ b/packages/react/src/reactrouterv6-compat-utils.tsx @@ -84,13 +84,7 @@ export function createV6CompatibleWrapCreateBrowserRouter< } return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter { - routes.forEach(route => { - const extractedChildRoutes = getChildRoutesRecursively(route); - - extractedChildRoutes.forEach(r => { - allRoutes.add(r); - }); - }); + addRoutesToAllRoutes(routes); const router = createRouterFunction(routes, opts); const basename = opts?.basename; @@ -169,13 +163,7 @@ export function createV6CompatibleWrapCreateMemoryRouter< initialIndex?: number; }, ): TRouter { - routes.forEach(route => { - const extractedChildRoutes = getChildRoutesRecursively(route); - - extractedChildRoutes.forEach(r => { - allRoutes.add(r); - }); - }); + addRoutesToAllRoutes(routes); const router = createRouterFunction(routes, opts); const basename = opts?.basename; @@ -311,13 +299,7 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio typeof stableLocationParam === 'string' ? { pathname: stableLocationParam } : stableLocationParam; if (isMountRenderPass.current) { - routes.forEach(route => { - const extractedChildRoutes = getChildRoutesRecursively(route); - - extractedChildRoutes.forEach(r => { - allRoutes.add(r); - }); - }); + addRoutesToAllRoutes(routes); updatePageloadTransaction( getActiveRootSpan(), @@ -458,6 +440,17 @@ function locationIsInsideDescendantRoute(location: Location, routes: RouteObject return false; } +function addRoutesToAllRoutes(routes: RouteObject[]): void { + routes.forEach(route => { + const extractedChildRoutes = getChildRoutesRecursively(route); + + extractedChildRoutes.forEach(r => { + allRoutes.add(r); + }); + }); +} + + function getChildRoutesRecursively(route: RouteObject, allRoutes: Set = new Set()): Set { if (!allRoutes.has(route)) { allRoutes.add(route); @@ -648,13 +641,7 @@ export function createV6CompatibleWithSentryReactRouterRouting

{ - const extractedChildRoutes = getChildRoutesRecursively(route); - - extractedChildRoutes.forEach(r => { - allRoutes.add(r); - }); - }); + addRoutesToAllRoutes(routes); updatePageloadTransaction(getActiveRootSpan(), location, routes, undefined, undefined, Array.from(allRoutes)); isMountRenderPass.current = false; From 4420f37e2b42ba814ceab8b7ce54cb399a44dfef Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 4 Feb 2025 19:11:23 +0000 Subject: [PATCH 4/8] Revisit cross-usage with tests. --- .../react/src/reactrouterv6-compat-utils.tsx | 26 +- .../test/reactrouter-cross-usage.test.tsx | 789 ++++++++++++++++++ 2 files changed, 806 insertions(+), 9 deletions(-) create mode 100644 packages/react/test/reactrouter-cross-usage.test.tsx diff --git a/packages/react/src/reactrouterv6-compat-utils.tsx b/packages/react/src/reactrouterv6-compat-utils.tsx index aeb9542c386d..492cd6c97e14 100644 --- a/packages/react/src/reactrouterv6-compat-utils.tsx +++ b/packages/react/src/reactrouterv6-compat-utils.tsx @@ -361,14 +361,23 @@ export function handleNavigation(opts: { [name, source] = getNormalizedName(routes, location, branches, basename); } - startBrowserTracingNavigationSpan(client, { - name, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, - }, - }); + const activeSpan = getActiveSpan(); + const isAlreadyInNavigationSpan = activeSpan && spanToJSON(activeSpan).op === 'navigation'; + + // Cross usage can result in multiple navigation spans being created without this check + if (isAlreadyInNavigationSpan) { + activeSpan?.updateName(name); + activeSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + } else { + startBrowserTracingNavigationSpan(client, { + name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, + }, + }); + } } } @@ -450,7 +459,6 @@ function addRoutesToAllRoutes(routes: RouteObject[]): void { }); } - function getChildRoutesRecursively(route: RouteObject, allRoutes: Set = new Set()): Set { if (!allRoutes.has(route)) { allRoutes.add(route); diff --git a/packages/react/test/reactrouter-cross-usage.test.tsx b/packages/react/test/reactrouter-cross-usage.test.tsx new file mode 100644 index 000000000000..9a440198d9a8 --- /dev/null +++ b/packages/react/test/reactrouter-cross-usage.test.tsx @@ -0,0 +1,789 @@ +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 * as React from 'react'; +import { + MemoryRouter, + Navigate, + Route, + RouterProvider, + Routes, + createMemoryRouter, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, + useRoutes, +} from 'react-router-6'; + +import { BrowserClient } from '../src'; +import { + reactRouterV6BrowserTracingIntegration, + withSentryReactRouterV6Routing, + wrapCreateMemoryRouterV6, + wrapUseRoutesV6, +} from '../src/reactrouterv6'; + +const mockStartBrowserTracingPageLoadSpan = jest.fn(); +const mockStartBrowserTracingNavigationSpan = jest.fn(); + +const mockNavigationSpan = { + updateName: jest.fn(), + setAttribute: 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; + }, + getActiveSpan: () => { + const span = actual.getActiveSpan(); + + span.updateName = mockNavigationSpan.updateName; + span.setAttribute = mockNavigationSpan.setAttribute; + + return span; + }, + }; +}); + +describe('React Router cross usage of wrappers', () => { + function createMockBrowserClient(): BrowserClient { + return new BrowserClient({ + integrations: [], + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + getCurrentScope().setClient(undefined); + }); + + describe('wrapCreateBrowserRouter and wrapUseRoutes', () => { + it('works with descendant wildcard routes - pageload', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const sentryUseRoutes = wrapUseRoutesV6(useRoutes); + + const ThirdLevel = () =>

Details
; + + const ThirdLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: '/', + element:
, + }, + { + path: ':id', + element: , + }, + ]); + + const SecondLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: 'third-level/*', + element: , + }, + { + path: '/', + element:
, + }, + { + path: '*', + element:
, + }, + ]); + + const TopLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: 'second-level/:id/*', + element: , + }, + { + path: '/', + element:
, + }, + { + path: '*', + element:
, + }, + ]); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { + path: '/*', + element: , + }, + ], + }, + ], + { + initialEntries: ['/second-level/321/third-level/123'], + }, + ); + + const { container } = render( + + + , + ); + + expect(container.innerHTML).toContain('Details'); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id'); + 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 sentryUseRoutes = wrapUseRoutesV6(useRoutes); + + const ThirdLevel = () =>
Details
; + + const ThirdLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: '/', + element:
, + }, + { + path: ':id', + element: , + }, + ]); + + const SecondLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: 'third-level/*', + element: , + }, + { + path: '/', + element:
, + }, + { + path: '*', + element:
, + }, + ]); + + const TopLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: 'second-level/:id/*', + element: , + }, + { + path: '*', + element:
, + }, + ]); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { + path: '/*', + element: , + }, + { + path: '/navigate', + element: , + }, + ], + }, + ], + { + initialEntries: ['/navigate'], + }, + ); + + const { container } = render( + + + , + ); + + expect(container.innerHTML).toContain('Details'); + + // It's called 1 time from the wrapped `MemoryRouter` + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + + // It's called 3 times from the 3 `useRoutes` components + expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(3); + expect(mockNavigationSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/second-level/:id/third-level/:id', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + }); + + describe('withSentryReactRouterRouting and wrapUseRoutes', () => { + it('works with descendant wildcard routes - pageload', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const sentryUseRoutes = wrapUseRoutesV6(useRoutes); + + const ThirdLevel = () =>
Details
; + + const ThirdLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: '/', + element:
, + }, + { + path: ':id', + element: , + }, + ]); + + const SecondLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: 'third-level/*', + element: , + }, + { + path: '/', + element:
, + }, + { + path: '*', + element:
, + }, + ]); + + const TopLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: 'second-level/:id/*', + element: , + }, + { + path: '/', + element:
, + }, + { + path: '*', + element:
, + }, + ]); + + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const { container } = render( + + + + } /> + + + , + ); + + expect(container.innerHTML).toContain('Details'); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id'); + 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 sentryUseRoutes = wrapUseRoutesV6(useRoutes); + + const ThirdLevel = () =>
Details
; + + const ThirdLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: '/', + element:
, + }, + { + path: ':id', + element: , + }, + ]); + + const SecondLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: 'third-level/*', + element: , + }, + { + path: '/', + element:
, + }, + { + path: '*', + element:
, + }, + ]); + + const TopLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: 'second-level/:id/*', + element: , + }, + { + path: '*', + element:
, + }, + ]); + + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const { container } = render( + + + + } /> + } /> + + + , + ); + + expect(container.innerHTML).toContain('Details'); + + // It's called 1 time from the wrapped `MemoryRouter` + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + + // It's called 3 times from the 3 `useRoutes` components + expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(3); + expect(mockNavigationSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id'); + expect(mockNavigationSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + }); + + describe('withSentryReactRouterRouting and wrapCreateBrowserRouter', () => { + it('works with descendant wildcard routes - pageload', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const ThirdLevel = () =>
Details
; + + const ThirdLevelRoutes: React.FC = () => ( + + } /> + } /> + + ); + + const SecondLevelRoutes: React.FC = () => ( + + } /> + } /> + } /> + + ); + + const TopLevelRoutes: React.FC = () => ( + + } /> + } /> + } /> + + ); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { + path: '/*', + element: , + }, + ], + }, + ], + { + initialEntries: ['/second-level/321/third-level/123'], + }, + ); + + const { container } = render( + + + , + ); + + expect(container.innerHTML).toContain('Details'); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id'); + 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 createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const ThirdLevel = () =>
Details
; + + const ThirdLevelRoutes: React.FC = () => ( + + } /> + } /> + + ); + + const SecondLevelRoutes: React.FC = () => ( + + } /> + } /> + } /> + + ); + + const TopLevelRoutes: React.FC = () => ( + + } /> + } /> + + ); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { + path: '/*', + element: , + }, + { + path: '/navigate', + element: , + }, + ], + }, + ], + { + initialEntries: ['/navigate'], + }, + ); + + const { container } = render( + + + , + ); + + expect(container.innerHTML).toContain('Details'); + + // It's called 1 time from the wrapped `createMemoryRouter` + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + + // It's called 3 times from the 3 `SentryRoutes` components + expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(3); + expect(mockNavigationSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/second-level/:id/third-level/:id', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + }); + + describe('withSentryReactRouterRouting and wrapUseRoutes and wrapCreateBrowserRouter', () => { + it('works with descendant wildcard routes - pageload', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const sentryUseRoutes = wrapUseRoutesV6(useRoutes); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const ThirdLevel = () =>
Details
; + + const ThirdLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: '/', + element:
, + }, + { + path: ':id', + element: , + }, + ]); + + const SecondLevelRoutes: React.FC = () => ( + + } /> + } /> + } /> + + ); + + const TopLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: 'second-level/:id/*', + element: , + }, + { + path: '*', + element:
, + }, + ]); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { + path: '/*', + element: , + }, + ], + }, + ], + { + initialEntries: ['/second-level/321/third-level/123'], + }, + ); + + const { container } = render( + + + , + ); + + expect(container.innerHTML).toContain('Details'); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id'); + 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 sentryUseRoutes = wrapUseRoutesV6(useRoutes); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const ThirdLevel = () =>
Details
; + + const ThirdLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: '/', + element:
, + }, + { + path: ':id', + element: , + }, + ]); + + const SecondLevelRoutes: React.FC = () => ( + + } /> + } /> + } /> + + ); + + const TopLevelRoutes: React.FC = () => + sentryUseRoutes([ + { + path: 'second-level/:id/*', + element: , + }, + { + path: '*', + element:
, + }, + ]); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { + path: '/*', + element: , + }, + { + path: '/navigate', + element: , + }, + ], + }, + ], + { + initialEntries: ['/navigate'], + }, + ); + + const { container } = render( + + + , + ); + + expect(container.innerHTML).toContain('Details'); + + // It's called 1 time from the wrapped `MemoryRouter` + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + + // It's called 3 times from the 2 `useRoutes` components and 1 component + expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(3); + + expect(mockNavigationSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id'); + expect(mockNavigationSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + }); +}); From 8c2cf547947566af39a6990c10cb3b2f0344b740 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 4 Feb 2025 22:57:01 +0000 Subject: [PATCH 5/8] Add E2E tests. --- .../react-router-6-cross-usage/.gitignore | 29 ++++ .../react-router-6-cross-usage/.npmrc | 2 + .../react-router-6-cross-usage/package.json | 55 +++++++ .../playwright.config.mjs | 7 + .../public/index.html | 24 +++ .../react-router-6-cross-usage/server/app.js | 47 ++++++ .../src/globals.d.ts | 5 + .../react-router-6-cross-usage/src/index.tsx | 108 ++++++++++++++ .../src/pages/Index.tsx | 18 +++ .../src/react-app-env.d.ts | 1 + .../start-event-proxy.mjs | 6 + .../tests/transactions.test.ts | 138 ++++++++++++++++++ .../react-router-6-cross-usage/tsconfig.json | 20 +++ 13 files changed, 460 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/package.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/public/index.html create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/server/app.js create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/pages/Index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/react-app-env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/.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-cross-usage/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/.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-cross-usage/package.json b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/package.json new file mode 100644 index 000000000000..0e83d278fa93 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/package.json @@ -0,0 +1,55 @@ +{ + "name": "react-router-6-cross-usage", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "latest || *", + "@types/react": "18.0.0", + "@types/react-dom": "18.0.0", + "express": "4.20.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "^6.28.0", + "react-scripts": "5.0.1", + "typescript": "~5.0.0" + }, + "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.50.0", + "@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-cross-usage/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/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-cross-usage/public/index.html b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/public/index.html new file mode 100644 index 000000000000..39da76522bea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/public/index.html @@ -0,0 +1,24 @@ + + + + + + + + React App + + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/server/app.js b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/server/app.js new file mode 100644 index 000000000000..5a8cdb3929a1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/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-cross-usage/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/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-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/index.tsx new file mode 100644 index 000000000000..0c65cb7048fd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/index.tsx @@ -0,0 +1,108 @@ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { + BrowserRouter, + Outlet, + Route, + RouterProvider, + Routes, + createBrowserRouter, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, + useRoutes, +} 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 sentryUseRoutes = Sentry.wrapUseRoutesV6(useRoutes); +const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter); + +const DetailsRoutes = () => + sentryUseRoutes([ + { + path: ':detailId', + element:
Details
, + }, + ]); + +const DetailsRoutesAlternative = () => ( + + Details
} /> + +); + +const ViewsRoutes = () => + sentryUseRoutes([ + { + index: true, + element:
Views
, + }, + { + path: 'views/:viewId/*', + element: , + }, + { + path: 'old-views/:viewId/*', + element: , + }, + ]); + +const ProjectsRoutes = () => ( + + }> + Project Page Root
} /> + }> + } /> + + + +); + +const router = sentryCreateBrowserRouter([ + { + children: [ + { + path: '/', + element: , + }, + { + path: '/*', + element: , + }, + ], + }, +]); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render(); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/pages/Index.tsx new file mode 100644 index 000000000000..d2362c149f84 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/pages/Index.tsx @@ -0,0 +1,18 @@ +// 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 + + + navigate old + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/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-cross-usage/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/start-event-proxy.mjs new file mode 100644 index 000000000000..91b4b540aefb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-6-cross-usage', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/tests/transactions.test.ts new file mode 100644 index 000000000000..906e5da00f9d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/tests/transactions.test.ts @@ -0,0 +1,138 @@ +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-cross-usage', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/projects/123/views/234/567`); + + const rootSpan = await transactionPromise; + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + 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 pageload transaction with a parameterized URL - alternative route', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-6-cross-usage', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/projects/234/old-views/234/567`); + + const rootSpan = await transactionPromise; + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.react.reactrouter_v6', + }, + }, + transaction: '/projects/:projectId/old-views/:viewId/:detailId', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('react-router-6-cross-usage', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('react-router-6-cross-usage', 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((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react.reactrouter_v6', + }, + }, + transaction: '/projects/:projectId/views/:viewId/:detailId', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL - alternative route', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('react-router-6-cross-usage', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('react-router-6-cross-usage', 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=old-navigation'); + + const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react.reactrouter_v6', + }, + }, + transaction: '/projects/:projectId/old-views/:viewId/:detailId', + transaction_info: { + source: 'route', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/tsconfig.json new file mode 100644 index 000000000000..4cc95dc2689a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/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"] +} From e248f4d97677ba55e60854ecdc53cf3539971dc6 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 4 Feb 2025 23:02:34 +0000 Subject: [PATCH 6/8] Lint --- .../test-applications/react-router-6-cross-usage/src/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/index.tsx index 0c65cb7048fd..50f237217f10 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/index.tsx @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/react'; import React from 'react'; import ReactDOM from 'react-dom/client'; import { - BrowserRouter, Outlet, Route, RouterProvider, From 88fac092abe48dee55e5e86aacd45f90b3269f78 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 24 Feb 2025 18:10:51 +0000 Subject: [PATCH 7/8] Use react-router v7 in e2e tests --- .../react-router-6-cross-usage/server/app.js | 47 ------------------- .../.gitignore | 0 .../.npmrc | 0 .../package.json | 8 ++-- .../playwright.config.mjs | 0 .../public/index.html | 0 .../src/globals.d.ts | 0 .../src/index.tsx | 8 ++-- .../src/pages/Index.tsx | 0 .../src/react-app-env.d.ts | 0 .../start-event-proxy.mjs | 2 +- .../tests/transactions.test.ts | 24 +++++----- .../tsconfig.json | 0 13 files changed, 20 insertions(+), 69 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/server/app.js rename dev-packages/e2e-tests/test-applications/{react-router-6-cross-usage => react-router-7-cross-usage}/.gitignore (100%) rename dev-packages/e2e-tests/test-applications/{react-router-6-cross-usage => react-router-7-cross-usage}/.npmrc (100%) rename dev-packages/e2e-tests/test-applications/{react-router-6-cross-usage => react-router-7-cross-usage}/package.json (86%) rename dev-packages/e2e-tests/test-applications/{react-router-6-cross-usage => react-router-7-cross-usage}/playwright.config.mjs (100%) rename dev-packages/e2e-tests/test-applications/{react-router-6-cross-usage => react-router-7-cross-usage}/public/index.html (100%) rename dev-packages/e2e-tests/test-applications/{react-router-6-cross-usage => react-router-7-cross-usage}/src/globals.d.ts (100%) rename dev-packages/e2e-tests/test-applications/{react-router-6-cross-usage => react-router-7-cross-usage}/src/index.tsx (92%) rename dev-packages/e2e-tests/test-applications/{react-router-6-cross-usage => react-router-7-cross-usage}/src/pages/Index.tsx (100%) rename dev-packages/e2e-tests/test-applications/{react-router-6-cross-usage => react-router-7-cross-usage}/src/react-app-env.d.ts (100%) rename dev-packages/e2e-tests/test-applications/{react-router-6-cross-usage => react-router-7-cross-usage}/start-event-proxy.mjs (69%) rename dev-packages/e2e-tests/test-applications/{react-router-6-cross-usage => react-router-7-cross-usage}/tests/transactions.test.ts (84%) rename dev-packages/e2e-tests/test-applications/{react-router-6-cross-usage => react-router-7-cross-usage}/tsconfig.json (100%) diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/server/app.js b/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/server/app.js deleted file mode 100644 index 5a8cdb3929a1..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/server/app.js +++ /dev/null @@ -1,47 +0,0 @@ -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-cross-usage/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/.gitignore similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/.gitignore rename to dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/.gitignore diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/.npmrc similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/.npmrc rename to dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/.npmrc diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/package.json similarity index 86% rename from dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/package.json rename to dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/package.json index 0e83d278fa93..b86327f94772 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/package.json @@ -1,5 +1,5 @@ { - "name": "react-router-6-cross-usage", + "name": "react-router-7-cross-usage", "version": "0.1.0", "private": true, "dependencies": { @@ -9,15 +9,13 @@ "express": "4.20.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "^6.28.0", + "react-router-dom": "^7.2.0", "react-scripts": "5.0.1", "typescript": "~5.0.0" }, "scripts": { "build": "react-scripts build", - "start": "run-p start:client start:server", - "start:client": "node server/app.js", - "start:server": "serve -s build", + "start": "serve -s build", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && npx playwright install && pnpm build", diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/playwright.config.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/playwright.config.mjs rename to dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/playwright.config.mjs diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/public/index.html b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/public/index.html similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/public/index.html rename to dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/public/index.html diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/globals.d.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/globals.d.ts rename to dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/globals.d.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx similarity index 92% rename from dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/index.tsx rename to dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx index 50f237217f10..bfcc527ded1b 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx @@ -21,7 +21,7 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ - Sentry.reactRouterV6BrowserTracingIntegration({ + Sentry.reactRouterV7BrowserTracingIntegration({ useEffect: React.useEffect, useLocation, useNavigationType, @@ -43,9 +43,9 @@ Sentry.init({ tunnel: 'http://localhost:3031', }); -const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); -const sentryUseRoutes = Sentry.wrapUseRoutesV6(useRoutes); -const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter); +const SentryRoutes = Sentry.withSentryReactRouterV7Routing(Routes); +const sentryUseRoutes = Sentry.wrapUseRoutesV7(useRoutes); +const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV7(createBrowserRouter); const DetailsRoutes = () => sentryUseRoutes([ diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/pages/Index.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/pages/Index.tsx rename to dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/pages/Index.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/react-app-env.d.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/src/react-app-env.d.ts rename to dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/react-app-env.d.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/start-event-proxy.mjs similarity index 69% rename from dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/start-event-proxy.mjs rename to dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/start-event-proxy.mjs index 91b4b540aefb..0b60f17c3266 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'react-router-6-cross-usage', + proxyServerName: 'react-router-7-cross-usage', }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts similarity index 84% rename from dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts index 906e5da00f9d..1b521964f770 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts @@ -2,7 +2,7 @@ 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-cross-usage', async transactionEvent => { + const transactionPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; }); @@ -15,7 +15,7 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = contexts: { trace: { op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', + origin: 'auto.pageload.react.reactrouter_v7', }, }, transaction: '/projects/:projectId/views/:viewId/:detailId', @@ -26,7 +26,7 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = }); test('sends a pageload transaction with a parameterized URL - alternative route', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-cross-usage', async transactionEvent => { + const transactionPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; }); @@ -39,7 +39,7 @@ test('sends a pageload transaction with a parameterized URL - alternative route' contexts: { trace: { op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', + origin: 'auto.pageload.react.reactrouter_v7', }, }, transaction: '/projects/:projectId/old-views/:viewId/:detailId', @@ -50,11 +50,11 @@ test('sends a pageload transaction with a parameterized URL - alternative route' }); test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-cross-usage', async transactionEvent => { + const pageloadTxnPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; }); - const navigationTxnPromise = waitForTransaction('react-router-6-cross-usage', async transactionEvent => { + const navigationTxnPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; }); @@ -65,7 +65,7 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) contexts: { trace: { op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', + origin: 'auto.pageload.react.reactrouter_v7', }, }, transaction: '/', @@ -83,7 +83,7 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) contexts: { trace: { op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', + origin: 'auto.navigation.react.reactrouter_v7', }, }, transaction: '/projects/:projectId/views/:viewId/:detailId', @@ -94,11 +94,11 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) }); test('sends a navigation transaction with a parameterized URL - alternative route', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-cross-usage', async transactionEvent => { + const pageloadTxnPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; }); - const navigationTxnPromise = waitForTransaction('react-router-6-cross-usage', async transactionEvent => { + const navigationTxnPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; }); @@ -109,7 +109,7 @@ test('sends a navigation transaction with a parameterized URL - alternative rout contexts: { trace: { op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', + origin: 'auto.pageload.react.reactrouter_v7', }, }, transaction: '/', @@ -127,7 +127,7 @@ test('sends a navigation transaction with a parameterized URL - alternative rout contexts: { trace: { op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', + origin: 'auto.navigation.react.reactrouter_v7', }, }, transaction: '/projects/:projectId/old-views/:viewId/:detailId', diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tsconfig.json similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-6-cross-usage/tsconfig.json rename to dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tsconfig.json From 003c103b5f307472dd358650e902422914c5f80a Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 25 Feb 2025 18:21:53 +0000 Subject: [PATCH 8/8] Switch to vitest --- .../test/reactrouter-cross-usage.test.tsx | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/react/test/reactrouter-cross-usage.test.tsx b/packages/react/test/reactrouter-cross-usage.test.tsx index 9a440198d9a8..e268c774bdd1 100644 --- a/packages/react/test/reactrouter-cross-usage.test.tsx +++ b/packages/react/test/reactrouter-cross-usage.test.tsx @@ -1,3 +1,8 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -30,24 +35,25 @@ import { wrapUseRoutesV6, } from '../src/reactrouterv6'; -const mockStartBrowserTracingPageLoadSpan = jest.fn(); -const mockStartBrowserTracingNavigationSpan = jest.fn(); + +const mockStartBrowserTracingPageLoadSpan = vi.fn(); +const mockStartBrowserTracingNavigationSpan = vi.fn(); const mockNavigationSpan = { - updateName: jest.fn(), - setAttribute: jest.fn(), + updateName: vi.fn(), + setAttribute: vi.fn(), }; const mockRootSpan = { - updateName: jest.fn(), - setAttribute: jest.fn(), + updateName: vi.fn(), + setAttribute: vi.fn(), getSpanJSON() { return { op: 'pageload' }; }, }; -jest.mock('@sentry/browser', () => { - const actual = jest.requireActual('@sentry/browser'); +vi.mock('@sentry/browser', async requireActual => { + const actual = (await requireActual()) as any; return { ...actual, startBrowserTracingNavigationSpan: (...args: unknown[]) => { @@ -61,8 +67,18 @@ jest.mock('@sentry/browser', () => { }; }); -jest.mock('@sentry/core', () => { - const actual = jest.requireActual('@sentry/core'); +vi.mock('@sentry/core', async requireActual => { + return { + ...(await requireActual()), + getRootSpan: () => { + return mockRootSpan; + }, + }; +}); + + +vi.mock('@sentry/core', async requireActual => { + const actual = (await requireActual()) as any; return { ...actual, getRootSpan: () => { @@ -90,7 +106,7 @@ describe('React Router cross usage of wrappers', () => { } beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); getCurrentScope().setClient(undefined); });