diff --git a/MIGRATION.md b/MIGRATION.md index b653c67d9be9..6d4b806333b4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1303,6 +1303,41 @@ Affected SDKs: `@sentry/remix`. The plugin now also applies the build-time instrumentation transform. If you added `sentryOrchestrionPlugin()` from `@sentry/server-utils/orchestrion/vite` to your Vite config manually, remove it. Opt out with `sentryRemixVitePlugin({ buildTimeInstrumentation: false })`. +### React: Simpler React Router setup via `@sentry/react/react-router` + +Affected SDKs: `@sentry/react`. + +`@sentry/react` gained a new `@sentry/react/react-router` entry point that pulls the required React Router hooks (`useLocation`, `useNavigationType`, `matchRoutes`, `createRoutesFromChildren`) from `react-router` for you, so you no longer have to thread them through `reactRouterBrowserTracingIntegration` yourself: + +```diff +- import * as Sentry from '@sentry/react'; +- import { useEffect } from 'react'; +- import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router'; ++ import * as Sentry from '@sentry/react'; ++ import { reactRouterBrowserTracingIntegration } from '@sentry/react/react-router'; + + Sentry.init({ + integrations: [ +- Sentry.reactRouterBrowserTracingIntegration({ +- useEffect, +- useLocation, +- useNavigationType, +- createRoutesFromChildren, +- matchRoutes, +- }), ++ reactRouterBrowserTracingIntegration(), + ], + }); +``` + +The `wrapReactRouterRouting`, `wrapUseRoutes`, `wrapCreateBrowserRouter` and `wrapCreateMemoryRouter` helpers are re-exported from `@sentry/react/react-router` as well. + +This entry requires `react-router` to be resolvable — it is declared as an optional peer dependency and supports React Router v6, v7 and v8. If you are on React Router v6 with only `react-router-dom` installed, either add `react-router` as a dependency or keep importing `reactRouterBrowserTracingIntegration` from `@sentry/react` and pass the hooks explicitly. + +The existing `@sentry/react` API is unchanged and keeps working; passing the hooks there is now optional too (`useEffect` in particular is no longer used and can be omitted). + +Additionally — for **every** `@sentry/react` routing setup, not just the new entry — the order in which you add the browser tracing integration and wrap your routes no longer matters. + ## 3. Removed APIs ### `@sentry/core` / All SDKs diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/.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-router-entry/index.html b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/index.html new file mode 100644 index 000000000000..e4b78eae1230 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/index.html @@ -0,0 +1,13 @@ + + +
+ + + +I am a blank page :)
; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/start-event-proxy.mjs new file mode 100644 index 000000000000..4163849952c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-6-router-entry', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/errors.test.ts new file mode 100644 index 000000000000..569ad71e1483 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page }) => { + const errorEventPromise = waitForError('react-router-6-router-entry', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); + +test('Sets correct transactionName', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const errorEventPromise = waitForError('react-router-6-router-entry', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + // Only capture error once the pageload span was sent + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/navigation-trace-propagation.test.ts new file mode 100644 index 000000000000..59f49984caf6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/navigation-trace-propagation.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({ + page, +}) => { + // Intercept the /products data fetch and capture the tracing header the SDK attached. + let productsRequestSentryTrace: string | undefined; + await page.route('**/api/products', async route => { + productsRequestSentryTrace = route.request().headers()['sentry-trace']; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '[]', + }); + }); + + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/products'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + await page.locator('id=navigation-products').click(); + const navigationSpan = await navigationSpanPromise; + + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; + const propagatedTraceId = productsRequestSentryTrace?.split('-')[0]; + + expect(pageloadTraceId).toBeDefined(); + expect(navigationTraceId).toBeDefined(); + expect(propagatedTraceId).toBeDefined(); + expect(navigationTraceId).not.toEqual(pageloadTraceId); + + // The fetch fired on /products must carry the navigation trace, not the stale pageload trace. + expect(propagatedTraceId).toEqual(navigationTraceId); + expect(propagatedTraceId).not.toEqual(pageloadTraceId); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/spans.test.ts new file mode 100644 index 000000000000..7d1b81748c29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/spans.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized route name (no hooks passed to the integration)', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/user/5`); + + const span = await spanPromise; + + expect(span.name).toBe('/user/:id'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized route name', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tsconfig.json new file mode 100644 index 000000000000..bd5b8e2eeb98 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tsconfig.json @@ -0,0 +1,21 @@ +{ + "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": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "types": ["vite/client"] + }, + "include": ["src", "tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/vite.config.ts new file mode 100644 index 000000000000..63c2c4317df7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/vite.config.ts @@ -0,0 +1,8 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + envPrefix: 'PUBLIC_', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/.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-7-router-entry/index.html b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/index.html new file mode 100644 index 000000000000..e4b78eae1230 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/index.html @@ -0,0 +1,13 @@ + + + + + + +I am a blank page :)
; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/sentry-routes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/sentry-routes.tsx new file mode 100644 index 000000000000..a0504ffd6bc4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/sentry-routes.tsx @@ -0,0 +1,8 @@ +import { wrapReactRouterRouting } from '@sentry/react/react-router'; +import { Routes } from 'react-router'; + +// `wrapReactRouterRouting` runs here, at this module's evaluation time. Because `main.tsx` imports +// this module, that happens BEFORE `main.tsx` calls `Sentry.init()`. This deliberately exercises the +// order-independence of the setup: wrapping the routes before Sentry is initialized still instruments +// navigations once the app renders (the wrapper reads its config at render time, after init). +export const SentryRoutes = wrapReactRouterRouting(Routes); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/start-event-proxy.mjs new file mode 100644 index 000000000000..f8424f618609 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-router-entry', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/errors.test.ts new file mode 100644 index 000000000000..41e7e7dbe3aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page }) => { + const errorEventPromise = waitForError('react-router-7-router-entry', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); + +test('Sets correct transactionName', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const errorEventPromise = waitForError('react-router-7-router-entry', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + // Only capture error once the pageload span was sent + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/navigation-trace-propagation.test.ts new file mode 100644 index 000000000000..3c2bb4cc35da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/navigation-trace-propagation.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({ + page, +}) => { + // Intercept the /products data fetch and capture the tracing header the SDK attached. + let productsRequestSentryTrace: string | undefined; + await page.route('**/api/products', async route => { + productsRequestSentryTrace = route.request().headers()['sentry-trace']; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '[]', + }); + }); + + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-7-router-entry', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/products'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + await page.locator('id=navigation-products').click(); + const navigationSpan = await navigationSpanPromise; + + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; + const propagatedTraceId = productsRequestSentryTrace?.split('-')[0]; + + expect(pageloadTraceId).toBeDefined(); + expect(navigationTraceId).toBeDefined(); + expect(propagatedTraceId).toBeDefined(); + expect(navigationTraceId).not.toEqual(pageloadTraceId); + + // The fetch fired on /products must carry the navigation trace, not the stale pageload trace. + expect(propagatedTraceId).toEqual(navigationTraceId); + expect(propagatedTraceId).not.toEqual(pageloadTraceId); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/spans.test.ts new file mode 100644 index 000000000000..fdc6ce54c3a4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/spans.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +// This app wraps its routes (in `src/sentry-routes.tsx`) BEFORE `Sentry.init()` runs. That these +// pageload/navigation spans are still emitted with parameterized route names proves the +// `@sentry/react/react-router` setup is order-independent w.r.t. init - see MIGRATION.md. + +test('sends a pageload span with a parameterized route name (no hooks passed to the integration)', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-7-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/user/5`); + + const span = await spanPromise; + + expect(span.name).toBe('/user/:id'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized route name', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-7-router-entry', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tsconfig.json new file mode 100644 index 000000000000..bd5b8e2eeb98 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tsconfig.json @@ -0,0 +1,21 @@ +{ + "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": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "types": ["vite/client"] + }, + "include": ["src", "tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/vite.config.ts new file mode 100644 index 000000000000..63c2c4317df7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/vite.config.ts @@ -0,0 +1,8 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + envPrefix: 'PUBLIC_', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/.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-8-router-entry/index.html b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/index.html new file mode 100644 index 000000000000..e4b78eae1230 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/index.html @@ -0,0 +1,13 @@ + + + + + + +I am a blank page :)
; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/start-event-proxy.mjs new file mode 100644 index 000000000000..26e08fa7faf0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-8-router-entry', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/errors.test.ts new file mode 100644 index 000000000000..2514d8136f83 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page }) => { + const errorEventPromise = waitForError('react-router-8-router-entry', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); + +test('Sets correct transactionName', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const errorEventPromise = waitForError('react-router-8-router-entry', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + // Only capture error once the pageload span was sent + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/navigation-trace-propagation.test.ts new file mode 100644 index 000000000000..8d3778571e3e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/navigation-trace-propagation.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({ + page, +}) => { + // Intercept the /products data fetch and capture the tracing header the SDK attached. + let productsRequestSentryTrace: string | undefined; + await page.route('**/api/products', async route => { + productsRequestSentryTrace = route.request().headers()['sentry-trace']; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '[]', + }); + }); + + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-8-router-entry', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/products'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + await page.locator('id=navigation-products').click(); + const navigationSpan = await navigationSpanPromise; + + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; + const propagatedTraceId = productsRequestSentryTrace?.split('-')[0]; + + expect(pageloadTraceId).toBeDefined(); + expect(navigationTraceId).toBeDefined(); + expect(propagatedTraceId).toBeDefined(); + expect(navigationTraceId).not.toEqual(pageloadTraceId); + + // The fetch fired on /products must carry the navigation trace, not the stale pageload trace. + expect(propagatedTraceId).toEqual(navigationTraceId); + expect(propagatedTraceId).not.toEqual(pageloadTraceId); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/spans.test.ts new file mode 100644 index 000000000000..f1b488bd21a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/spans.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized route name (no hooks passed to the integration)', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-8-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/user/5`); + + const span = await spanPromise; + + expect(span.name).toBe('/user/:id'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized route name', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-8-router-entry', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tsconfig.json new file mode 100644 index 000000000000..bd5b8e2eeb98 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tsconfig.json @@ -0,0 +1,21 @@ +{ + "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": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "types": ["vite/client"] + }, + "include": ["src", "tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/vite.config.ts new file mode 100644 index 000000000000..63c2c4317df7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/vite.config.ts @@ -0,0 +1,8 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + envPrefix: 'PUBLIC_', +}); diff --git a/packages/react/package.json b/packages/react/package.json index 6718f491759d..02ace40cdc28 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -30,6 +30,27 @@ "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } + }, + "./react-router": { + "react-native": { + "types": "./build/types/react-router.d.ts", + "default": "./build/esm/react-router.js" + }, + "import": { + "types": "./build/types/react-router.d.ts", + "default": "./build/esm/react-router.js" + }, + "require": { + "types": "./build/types/react-router.d.ts", + "default": "./build/cjs/react-router.js" + } + } + }, + "typesVersions": { + "*": { + "react-router": [ + "build/types/react-router.d.ts" + ] } }, "publishConfig": { @@ -41,7 +62,13 @@ "@sentry/conventions": "^0.20.0" }, "peerDependencies": { - "react": "17.x || 18.x || 19.x" + "react": "17.x || 18.x || 19.x", + "react-router": "6.x || 7.x || 8.x" + }, + "peerDependenciesMeta": { + "react-router": { + "optional": true + } }, "devDependencies": { "@testing-library/react": "^15.0.5", @@ -56,6 +83,7 @@ "history-5": "npm:history@4.9.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-router": "^7.18.3", "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.3.4", @@ -71,7 +99,7 @@ "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", "build:tarball": "npm pack", - "circularDepCheck": "madge --circular src/index.ts", + "circularDepCheck": "madge --circular src/index.ts && madge --circular src/react-router.ts", "clean": "rimraf build coverage sentry-react-*.tgz", "lint:fix": "oxlint . --fix --type-aware", "lint": "oxlint . --type-aware", diff --git a/packages/react/rollup.npm.config.mjs b/packages/react/rollup.npm.config.mjs index 66c3b16aba58..2692df6c8fee 100644 --- a/packages/react/rollup.npm.config.mjs +++ b/packages/react/rollup.npm.config.mjs @@ -6,6 +6,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu // https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html export default makeNPMConfigVariants( makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/react-router.ts'], packageSpecificConfig: { external: ['react', 'react/jsx-runtime'], }, diff --git a/packages/react/src/react-router.ts b/packages/react/src/react-router.ts new file mode 100644 index 000000000000..5519f1850e60 --- /dev/null +++ b/packages/react/src/react-router.ts @@ -0,0 +1,48 @@ +import type { browserTracingIntegration } from '@sentry/browser'; +import type { Integration } from '@sentry/core'; +import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router'; +import type { ReactRouterOptions } from './reactrouter-compat-utils'; +import { reactRouterBrowserTracingIntegration as reactRouterBrowserTracingIntegrationBase } from './reactrouter.compat'; + +// The routing wrappers (`wrapReactRouterRouting`, `wrapUseRoutes`, `wrapCreateBrowserRouter`, +// `wrapCreateMemoryRouter`) do not need the hooks - they read the config the integration below stored on +// the client - so they are re-exported unchanged from the main entry point. +export { + wrapReactRouterRouting, + wrapCreateBrowserRouter, + wrapCreateMemoryRouter, + wrapUseRoutes, +} from './reactrouter.compat'; + +type BrowserTracingOptions = Parameters = (props: P) => {
+ // Null-rendering reporter that owns every config-dependent hook. It is mounted as a *sibling* of the
+ // routes (never wrapping them) and only once a client config exists, so the route subtree always keeps
+ // the same component type across the `Sentry.init()` transition and is never remounted - remounting
+ // would wipe form state and in-flight work in the host app. As a freshly mounted component, its own hook
+ // sequence stays self-consistent for its whole lifetime, so this is Rules-of-Hooks safe.
+ const RouteReporter: React.FC<{ config: ReactRouterConfig; routeChildren: React.ReactNode }> = ({
+ config,
+ routeChildren,
+ }) => {
const isMountRenderPass = React.useRef(true);
- const location = _useLocation();
- const navigationType = _useNavigationType();
+ const location = config.useLocation();
+ const navigationType = config.useNavigationType();
- const routes = _createRoutesFromChildren(props.children) as RouteObject[];
+ const routes = config.createRoutesFromChildren(
+ routeChildren as Parameters = (props: P) => {
+ const config = getRouterConfig(getClient());
+ return (
+ <>
+ {
+ // @ts-expect-error Setting more specific React Component typing for `R` generic above
+ // will break advanced type inference done by react router params
+