diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.gitignore new file mode 100644 index 000000000000..012e938ef384 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.gitignore @@ -0,0 +1,10 @@ +node_modules + +/.cache +/build +.env + +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.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-7-rsc/README.md b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/README.md new file mode 100644 index 000000000000..9163c5ff69c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/README.md @@ -0,0 +1,5 @@ +# React Router 7 RSC + +E2E test app for React Router 7 RSC (React Server Components) and `@sentry/react-router`. + +**Note:** Skipped in CI (`sentryTest.skip: true`) - React Router's RSC Framework Mode is experimental. diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/app.css new file mode 100644 index 000000000000..36331bc72654 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/app.css @@ -0,0 +1,47 @@ +* { + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + margin: 0; + padding: 20px; + line-height: 1.6; +} + +h1 { + margin-top: 0; +} + +nav { + margin-bottom: 20px; +} + +nav ul { + list-style: none; + padding: 0; + display: flex; + gap: 20px; +} + +nav a { + color: #0066cc; + text-decoration: none; +} + +nav a:hover { + text-decoration: underline; +} + +button { + padding: 8px 16px; + font-size: 14px; + cursor: pointer; +} + +.error { + color: #cc0000; + background: #ffeeee; + padding: 10px; + border-radius: 4px; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.server.tsx new file mode 100644 index 000000000000..738cd1515a4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.server.tsx @@ -0,0 +1,18 @@ +import { createReadableStreamFromReadable } from '@react-router/node'; +import * as Sentry from '@sentry/react-router'; +import { renderToPipeableStream } from 'react-dom/server'; +import { ServerRouter } from 'react-router'; +import { type HandleErrorFunction } from 'react-router'; + +const ABORT_DELAY = 5_000; + +const handleRequest = Sentry.createSentryHandleRequest({ + streamTimeout: ABORT_DELAY, + ServerRouter, + renderToPipeableStream, + createReadableStreamFromReadable, +}); + +export default handleRequest; + +export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/root.tsx new file mode 100644 index 000000000000..468cb79fc6f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/root.tsx @@ -0,0 +1,59 @@ +import * as Sentry from '@sentry/react-router'; +import { Links, Meta, Outlet, ScrollRestoration, isRouteErrorResponse } from 'react-router'; +import type { Route } from './+types/root'; +import stylesheet from './app.css?url'; +import { SentryClient } from './sentry-client'; + +export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + + {children} + + {/* is not needed in RSC mode - scripts are injected by the RSC framework */} + + + ); +} + +export default function App() { + return ; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!'; + let details = 'An unexpected error occurred.'; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error'; + details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; + } else if (error && error instanceof Error) { + Sentry.captureException(error); + if (import.meta.env.DEV) { + details = error.message; + stack = error.stack; + } + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes.ts new file mode 100644 index 000000000000..3230eb68a6dd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes.ts @@ -0,0 +1,24 @@ +import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes'; + +export default [ + index('routes/home.tsx'), + ...prefix('rsc', [ + // RSC Server Component tests + route('server-component', 'routes/rsc/server-component.tsx'), + route('server-component-error', 'routes/rsc/server-component-error.tsx'), + route('server-component-async', 'routes/rsc/server-component-async.tsx'), + route('server-component-redirect', 'routes/rsc/server-component-redirect.tsx'), + route('server-component-not-found', 'routes/rsc/server-component-not-found.tsx'), + route('server-component/:param', 'routes/rsc/server-component-param.tsx'), + route('server-component-comment-directive', 'routes/rsc/server-component-comment-directive.tsx'), + // RSC Server Function tests + route('server-function', 'routes/rsc/server-function.tsx'), + route('server-function-error', 'routes/rsc/server-function-error.tsx'), + route('server-function-arrow', 'routes/rsc/server-function-arrow.tsx'), + route('server-function-default', 'routes/rsc/server-function-default.tsx'), + ]), + ...prefix('performance', [ + index('routes/performance/index.tsx'), + route('with/:param', 'routes/performance/dynamic-param.tsx'), + ]), +] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/home.tsx new file mode 100644 index 000000000000..4b44ffca47d3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/home.tsx @@ -0,0 +1,34 @@ +import { Link } from 'react-router'; + +export default function Home() { + return ( +
+

React Router 7 RSC Test App

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/dynamic-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/dynamic-param.tsx new file mode 100644 index 000000000000..51948e4d322f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/dynamic-param.tsx @@ -0,0 +1,10 @@ +import type { Route } from './+types/dynamic-param'; + +export default function DynamicParamPage({ params }: Route.ComponentProps) { + return ( +
+

Dynamic Param Page

+

Param: {params.param}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/index.tsx new file mode 100644 index 000000000000..459806f56e17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/index.tsx @@ -0,0 +1,16 @@ +import { Link } from 'react-router'; + +export default function PerformancePage() { + return ( +
+

Performance Test

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions-default.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions-default.ts new file mode 100644 index 000000000000..5a02bfd5ddb6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions-default.ts @@ -0,0 +1,12 @@ +'use server'; + +// This file only has a default export — the Sentry plugin should wrap it as +// a default server function, NOT extract "defaultAction" as a named export. +export default async function defaultAction(formData: FormData): Promise<{ success: boolean; message: string }> { + const name = formData.get('name') as string; + await new Promise(resolve => setTimeout(resolve, 50)); + return { + success: true, + message: `Default: Hello, ${name}!`, + }; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions.ts new file mode 100644 index 000000000000..0f39749fc4a3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions.ts @@ -0,0 +1,27 @@ +'use server'; + +export async function submitForm(formData: FormData): Promise<{ success: boolean; message: string }> { + const name = formData.get('name') as string; + + // Simulate some async work + await new Promise(resolve => setTimeout(resolve, 50)); + + return { + success: true, + message: `Hello, ${name}! Form submitted successfully.`, + }; +} + +export async function submitFormWithError(_formData: FormData): Promise<{ success: boolean; message: string }> { + // Simulate an error in server function + throw new Error('RSC Server Function Error: Something went wrong!'); +} + +export const submitFormArrow = async (formData: FormData): Promise<{ success: boolean; message: string }> => { + const name = formData.get('name') as string; + await new Promise(resolve => setTimeout(resolve, 50)); + return { + success: true, + message: `Arrow: Hello, ${name}!`, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-async.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-async.tsx new file mode 100644 index 000000000000..a19bcb13e0d3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-async.tsx @@ -0,0 +1,31 @@ +import { wrapServerComponent } from '@sentry/react-router'; +import type { Route } from './+types/server-component-async'; + +async function fetchData(): Promise<{ title: string; content: string }> { + await new Promise(resolve => setTimeout(resolve, 50)); + return { + title: 'Async Server Component', + content: 'This content was fetched asynchronously on the server.', + }; +} + +async function AsyncServerComponent(_props: Route.ComponentProps) { + const data = await fetchData(); + + return ( +
+

{data.title}

+

{data.content}

+
+ ); +} + +export default wrapServerComponent(AsyncServerComponent, { + componentRoute: '/rsc/server-component-async', + componentType: 'Page', +}); + +export async function loader() { + const data = await fetchData(); + return data; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-comment-directive.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-comment-directive.tsx new file mode 100644 index 000000000000..90cd2cf78851 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-comment-directive.tsx @@ -0,0 +1,25 @@ +// This is a server component, NOT a client component. +// "use client" — this comment should be ignored by the Sentry plugin. + +import { wrapServerComponent } from '@sentry/react-router'; +import type { Route } from './+types/server-component-comment-directive'; + +async function ServerComponentWithCommentDirective({ loaderData }: Route.ComponentProps) { + await new Promise(resolve => setTimeout(resolve, 10)); + + return ( +
+

Server Component With Comment Directive

+

Message: {loaderData?.message ?? 'No loader data'}

+
+ ); +} + +export default wrapServerComponent(ServerComponentWithCommentDirective, { + componentRoute: '/rsc/server-component-comment-directive', + componentType: 'Page', +}); + +export async function loader() { + return { message: 'Hello from comment-directive server component!' }; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-error.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-error.tsx new file mode 100644 index 000000000000..094b551fcfb0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-error.tsx @@ -0,0 +1,10 @@ +import { wrapServerComponent } from '@sentry/react-router'; + +async function ServerComponentWithError() { + throw new Error('RSC Server Component Error: Mamma mia!'); +} + +export default wrapServerComponent(ServerComponentWithError, { + componentRoute: '/rsc/server-component-error', + componentType: 'Page', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-not-found.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-not-found.tsx new file mode 100644 index 000000000000..98972fcaaa4b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-not-found.tsx @@ -0,0 +1,10 @@ +import { wrapServerComponent } from '@sentry/react-router'; + +async function NotFoundServerComponent() { + throw new Response('Not Found', { status: 404 }); +} + +export default wrapServerComponent(NotFoundServerComponent, { + componentRoute: '/rsc/server-component-not-found', + componentType: 'Page', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-param.tsx new file mode 100644 index 000000000000..c17927404c3d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-param.tsx @@ -0,0 +1,18 @@ +import { wrapServerComponent } from '@sentry/react-router'; +import type { Route } from './+types/server-component-param'; + +async function ParamServerComponent({ params }: Route.ComponentProps) { + await new Promise(resolve => setTimeout(resolve, 10)); + + return ( +
+

Server Component with Parameter

+

Parameter: {params.param}

+
+ ); +} + +export default wrapServerComponent(ParamServerComponent, { + componentRoute: '/rsc/server-component/:param', + componentType: 'Page', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-redirect.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-redirect.tsx new file mode 100644 index 000000000000..21389c6fece3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-redirect.tsx @@ -0,0 +1,11 @@ +import { redirect } from 'react-router'; +import { wrapServerComponent } from '@sentry/react-router'; + +async function RedirectServerComponent() { + throw redirect('/'); +} + +export default wrapServerComponent(RedirectServerComponent, { + componentRoute: '/rsc/server-component-redirect', + componentType: 'Page', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component.tsx new file mode 100644 index 000000000000..215decf3639e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component.tsx @@ -0,0 +1,23 @@ +import { wrapServerComponent } from '@sentry/react-router'; +import type { Route } from './+types/server-component'; + +async function ServerComponent({ loaderData }: Route.ComponentProps) { + await new Promise(resolve => setTimeout(resolve, 10)); + + return ( +
+

Server Component

+

This demonstrates a manually wrapped server component.

+

Message: {loaderData?.message ?? 'No loader data'}

+
+ ); +} + +export default wrapServerComponent(ServerComponent, { + componentRoute: '/rsc/server-component', + componentType: 'Page', +}); + +export async function loader() { + return { message: 'Hello from server loader!' }; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-arrow.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-arrow.tsx new file mode 100644 index 000000000000..f7ff1edf14c6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-arrow.tsx @@ -0,0 +1,33 @@ +import { Form, useActionData } from 'react-router'; +import { submitFormArrow } from './actions'; +import type { Route } from './+types/server-function-arrow'; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData(); + return submitFormArrow(formData); +} + +export default function ServerFunctionArrowPage() { + const actionData = useActionData(); + + return ( +
+

Server Function Arrow Test

+

This tests export const arrow function wrapping.

+ +
+ + + +
+ + {actionData && ( +
+

Message: {actionData.message}

+
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-default.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-default.tsx new file mode 100644 index 000000000000..8dbfd2990935 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-default.tsx @@ -0,0 +1,33 @@ +import { Form, useActionData } from 'react-router'; +import defaultAction from './actions-default'; +import type { Route } from './+types/server-function-default'; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData(); + return defaultAction(formData); +} + +export default function ServerFunctionDefaultPage() { + const actionData = useActionData(); + + return ( +
+

Server Function Default Export Test

+

This tests "use server" files with only a default export.

+ +
+ + + +
+ + {actionData && ( +
+

Message: {actionData.message}

+
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-error.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-error.tsx new file mode 100644 index 000000000000..3d72bab7ccf0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-error.tsx @@ -0,0 +1,32 @@ +import { Form, useActionData } from 'react-router'; +import { submitFormWithError } from './actions'; +import type { Route } from './+types/server-function-error'; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData(); + return submitFormWithError(formData); +} + +export default function ServerFunctionErrorPage() { + const actionData = useActionData(); + + return ( +
+

Server Function Error Test

+

This page tests error capture in wrapServerFunction.

+ +
+ + +
+ + {actionData && ( +
+

This should not appear - error should be thrown

+
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function.tsx new file mode 100644 index 000000000000..af147366f4c1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function.tsx @@ -0,0 +1,34 @@ +import { Form, useActionData } from 'react-router'; +import { submitForm } from './actions'; +import type { Route } from './+types/server-function'; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData(); + return submitForm(formData); +} + +export default function ServerFunctionPage() { + const actionData = useActionData(); + + return ( +
+

Server Function Test

+

This page tests wrapServerFunction instrumentation.

+ +
+ + + +
+ + {actionData && ( +
+

Success: {String(actionData.success)}

+

Message: {actionData.message}

+
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/sentry-client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/sentry-client.tsx new file mode 100644 index 000000000000..2349c00ce937 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/sentry-client.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useEffect } from 'react'; + +// RSC mode doesn't use entry.client.tsx, so we initialize Sentry via a client component. +export function SentryClient() { + useEffect(() => { + import('@sentry/react-router') + .then(Sentry => { + if (!Sentry.isInitialized()) { + Sentry.init({ + environment: 'qa', + dsn: 'https://username@domain/123', + tunnel: `http://localhost:3031/`, + integrations: [Sentry.reactRouterTracingIntegration()], + tracesSampleRate: 1.0, + tracePropagationTargets: [/^\//], + }); + } + }) + .catch(e => { + // Silent fail in production, but log in dev for debugging + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.warn('[Sentry] Failed to initialize:', e); + } + }); + }, []); + + return null; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/instrument.mjs new file mode 100644 index 000000000000..d9d1ea7f386e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/react-router'; + +Sentry.init({ + dsn: 'https://username@domain/123', + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, + tunnel: `http://localhost:3031/`, // proxy server + debug: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/package.json new file mode 100644 index 000000000000..5a8f65710f15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/package.json @@ -0,0 +1,64 @@ +{ + "name": "react-router-7-rsc", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "react": "19.1.0", + "react-dom": "19.1.0", + "react-router": "7.12.0", + "@react-router/node": "7.12.0", + "@react-router/serve": "7.12.0", + "@sentry/react-router": "latest || *", + "isbot": "^5.1.17" + }, + "devDependencies": { + "@types/react": "19.1.0", + "@types/react-dom": "19.1.0", + "@types/node": "^22", + "@react-router/dev": "7.12.0", + "@vitejs/plugin-react": "4.5.1", + "@vitejs/plugin-rsc": "0.5.14", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "5.6.3", + "vite": "6.3.5" + }, + "scripts": { + "build": "react-router build", + "test:build-latest": "pnpm install && pnpm add react-router@latest && pnpm add @react-router/node@latest && pnpm add @react-router/serve@latest && pnpm add @react-router/dev@latest && pnpm build", + "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev", + "start": "NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js", + "proxy": "node start-event-proxy.mjs", + "typecheck": "react-router typegen && tsc", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:ts && pnpm test:playwright", + "test:ts": "pnpm typecheck", + "test:playwright": "playwright test" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build-latest", + "label": "react-router-7-rsc (latest)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/playwright.config.mjs new file mode 100644 index 000000000000..3ed5721107a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `PORT=3030 pnpm start`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/public/.gitkeep b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/public/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/react-router.config.ts new file mode 100644 index 000000000000..51e8967770b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/react-router.config.ts @@ -0,0 +1,5 @@ +import type { Config } from '@react-router/dev/config'; + +export default { + ssr: true, +} satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/start-event-proxy.mjs new file mode 100644 index 000000000000..c39b3e59484b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-rsc', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/constants.ts new file mode 100644 index 000000000000..e0ecda948342 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/constants.ts @@ -0,0 +1 @@ +export const APP_NAME = 'react-router-7-rsc'; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/performance/performance.server.test.ts new file mode 100644 index 000000000000..3de973d1a5ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/performance/performance.server.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('RSC - Performance', () => { + test('should send server transaction on pageload', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const matchesRoute = + transactionEvent.transaction?.includes('/performance') || + transactionEvent.request?.url?.includes('/performance'); + return Boolean(isServerTransaction && matchesRoute && !transactionEvent.request?.url?.includes('/with/')); + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: expect.stringMatching(/GET \/performance|GET \*/), + platform: 'node', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': expect.stringMatching(/auto\.http\.(otel\.http|react_router\.request_handler)/), + 'sentry.source': expect.stringMatching(/route|url/), + }, + op: 'http.server', + origin: expect.stringMatching(/auto\.http\.(otel\.http|react_router\.request_handler)/), + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction_info: { source: expect.stringMatching(/route|url/) }, + request: { + url: expect.stringContaining('/performance'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: expect.arrayContaining([ + expect.objectContaining({ name: 'npm:@sentry/react-router', version: expect.any(String) }), + expect.objectContaining({ name: 'npm:@sentry/node', version: expect.any(String) }), + ]), + }, + tags: { + runtime: 'node', + }, + }); + }); + + test('should send server transaction on parameterized route', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const matchesRoute = + transactionEvent.transaction?.includes('/performance/with') || + transactionEvent.request?.url?.includes('/performance/with/some-param'); + return Boolean(isServerTransaction && matchesRoute); + }); + + await page.goto(`/performance/with/some-param`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: expect.stringMatching(/GET \/performance\/with|GET \*/), + platform: 'node', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': expect.stringMatching(/auto\.http\.(otel\.http|react_router\.request_handler)/), + 'sentry.source': expect.stringMatching(/route|url/), + }, + op: 'http.server', + origin: expect.stringMatching(/auto\.http\.(otel\.http|react_router\.request_handler)/), + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction_info: { source: expect.stringMatching(/route|url/) }, + request: { + url: expect.stringContaining('/performance/with/some-param'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: expect.arrayContaining([ + expect.objectContaining({ name: 'npm:@sentry/react-router', version: expect.any(String) }), + expect.objectContaining({ name: 'npm:@sentry/node', version: expect.any(String) }), + ]), + }, + tags: { + runtime: 'node', + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-component.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-component.test.ts new file mode 100644 index 000000000000..7fd3e3cbd4c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-component.test.ts @@ -0,0 +1,224 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('RSC - Server Component Wrapper', () => { + test('captures error from server component', async ({ page }) => { + const errorMessage = 'RSC Server Component Error: Mamma mia!'; + const errorPromise = waitForError(APP_NAME, errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/rsc/server-component-error`); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'react_router.rsc', + data: { + function: 'ServerComponent', + component_route: '/rsc/server-component-error', + component_type: 'Page', + }, + }, + }, + ], + }, + level: 'error', + platform: 'node', + environment: 'qa', + sdk: { + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'node' }, + }); + }); + + test('server component page loads with loader data', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const matchesRoute = + transactionEvent.transaction?.includes('/rsc/server-component') || + transactionEvent.request?.url?.includes('/rsc/server-component'); + return Boolean(isServerTransaction && matchesRoute && !transactionEvent.transaction?.includes('-async')); + }); + + await page.goto(`/rsc/server-component`); + + await expect(page.getByTestId('loader-message')).toContainText('Hello from server loader!'); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: expect.stringMatching(/\/rsc\/server-component|GET \*/), + platform: 'node', + environment: 'qa', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + sdk: { + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: expect.arrayContaining([ + expect.objectContaining({ name: 'npm:@sentry/react-router', version: expect.any(String) }), + expect.objectContaining({ name: 'npm:@sentry/node', version: expect.any(String) }), + ]), + }, + }); + }); + + test('async server component page loads', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const matchesRoute = + transactionEvent.transaction?.includes('/rsc/server-component-async') || + transactionEvent.request?.url?.includes('/rsc/server-component-async'); + return Boolean(isServerTransaction && matchesRoute); + }); + + await page.goto(`/rsc/server-component-async`); + + await expect(page.getByTestId('title')).toHaveText('Async Server Component'); + await expect(page.getByTestId('content')).toHaveText('This content was fetched asynchronously on the server.'); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: expect.stringMatching(/\/rsc\/server-component-async|GET \*/), + platform: 'node', + environment: 'qa', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + sdk: { + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: expect.arrayContaining([ + expect.objectContaining({ name: 'npm:@sentry/react-router', version: expect.any(String) }), + expect.objectContaining({ name: 'npm:@sentry/node', version: expect.any(String) }), + ]), + }, + }); + }); + + test('does not capture redirect as an error', async ({ page }) => { + const errorPromise = waitForError(APP_NAME, errorEvent => { + return !!errorEvent?.request?.url?.includes('/rsc/server-component-redirect'); + }); + + await page.goto('/rsc/server-component-redirect'); + + // The redirect should have taken us to the home page + await expect(page).toHaveURL('/'); + + // No error should be captured for a redirect + const maybeError = await Promise.race([ + errorPromise, + new Promise<'no-error'>(resolve => setTimeout(() => resolve('no-error'), 3000)), + ]); + + expect(maybeError).toBe('no-error'); + }); + + test('does not capture 404 response as an error', async ({ page }) => { + const errorPromise = waitForError(APP_NAME, errorEvent => { + return !!errorEvent?.request?.url?.includes('/rsc/server-component-not-found'); + }); + + await page.goto('/rsc/server-component-not-found'); + + // No error should be captured for a 404 response + const maybeError = await Promise.race([ + errorPromise, + new Promise<'no-error'>(resolve => setTimeout(() => resolve('no-error'), 3000)), + ]); + + expect(maybeError).toBe('no-error'); + }); + + test('manually wrapped server component with "use client" in comment loads correctly', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const matchesRoute = + transactionEvent.transaction?.includes('/rsc/server-component-comment-directive') || + transactionEvent.request?.url?.includes('/rsc/server-component-comment-directive'); + return Boolean(isServerTransaction && matchesRoute); + }); + + await page.goto('/rsc/server-component-comment-directive'); + + await expect(page.getByTestId('title')).toHaveText('Server Component With Comment Directive'); + await expect(page.getByTestId('loader-message')).toContainText('Hello from comment-directive server component!'); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: expect.stringMatching(/\/rsc\/server-component-comment-directive|GET \*/), + platform: 'node', + environment: 'qa', + }); + }); + + test('parameterized server component route works', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const matchesRoute = + transactionEvent.transaction?.includes('/rsc/server-component') || + transactionEvent.request?.url?.includes('/rsc/server-component/my-test-param'); + return Boolean(isServerTransaction && matchesRoute && !transactionEvent.transaction?.includes('-async')); + }); + + await page.goto(`/rsc/server-component/my-test-param`); + + await expect(page.getByTestId('param')).toContainText('my-test-param'); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: expect.stringMatching(/\/rsc\/server-component|GET \*/), + platform: 'node', + environment: 'qa', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + sdk: { + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: expect.arrayContaining([ + expect.objectContaining({ name: 'npm:@sentry/react-router', version: expect.any(String) }), + expect.objectContaining({ name: 'npm:@sentry/node', version: expect.any(String) }), + ]), + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-function.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-function.test.ts new file mode 100644 index 000000000000..f72c33c881ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-function.test.ts @@ -0,0 +1,209 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('RSC - Server Function Wrapper', () => { + test('creates transaction for wrapped server function via action', async ({ page }) => { + await page.goto(`/rsc/server-function`); + + // Listen after page load to skip the initial GET transaction. + // Match either a child span or a forceTransaction with the server function attribute. + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + // Match a transaction that either: + // (a) has a child span with the server function attribute, or + // (b) is the server function transaction itself (forceTransaction case) + const hasServerFunctionSpan = transactionEvent.spans?.some( + span => span.data?.['rsc.server_function.name'] === 'submitForm', + ); + const isServerFunctionTransaction = + transactionEvent.contexts?.trace?.data?.['rsc.server_function.name'] === 'submitForm'; + return Boolean(isServerTransaction && (hasServerFunctionSpan || isServerFunctionTransaction)); + }); + + await page.locator('#submit').click(); + + // Verify the form submission was successful + await expect(page.getByTestId('message')).toContainText('Hello, Sentry User!'); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + platform: 'node', + environment: 'qa', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + sdk: { + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: expect.arrayContaining([ + expect.objectContaining({ name: 'npm:@sentry/react-router', version: expect.any(String) }), + expect.objectContaining({ name: 'npm:@sentry/node', version: expect.any(String) }), + ]), + }, + }); + + // The server function span may be a child span or the transaction root itself. + const serverFunctionSpan = transaction.spans?.find( + span => span.data?.['rsc.server_function.name'] === 'submitForm', + ); + const traceData = transaction.contexts?.trace?.data; + + if (serverFunctionSpan) { + // Child span case: server function ran inside an active HTTP transaction + expect(serverFunctionSpan).toMatchObject({ + data: expect.objectContaining({ + 'sentry.op': 'function.rsc.server_function', + 'sentry.origin': 'auto.function.react_router.rsc.server_function', + 'rsc.server_function.name': 'submitForm', + }), + }); + } else { + // forceTransaction case: server function is the transaction + expect(traceData).toMatchObject( + expect.objectContaining({ + 'sentry.op': 'function.rsc.server_function', + 'rsc.server_function.name': 'submitForm', + }), + ); + } + }); + + test('captures error from wrapped server function', async ({ page }) => { + const errorMessage = 'RSC Server Function Error: Something went wrong!'; + const errorPromise = waitForError(APP_NAME, errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/rsc/server-function-error`); + await page.locator('#submit').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'react_router.rsc', + data: { + function: 'serverFunction', + server_function_name: 'submitFormWithError', + }, + }, + }, + ], + }, + level: 'error', + platform: 'node', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'node' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); + + test('creates transaction for server function using export const arrow pattern', async ({ page }) => { + // Load the page first to avoid catching the GET page load transaction. + await page.goto('/rsc/server-function-arrow'); + + // Set up listener after page load — filter for the server function span specifically. + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const hasServerFunctionSpan = transactionEvent.spans?.some( + span => span.data?.['rsc.server_function.name'] === 'submitFormArrow', + ); + const isServerFunctionTransaction = + transactionEvent.contexts?.trace?.data?.['rsc.server_function.name'] === 'submitFormArrow'; + return Boolean(isServerTransaction && (hasServerFunctionSpan || isServerFunctionTransaction)); + }); + + await page.locator('#submit').click(); + + await expect(page.getByTestId('message')).toContainText('Arrow: Hello, Arrow User!'); + + const transaction = await txPromise; + + const serverFunctionSpan = transaction.spans?.find( + span => span.data?.['rsc.server_function.name'] === 'submitFormArrow', + ); + + if (serverFunctionSpan) { + expect(serverFunctionSpan).toMatchObject({ + data: expect.objectContaining({ + 'sentry.op': 'function.rsc.server_function', + 'rsc.server_function.name': 'submitFormArrow', + }), + }); + } else { + // forceTransaction case: server function is the transaction + expect(transaction.contexts?.trace?.data).toMatchObject( + expect.objectContaining({ + 'sentry.op': 'function.rsc.server_function', + 'rsc.server_function.name': 'submitFormArrow', + }), + ); + } + }); + + test('creates transaction for server function with default export only', async ({ page }) => { + // Load the page first to avoid catching the GET page load transaction. + await page.goto('/rsc/server-function-default'); + + // Set up listener after page load — filter for the server function span specifically. + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const hasServerFunctionSpan = transactionEvent.spans?.some( + span => span.data?.['rsc.server_function.name'] === 'default', + ); + const isServerFunctionTransaction = + transactionEvent.contexts?.trace?.data?.['rsc.server_function.name'] === 'default'; + return Boolean(isServerTransaction && (hasServerFunctionSpan || isServerFunctionTransaction)); + }); + + await page.locator('#submit').click(); + + await expect(page.getByTestId('message')).toContainText('Default: Hello, Default User!'); + + const transaction = await txPromise; + + // The default export should be wrapped as "default", not as "defaultAction" + const serverFunctionSpan = transaction.spans?.find(span => span.data?.['rsc.server_function.name'] === 'default'); + + if (serverFunctionSpan) { + expect(serverFunctionSpan).toMatchObject({ + data: expect.objectContaining({ + 'sentry.op': 'function.rsc.server_function', + 'rsc.server_function.name': 'default', + }), + }); + } else { + // forceTransaction case: server function is the transaction + expect(transaction.contexts?.trace?.data).toMatchObject( + expect.objectContaining({ + 'sentry.op': 'function.rsc.server_function', + 'rsc.server_function.name': 'default', + }), + ); + } + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tsconfig.json new file mode 100644 index 000000000000..6b11840e7262 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["@react-router/node", "vite/client"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "noEmit": true, + "rootDirs": [".", ".react-router/types"] + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/vite.config.ts new file mode 100644 index 000000000000..6ef5f7b97378 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/vite.config.ts @@ -0,0 +1,21 @@ +import { sentryReactRouter } from '@sentry/react-router'; +import { unstable_reactRouterRSC } from '@react-router/dev/vite'; +import rsc from '@vitejs/plugin-rsc/plugin'; +import { defineConfig } from 'vite'; + +export default defineConfig(async env => ({ + plugins: [...(await sentryReactRouter({}, env)), unstable_reactRouterRSC(), rsc()], + // Exclude chokidar from RSC bundling - it's a CommonJS file watcher + // that causes parse errors when the RSC plugin tries to process it + optimizeDeps: { + exclude: ['chokidar'], + }, + ssr: { + external: ['chokidar'], + }, + build: { + rollupOptions: { + external: ['chokidar'], + }, + }, +})); diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 8a6e449e0e37..f8157c14b4e0 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -45,6 +45,7 @@ "access": "public" }, "dependencies": { + "@babel/parser": "7.26.9", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.5.0", "@opentelemetry/instrumentation": "^0.211.0", @@ -55,7 +56,8 @@ "@sentry/node": "10.38.0", "@sentry/react": "10.38.0", "@sentry/vite-plugin": "^4.8.0", - "glob": "^13.0.1" + "glob": "^13.0.1", + "recast": "0.23.11" }, "devDependencies": { "@react-router/dev": "^7.13.0", diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index 6734b21c8583..226be4454eee 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -12,6 +12,29 @@ export { export { captureReactException, reactErrorHandler, Profiler, withProfiler, useProfiler } from '@sentry/react'; +/** + * Just a passthrough in case this is imported from the client. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function wrapServerComponent any>( + serverComponent: T, + _context: { componentRoute: string; componentType: string }, +): T { + return serverComponent; +} + +/** + * Just a passthrough in case this is imported from the client. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function wrapServerFunction Promise>( + _functionName: string, + serverFunction: T, + _options?: { name?: string; attributes?: Record }, +): T { + return serverFunction; +} + /** * @deprecated ErrorBoundary is deprecated, use React Router's error boundary instead. * See https://docs.sentry.io/platforms/javascript/guides/react-router/#report-errors-from-error-boundaries diff --git a/packages/react-router/src/index.types.ts b/packages/react-router/src/index.types.ts index c9c5cb371763..ee09fc108b10 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -28,3 +28,6 @@ export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegra export declare const OpenFeatureIntegrationHook: typeof clientSdk.OpenFeatureIntegrationHook; export declare const statsigIntegration: typeof clientSdk.statsigIntegration; export declare const unleashIntegration: typeof clientSdk.unleashIntegration; + +export declare const wrapServerComponent: typeof serverSdk.wrapServerComponent; +export declare const wrapServerFunction: typeof serverSdk.wrapServerFunction; diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index e0b8c8981632..f09d8a25eccd 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -18,3 +18,8 @@ export { isInstrumentationApiUsed, type CreateSentryServerInstrumentationOptions, } from './createServerInstrumentation'; + +// React Server Components (RSC) - React Router v7.9.0+ +export { wrapServerFunction, wrapServerComponent } from './rsc'; + +export type { ServerComponentContext, WrapServerFunctionOptions } from './rsc'; diff --git a/packages/react-router/src/server/rsc/index.ts b/packages/react-router/src/server/rsc/index.ts new file mode 100644 index 000000000000..12d38de4ccbf --- /dev/null +++ b/packages/react-router/src/server/rsc/index.ts @@ -0,0 +1,4 @@ +export { wrapServerFunction } from './wrapServerFunction'; +export { wrapServerComponent } from './wrapServerComponent'; + +export type { ServerComponentContext, WrapServerFunctionOptions } from './types'; diff --git a/packages/react-router/src/server/rsc/responseUtils.ts b/packages/react-router/src/server/rsc/responseUtils.ts new file mode 100644 index 000000000000..d74abee0ba6b --- /dev/null +++ b/packages/react-router/src/server/rsc/responseUtils.ts @@ -0,0 +1,64 @@ +/** + * Read-only check for the `__sentry_captured__` flag set by `captureException`. + * Only reads the flag — does not mark the error — to avoid conflicting with + * the internal dedup in `captureException`. + */ +export function isAlreadyCaptured(exception: unknown): boolean { + if (exception == null || typeof exception !== 'object') { + return false; + } + try { + return !!(exception as Record).__sentry_captured__; + } catch { + return false; + } +} + +/** + * Check if an error/response is a redirect. + * Handles both Response objects and internal React Router throwables. + */ +export function isRedirectResponse(error: unknown): boolean { + if (error instanceof Response) { + const status = error.status; + return status >= 300 && status < 400; + } + + if (error && typeof error === 'object') { + const errorObj = error as { status?: number; type?: unknown }; + + if (typeof errorObj.type === 'string' && errorObj.type === 'redirect') { + return true; + } + + if (typeof errorObj.status === 'number' && errorObj.status >= 300 && errorObj.status < 400) { + return true; + } + } + + return false; +} + +/** + * Check if an error/response is a not-found response (404). + * Handles both Response objects and internal React Router throwables. + */ +export function isNotFoundResponse(error: unknown): boolean { + if (error instanceof Response) { + return error.status === 404; + } + + if (error && typeof error === 'object') { + const errorObj = error as { status?: number; type?: unknown }; + + if (typeof errorObj.type === 'string' && (errorObj.type === 'not-found' || errorObj.type === 'notFound')) { + return true; + } + + if (errorObj.status === 404) { + return true; + } + } + + return false; +} diff --git a/packages/react-router/src/server/rsc/types.ts b/packages/react-router/src/server/rsc/types.ts new file mode 100644 index 000000000000..39141cf9c87d --- /dev/null +++ b/packages/react-router/src/server/rsc/types.ts @@ -0,0 +1,11 @@ +export interface ServerComponentContext { + /** The parameterized route path (e.g., "/users/:id") */ + componentRoute: string; + componentType: 'Page' | 'Layout' | 'Loading' | 'Error' | 'Template' | 'Not-found' | 'Unknown'; +} + +export interface WrapServerFunctionOptions { + /** Custom span name. Defaults to `serverFunction/{functionName}` */ + name?: string; + attributes?: Record; +} diff --git a/packages/react-router/src/server/rsc/wrapServerComponent.ts b/packages/react-router/src/server/rsc/wrapServerComponent.ts new file mode 100644 index 000000000000..06bd1d07eb11 --- /dev/null +++ b/packages/react-router/src/server/rsc/wrapServerComponent.ts @@ -0,0 +1,117 @@ +import { + captureException, + debug, + flushIfServerless, + getActiveSpan, + getIsolationScope, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../common/debug-build'; +import { isAlreadyCaptured, isNotFoundResponse, isRedirectResponse } from './responseUtils'; +import type { ServerComponentContext } from './types'; + +/** + * Wraps a server component with Sentry error instrumentation. + * + * @experimental This API is experimental and may change in minor releases. + * React Router RSC support requires React Router v7.9.0+ with `unstable_reactRouterRSC()`. + * + * @example + * ```ts + * import { wrapServerComponent } from "@sentry/react-router"; + * + * async function UserPage({ params }: Route.ComponentProps) { + * const user = await getUser(params.id); + * return ; + * } + * + * export default wrapServerComponent(UserPage, { + * componentRoute: "/users/:id", + * componentType: "Page", + * }); + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function wrapServerComponent any>( + serverComponent: T, + context: ServerComponentContext, +): T { + const { componentRoute, componentType } = context; + + DEBUG_BUILD && debug.log(`[RSC] Wrapping server component: ${componentType} (${componentRoute})`); + + return new Proxy(serverComponent, { + apply: (originalFunction, thisArg, args) => { + const isolationScope = getIsolationScope(); + + const transactionName = `${componentType} Server Component (${componentRoute})`; + isolationScope.setTransactionName(transactionName); + + let result: ReturnType; + try { + result = originalFunction.apply(thisArg, args); + } catch (error) { + handleError(error, componentRoute, componentType); + flushIfServerless().catch(() => undefined); + throw error; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + if (result && typeof (result as any).then === 'function') { + // Attach handlers as side-effects. These create new promises but we intentionally + // return the original so React sees the unmodified rejection. + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + (result as any).then( + () => { + flushIfServerless().catch(() => undefined); + }, + (error: unknown) => { + handleError(error, componentRoute, componentType); + flushIfServerless().catch(() => undefined); + }, + ); + } else { + flushIfServerless().catch(() => undefined); + } + + return result; + }, + }); +} + +function handleError(error: unknown, componentRoute: string, componentType: string): void { + const span = getActiveSpan(); + + if (isRedirectResponse(error)) { + if (span) { + span.setStatus({ code: SPAN_STATUS_OK }); + } + return; + } + + if (isNotFoundResponse(error)) { + if (span) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + } + return; + } + + if (span) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + } + + if (!isAlreadyCaptured(error)) { + captureException(error, { + mechanism: { + type: 'react_router.rsc', + handled: false, + data: { + function: 'ServerComponent', + component_route: componentRoute, + component_type: componentType, + }, + }, + }); + } +} diff --git a/packages/react-router/src/server/rsc/wrapServerFunction.ts b/packages/react-router/src/server/rsc/wrapServerFunction.ts new file mode 100644 index 000000000000..0a0aab7ccdeb --- /dev/null +++ b/packages/react-router/src/server/rsc/wrapServerFunction.ts @@ -0,0 +1,105 @@ +import { + captureException, + debug, + flushIfServerless, + getActiveSpan, + getIsolationScope, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startSpan, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../common/debug-build'; +import { isAlreadyCaptured, isNotFoundResponse, isRedirectResponse } from './responseUtils'; +import type { WrapServerFunctionOptions } from './types'; + +/** + * Wraps a server function (marked with `"use server"` directive) with Sentry error and performance instrumentation. + * + * @experimental This API is experimental and may change in minor releases. + * React Router RSC support requires React Router v7.9.0+ with `unstable_reactRouterRSC()`. + * + * @example + * ```ts + * // actions.ts + * "use server"; + * import { wrapServerFunction } from "@sentry/react-router"; + * + * async function _updateUser(formData: FormData) { + * const userId = formData.get("id"); + * await db.users.update(userId, { name: formData.get("name") }); + * return { success: true }; + * } + * + * export const updateUser = wrapServerFunction("updateUser", _updateUser); + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function wrapServerFunction Promise>( + functionName: string, + serverFunction: T, + options: WrapServerFunctionOptions = {}, +): T { + DEBUG_BUILD && debug.log(`[RSC] Wrapping server function: ${functionName}`); + + return new Proxy(serverFunction, { + apply: (originalFunction, thisArg, args) => { + const spanName = options.name || `serverFunction/${functionName}`; + + const isolationScope = getIsolationScope(); + isolationScope.setTransactionName(spanName); + + const hasActiveSpan = !!getActiveSpan(); + + return startSpan( + { + name: spanName, + forceTransaction: !hasActiveSpan, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.server_function', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.rsc.server_function', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'rsc.server_function.name': functionName, + ...options.attributes, + }, + }, + async span => { + try { + const result = await originalFunction.apply(thisArg, args); + return result; + } catch (error) { + if (isRedirectResponse(error)) { + span.setStatus({ code: SPAN_STATUS_OK }); + throw error; + } + + if (isNotFoundResponse(error)) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + throw error; + } + + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + + if (!isAlreadyCaptured(error)) { + captureException(error, { + mechanism: { + type: 'react_router.rsc', + handled: false, + data: { + function: 'serverFunction', + server_function_name: functionName, + }, + }, + }); + } + throw error; + } finally { + await flushIfServerless(); + } + }, + ); + }, + }); +} diff --git a/packages/react-router/src/vite/index.ts b/packages/react-router/src/vite/index.ts index 5f5b6266015a..b34713e29dd1 100644 --- a/packages/react-router/src/vite/index.ts +++ b/packages/react-router/src/vite/index.ts @@ -1,4 +1,4 @@ export { sentryReactRouter } from './plugin'; export { sentryOnBuildEnd } from './buildEnd/handleOnBuildEnd'; -export type { SentryReactRouterBuildOptions } from './types'; +export type { AutoInstrumentRSCOptions, SentryReactRouterBuildOptions } from './types'; export { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin'; diff --git a/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts new file mode 100644 index 000000000000..76e0669a87e8 --- /dev/null +++ b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts @@ -0,0 +1,307 @@ +import { readFile } from 'node:fs/promises'; +import * as recast from 'recast'; +import type { Plugin } from 'vite'; +import { parser } from './recastTypescriptParser'; +import type { AutoInstrumentRSCOptions } from './types'; + +import t = recast.types.namedTypes; + +const JS_EXTENSIONS_RE = /\.(ts|tsx|js|jsx|mjs|mts)$/; +const WRAPPED_MODULE_SUFFIX = '?sentry-rsc-wrap'; + +// Prevents the Sentry bundler plugin from transforming this import path +const SENTRY_PACKAGE = '@sentry/react-router'; + +/** Exported for testing. */ +export interface ModuleAnalysis { + hasUseClientDirective: boolean; + hasUseServerDirective: boolean; + hasDefaultExport: boolean; + hasManualServerFunctionWrapping: boolean; + namedExports: string[]; +} + +// Babel-specific extensions not present in recast's type definitions +interface BabelExpressionStatement extends t.ExpressionStatement { + directive?: string; +} +interface BabelExportNamedDeclaration extends t.ExportNamedDeclaration { + exportKind?: string; +} +interface BabelExportSpecifier extends t.ExportSpecifier { + exportKind?: string; +} + +/** Extracts directive values ("use client"/"use server") from the program. */ +function extractDirectives(program: t.Program): { useClient: boolean; useServer: boolean } { + let useClient = false; + let useServer = false; + + // Babel puts directives in program.directives (e.g. "use strict", "use server") + if (program.directives) { + for (const d of program.directives) { + const value = d.value?.value; + if (value === 'use client') { + useClient = true; + } + if (value === 'use server') { + useServer = true; + } + } + } + + // Some Babel versions may place directive-like expression statements in the body + for (const node of program.body) { + if (node.type !== 'ExpressionStatement') { + break; + } + const expr = node as BabelExpressionStatement; + let value = expr.directive; + if (!value && expr.expression.type === 'StringLiteral') { + value = expr.expression.value; + } + if (typeof value !== 'string') { + break; + } + if (value === 'use client') { + useClient = true; + } + if (value === 'use server') { + useServer = true; + } + } + + return { useClient, useServer }; +} + +/** + * Collects named export identifiers from an ExportNamedDeclaration node. + * Returns `true` when the node contains `export { x as default }`, which + * counts as a default export even though it is syntactically an + * ExportNamedDeclaration. + */ +function collectNamedExports(node: BabelExportNamedDeclaration, into: Set): boolean { + if (node.exportKind === 'type') { + return false; + } + + let hasDefault = false; + + const decl = node.declaration; + if (decl) { + if (decl.type === 'TSTypeAliasDeclaration' || decl.type === 'TSInterfaceDeclaration') { + return false; + } + + if (decl.type === 'VariableDeclaration') { + for (const declarator of decl.declarations) { + if (declarator.type === 'VariableDeclarator' && declarator.id.type === 'Identifier') { + into.add(declarator.id.name); + } + } + } else { + const name = getDeclarationName(decl); + if (name) { + into.add(name); + } + } + } + + if (node.specifiers) { + node.specifiers + .filter(spec => spec.type === 'ExportSpecifier' && (spec as BabelExportSpecifier).exportKind !== 'type') + .forEach(spec => { + const name = getExportedName(spec.exported as t.Identifier | t.StringLiteral); + if (name === 'default') { + hasDefault = true; + } else if (name) { + into.add(name); + } + }); + } + + return hasDefault; +} + +function getExportedName(node: t.Identifier | t.StringLiteral): string | undefined { + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'StringLiteral') { + return node.value; + } + return undefined; +} + +function getDeclarationName(decl: t.Declaration): string | undefined { + if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') { + const id = (decl as t.FunctionDeclaration | t.ClassDeclaration).id; + return id?.type === 'Identifier' ? id.name : undefined; + } + return undefined; +} + +function importsWrapServerFunction(node: t.ImportDeclaration): boolean { + if (node.source.value !== SENTRY_PACKAGE || !node.specifiers) { + return false; + } + return node.specifiers.some( + spec => + spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'wrapServerFunction', + ); +} + +/** + * AST-based analysis of a module's directives, exports, and Sentry wrapping. + * Uses recast + @babel/parser so that patterns inside comments or strings + * are never matched. + * + * Returns `null` when the file cannot be parsed (the caller should skip it). + * + * Exported for testing. + */ +export function analyzeModule(code: string): ModuleAnalysis | null { + let program: t.Program | undefined; + try { + const ast = recast.parse(code, { parser }); + program = (ast as { program?: t.Program }).program; + } catch { + return null; + } + + if (!program) { + return null; + } + + const directives = extractDirectives(program); + + let hasDefaultExport = false; + let hasManualServerFunctionWrapping = false; + const namedExportSet = new Set(); + + recast.visit(program, { + visitExportDefaultDeclaration() { + hasDefaultExport = true; + return false; + }, + visitExportNamedDeclaration(path) { + if (collectNamedExports(path.node as BabelExportNamedDeclaration, namedExportSet)) { + hasDefaultExport = true; + } + return false; + }, + visitImportDeclaration(path) { + if (importsWrapServerFunction(path.node)) { + hasManualServerFunctionWrapping = true; + } + return false; + }, + }); + + return { + hasUseClientDirective: directives.useClient, + hasUseServerDirective: directives.useServer, + hasDefaultExport, + hasManualServerFunctionWrapping, + namedExports: [...namedExportSet], + }; +} + +/** Exported for testing. */ +export function getServerFunctionWrapperCode( + originalId: string, + exportNames: string[], + includeDefault: boolean = false, +): string { + const wrappedId = JSON.stringify(`${originalId}${WRAPPED_MODULE_SUFFIX}`); + const lines = [ + "'use server';", + `import { wrapServerFunction } from '${SENTRY_PACKAGE}';`, + `import * as _sentry_original from ${wrappedId};`, + ...exportNames.map( + name => + `export const ${name} = wrapServerFunction(${JSON.stringify(name)}, _sentry_original[${JSON.stringify(name)}]);`, + ), + ]; + if (includeDefault) { + lines.push('export default wrapServerFunction("default", _sentry_original.default);'); + } + return lines.join('\n'); +} + +/** @experimental May change in minor releases. */ +export function makeAutoInstrumentRSCPlugin(options: AutoInstrumentRSCOptions = {}): Plugin { + const { enabled = true, debug = false } = options; + + let rscDetected = false; + + return { + name: 'sentry-react-router-rsc-auto-instrument', + enforce: 'pre', + + configResolved(config) { + rscDetected = config.plugins.some(p => p.name.startsWith('react-router/rsc')); + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] RSC mode ${rscDetected ? 'detected' : 'not detected'}`); + }, + + resolveId(source) { + return source.includes(WRAPPED_MODULE_SUFFIX) ? source : null; + }, + + async load(id: string) { + if (!id.includes(WRAPPED_MODULE_SUFFIX)) { + return null; + } + const originalPath = id.slice(0, -WRAPPED_MODULE_SUFFIX.length); + try { + return await readFile(originalPath, 'utf-8'); + } catch { + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Failed to read original file: ${originalPath}`); + return null; + } + }, + + transform(code: string, id: string) { + if (id.includes(WRAPPED_MODULE_SUFFIX)) { + return null; + } + if (!enabled || !rscDetected || !JS_EXTENSIONS_RE.test(id)) { + return null; + } + + const analysis = analyzeModule(code); + if (!analysis) { + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Skipping unparseable: ${id}`); + return null; + } + + // Only handle "use server" files — server components must be wrapped manually + if (!analysis.hasUseServerDirective) { + return null; + } + + if (analysis.hasManualServerFunctionWrapping) { + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Skipping already wrapped: ${id}`); + return null; + } + + const exportNames = analysis.namedExports; + const includeDefault = analysis.hasDefaultExport; + if (exportNames.length === 0 && !includeDefault) { + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Skipping server function file with no exports: ${id}`); + return null; + } + const exportList = includeDefault ? [...exportNames, 'default'] : exportNames; + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Auto-wrapping server functions: ${id} -> [${exportList.join(', ')}]`); + return { code: getServerFunctionWrapperCode(id, exportNames, includeDefault), map: null }; + }, + }; +} diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts index d58b08df3fa2..b330890d9f5d 100644 --- a/packages/react-router/src/vite/plugin.ts +++ b/packages/react-router/src/vite/plugin.ts @@ -1,4 +1,5 @@ import type { ConfigEnv, Plugin } from 'vite'; +import { makeAutoInstrumentRSCPlugin } from './makeAutoInstrumentRSCPlugin'; import { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin'; import { makeCustomSentryVitePlugins } from './makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from './makeEnableSourceMapsPlugin'; @@ -19,6 +20,10 @@ export async function sentryReactRouter( plugins.push(makeConfigInjectorPlugin(options)); + if (options.experimental_rscAutoInstrumentation?.enabled !== false) { + plugins.push(makeAutoInstrumentRSCPlugin(options.experimental_rscAutoInstrumentation ?? {})); + } + if (process.env.NODE_ENV !== 'development' && viteConfig.command === 'build' && viteConfig.mode !== 'development') { plugins.push(makeEnableSourceMapsPlugin(options)); plugins.push(...(await makeCustomSentryVitePlugins(options))); diff --git a/packages/react-router/src/vite/recastTypescriptParser.ts b/packages/react-router/src/vite/recastTypescriptParser.ts new file mode 100644 index 000000000000..274f877ab20a --- /dev/null +++ b/packages/react-router/src/vite/recastTypescriptParser.ts @@ -0,0 +1,91 @@ +// This babel parser config is taken from recast's typescript parser config, specifically from these two files: +// see: https://github.com/benjamn/recast/blob/master/parsers/_babel_options.ts +// see: https://github.com/benjamn/recast/blob/master/parsers/babel-ts.ts +// +// Changes: +// - we add the 'jsx' plugin, because React Router files use JSX syntax +// - minor import and export changes +// - merged the two files linked above into one for simplicity + +// Date of access: 2025-03-04 +// Commit: https://github.com/benjamn/recast/commit/ba5132174894b496285da9d001f1f2524ceaed3a + +// Recast license: + +// Copyright (c) 2012 Ben Newman + +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: + +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import type { ParserPlugin } from '@babel/parser'; +import { parse as babelParse } from '@babel/parser'; +import type { Options } from 'recast'; + +export const parser: Options['parser'] = { + parse: (source: string) => + babelParse(source, { + strictMode: false, + allowImportExportEverywhere: true, + allowReturnOutsideFunction: true, + startLine: 1, + tokens: true, + plugins: [ + 'jsx', + 'typescript', + 'asyncGenerators', + 'bigInt', + 'classPrivateMethods', + 'classPrivateProperties', + 'classProperties', + 'classStaticBlock', + 'decimal', + 'decorators-legacy', + 'doExpressions', + 'dynamicImport', + 'exportDefaultFrom', + 'exportNamespaceFrom', + 'functionBind', + 'functionSent', + 'importAssertions', + 'exportExtensions' as ParserPlugin, + 'importMeta', + 'nullishCoalescingOperator', + 'numericSeparator', + 'objectRestSpread', + 'optionalCatchBinding', + 'optionalChaining', + [ + 'pipelineOperator', + { + proposal: 'minimal', + }, + ], + [ + 'recordAndTuple', + { + syntaxType: 'hash', + }, + ], + 'throwExpressions', + 'topLevelAwait', + 'v8intrinsic', + ], + sourceType: 'module', + }), +}; diff --git a/packages/react-router/src/vite/types.ts b/packages/react-router/src/vite/types.ts index c7555630c4fa..45ef383f5fd7 100644 --- a/packages/react-router/src/vite/types.ts +++ b/packages/react-router/src/vite/types.ts @@ -74,4 +74,32 @@ export type SentryReactRouterBuildOptions = BuildTimeOptionsBase & */ sourceMapsUploadOptions?: SourceMapsOptions; // todo(v11): Remove this option (all options already exist in BuildTimeOptionsBase) + + /** + * @experimental Options for automatic RSC server function instrumentation. + * RSC mode is auto-detected when `unstable_reactRouterRSC()` is present in the Vite config. + * Use this option to enable debug logging or to explicitly disable with `{ enabled: false }`. + * Server components must be wrapped manually using `wrapServerComponent`. + */ + experimental_rscAutoInstrumentation?: AutoInstrumentRSCOptions; }; + +/** + * Options for the experimental RSC auto-instrumentation Vite plugin. + * + * RSC mode is auto-detected — no explicit flag is needed. Pass this option only to + * customize behavior or to explicitly disable with `{ enabled: false }`. + */ +export type AutoInstrumentRSCOptions = { + /** + * Enable or disable auto-instrumentation of server functions. + * @default true + */ + enabled?: boolean; + + /** + * Enable debug logging to see which files are being instrumented. + * @default false + */ + debug?: boolean; +}; diff --git a/packages/react-router/test/server/rsc/responseUtils.test.ts b/packages/react-router/test/server/rsc/responseUtils.test.ts new file mode 100644 index 000000000000..630e7d44c062 --- /dev/null +++ b/packages/react-router/test/server/rsc/responseUtils.test.ts @@ -0,0 +1,79 @@ +import { addNonEnumerableProperty } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { isAlreadyCaptured, isNotFoundResponse, isRedirectResponse } from '../../../src/server/rsc/responseUtils'; + +describe('responseUtils', () => { + describe('isAlreadyCaptured', () => { + it('should return false for errors without __sentry_captured__', () => { + expect(isAlreadyCaptured(new Error('test'))).toBe(false); + }); + + it('should return true for errors with __sentry_captured__ set', () => { + const error = new Error('test'); + addNonEnumerableProperty(error as unknown as Record, '__sentry_captured__', true); + expect(isAlreadyCaptured(error)).toBe(true); + }); + + it('should return false for non-object values', () => { + expect(isAlreadyCaptured(null)).toBe(false); + expect(isAlreadyCaptured(undefined)).toBe(false); + expect(isAlreadyCaptured('string')).toBe(false); + expect(isAlreadyCaptured(42)).toBe(false); + }); + }); + + describe('isRedirectResponse', () => { + it.each([301, 302, 303, 307, 308])('should return true for Response with %d status', status => { + expect(isRedirectResponse(new Response(null, { status }))).toBe(true); + }); + + it.each([200, 404, 500])('should return false for Response with %d status', status => { + expect(isRedirectResponse(new Response(null, { status }))).toBe(false); + }); + + it('should return true for object with redirect type', () => { + expect(isRedirectResponse({ type: 'redirect', url: '/new-path' })).toBe(true); + }); + + it('should return true for object with status in 3xx range', () => { + expect(isRedirectResponse({ status: 302, location: '/new-path' })).toBe(true); + }); + + it('should return false for non-object values', () => { + expect(isRedirectResponse(null)).toBe(false); + expect(isRedirectResponse(undefined)).toBe(false); + expect(isRedirectResponse('error')).toBe(false); + expect(isRedirectResponse(42)).toBe(false); + }); + + it('should return false for Error objects', () => { + expect(isRedirectResponse(new Error('test'))).toBe(false); + }); + }); + + describe('isNotFoundResponse', () => { + it('should return true for Response with 404 status', () => { + expect(isNotFoundResponse(new Response(null, { status: 404 }))).toBe(true); + }); + + it.each([200, 302, 500])('should return false for Response with %d status', status => { + expect(isNotFoundResponse(new Response(null, { status }))).toBe(false); + }); + + it('should return true for object with not-found or notFound type', () => { + expect(isNotFoundResponse({ type: 'not-found' })).toBe(true); + expect(isNotFoundResponse({ type: 'notFound' })).toBe(true); + }); + + it('should return true for object with status 404', () => { + expect(isNotFoundResponse({ status: 404 })).toBe(true); + }); + + it('should return false for non-object values', () => { + expect(isNotFoundResponse(null)).toBe(false); + expect(isNotFoundResponse(undefined)).toBe(false); + expect(isNotFoundResponse('error')).toBe(false); + expect(isNotFoundResponse(42)).toBe(false); + }); + }); +}); diff --git a/packages/react-router/test/server/rsc/wrapServerComponent.test.ts b/packages/react-router/test/server/rsc/wrapServerComponent.test.ts new file mode 100644 index 000000000000..229d32ef0b6e --- /dev/null +++ b/packages/react-router/test/server/rsc/wrapServerComponent.test.ts @@ -0,0 +1,299 @@ +import * as core from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapServerComponent } from '../../../src/server/rsc/wrapServerComponent'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + getIsolationScope: vi.fn(), + getActiveSpan: vi.fn(), + captureException: vi.fn(), + flushIfServerless: vi.fn().mockResolvedValue(undefined), + SPAN_STATUS_OK: 1, + SPAN_STATUS_ERROR: 2, + }; +}); + +describe('wrapServerComponent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should wrap a server component and execute it', () => { + const mockResult = { type: 'div' }; + const mockComponent = vi.fn().mockReturnValue(mockResult); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/users/:id', + componentType: 'Page', + }); + const result = wrappedComponent({ id: '123' }); + + expect(result).toEqual(mockResult); + expect(mockComponent).toHaveBeenCalledWith({ id: '123' }); + expect(mockSetTransactionName).toHaveBeenCalledWith('Page Server Component (/users/:id)'); + }); + + it('should capture exceptions on sync error', () => { + const mockError = new Error('Component render failed'); + const mockComponent = vi.fn().mockImplementation(() => { + throw mockError; + }); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/users/:id', + componentType: 'Page', + }); + + expect(() => wrappedComponent()).toThrow('Component render failed'); + expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_ERROR, message: 'internal_error' }); + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { + type: 'react_router.rsc', + handled: false, + data: { + function: 'ServerComponent', + component_route: '/users/:id', + component_type: 'Page', + }, + }, + }); + }); + + it('should capture exceptions on async rejection', async () => { + const mockError = new Error('Async component failed'); + const mockComponent = vi.fn().mockRejectedValue(mockError); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/async-page', + componentType: 'Page', + }); + + const promise = wrappedComponent(); + await expect(promise).rejects.toThrow('Async component failed'); + + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { + type: 'react_router.rsc', + handled: false, + data: { + function: 'ServerComponent', + component_route: '/async-page', + component_type: 'Page', + }, + }, + }); + }); + + it('should not capture redirect responses as errors', () => { + const redirectResponse = new Response(null, { + status: 302, + headers: { Location: '/new-path' }, + }); + const mockComponent = vi.fn().mockImplementation(() => { + throw redirectResponse; + }); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/users/:id', + componentType: 'Page', + }); + + expect(() => wrappedComponent()).toThrow(); + expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_OK }); + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should not capture 404 responses as errors but mark span status', () => { + const notFoundResponse = new Response(null, { status: 404 }); + const mockComponent = vi.fn().mockImplementation(() => { + throw notFoundResponse; + }); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/users/:id', + componentType: 'Page', + }); + + expect(() => wrappedComponent()).toThrow(); + expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_ERROR, message: 'not_found' }); + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should work with async server components', async () => { + const mockResult = { type: 'div', props: { children: 'async content' } }; + const mockComponent = vi.fn().mockResolvedValue(mockResult); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/async-page', + componentType: 'Page', + }); + const result = await wrappedComponent(); + + expect(result).toEqual(mockResult); + expect(mockSetTransactionName).toHaveBeenCalledWith('Page Server Component (/async-page)'); + }); + + it('should handle a thenable that ignores the error callback gracefully', () => { + const thenableResult = { + then: (_resolve: (value: unknown) => void) => {}, + }; + const mockComponent = vi.fn().mockReturnValue(thenableResult); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/page', + componentType: 'Page', + }); + + expect(() => wrappedComponent()).not.toThrow(); + }); + + it('should flush on completion for async components', async () => { + const mockResult = { type: 'div' }; + const mockComponent = vi.fn().mockResolvedValue(mockResult); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/async-page', + componentType: 'Page', + }); + const result = wrappedComponent(); + + // Wait for the promise to resolve so the .then() handler fires + await result; + // Allow microtask queue to flush + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should flush on completion for sync components', () => { + const mockComponent = vi.fn().mockReturnValue({ type: 'div' }); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/page', + componentType: 'Page', + }); + wrappedComponent(); + + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should handle span being undefined', () => { + const mockError = new Error('Component error'); + const mockComponent = vi.fn().mockImplementation(() => { + throw mockError; + }); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue(undefined); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/page', + componentType: 'Page', + }); + + expect(() => wrappedComponent()).toThrow('Component error'); + expect(core.captureException).toHaveBeenCalled(); + }); + + it('should preserve function properties via Proxy', () => { + const mockComponent = Object.assign(vi.fn().mockReturnValue({ type: 'div' }), { + displayName: 'MyComponent', + customProp: 'value', + }); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/page', + componentType: 'Page', + }); + + expect((wrappedComponent as any).displayName).toBe('MyComponent'); + expect((wrappedComponent as any).customProp).toBe('value'); + }); + + it('should not double-capture already-captured errors', () => { + const mockError = new Error('Already captured error'); + Object.defineProperty(mockError, '__sentry_captured__', { value: true, enumerable: false }); + + const mockComponent = vi.fn().mockImplementation(() => { + throw mockError; + }); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/page', + componentType: 'Page', + }); + + expect(() => wrappedComponent()).toThrow('Already captured error'); + expect(core.captureException).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-router/test/server/rsc/wrapServerFunction.test.ts b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts new file mode 100644 index 000000000000..6254a432a622 --- /dev/null +++ b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts @@ -0,0 +1,221 @@ +import * as core from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapServerFunction } from '../../../src/server/rsc/wrapServerFunction'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startSpan: vi.fn(), + getIsolationScope: vi.fn(), + captureException: vi.fn(), + flushIfServerless: vi.fn().mockResolvedValue(undefined), + getActiveSpan: vi.fn(), + }; +}); + +describe('wrapServerFunction', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should wrap a server function and execute it', async () => { + const mockResult = { success: true }; + const mockServerFn = vi.fn().mockResolvedValue(mockResult); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + const result = await wrappedFn('arg1', 'arg2'); + + expect(result).toEqual(mockResult); + expect(mockServerFn).toHaveBeenCalledWith('arg1', 'arg2'); + expect(core.getIsolationScope).toHaveBeenCalled(); + expect(mockSetTransactionName).toHaveBeenCalledWith('serverFunction/testFunction'); + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'serverFunction/testFunction', + forceTransaction: true, + attributes: expect.objectContaining({ + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.server_function', + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.rsc.server_function', + 'rsc.server_function.name': 'testFunction', + }), + }), + expect.any(Function), + ); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should set forceTransaction to false when there is an active span', async () => { + const mockResult = { success: true }; + const mockServerFn = vi.fn().mockResolvedValue(mockResult); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.getActiveSpan as any).mockReturnValue({ spanId: 'existing-span' }); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + await wrappedFn(); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + forceTransaction: false, + }), + expect.any(Function), + ); + }); + + it('should use custom span name when provided', async () => { + const mockServerFn = vi.fn().mockResolvedValue('result'); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn, { + name: 'Custom Span Name', + }); + await wrappedFn(); + + expect(mockSetTransactionName).toHaveBeenCalledWith('Custom Span Name'); + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Custom Span Name', + }), + expect.any(Function), + ); + }); + + it('should merge custom attributes with default attributes', async () => { + const mockServerFn = vi.fn().mockResolvedValue('result'); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn, { + attributes: { 'custom.attr': 'value' }, + }); + await wrappedFn(); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.server_function', + 'custom.attr': 'value', + }), + }), + expect.any(Function), + ); + }); + + it('should capture exceptions on error', async () => { + const mockError = new Error('Server function failed'); + const mockServerFn = vi.fn().mockRejectedValue(mockError); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + + await expect(wrappedFn()).rejects.toThrow('Server function failed'); + expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { + type: 'react_router.rsc', + handled: false, + data: { + function: 'serverFunction', + server_function_name: 'testFunction', + }, + }, + }); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should not capture redirect errors as exceptions', async () => { + const redirectResponse = new Response(null, { + status: 302, + headers: { Location: '/new-path' }, + }); + const mockServerFn = vi.fn().mockRejectedValue(redirectResponse); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + + await expect(wrappedFn()).rejects.toBe(redirectResponse); + expect(mockSetStatus).toHaveBeenCalledWith({ code: 1 }); + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should not capture not-found errors as exceptions', async () => { + const notFoundResponse = new Response(null, { status: 404 }); + const mockServerFn = vi.fn().mockRejectedValue(notFoundResponse); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + + await expect(wrappedFn()).rejects.toBe(notFoundResponse); + expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'not_found' }); + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should preserve function properties via Proxy', () => { + const namedServerFn = Object.assign( + async function myServerAction(): Promise { + return 'result'; + }, + { customProp: 'value' }, + ); + const wrappedFn = wrapServerFunction('myServerAction', namedServerFn); + + // Proxy should preserve original function name and properties + expect(wrappedFn.name).toBe('myServerAction'); + expect((wrappedFn as any).customProp).toBe('value'); + }); + + it('should propagate errors after capturing', async () => { + const mockError = new Error('Test error'); + const mockServerFn = vi.fn().mockRejectedValue(mockError); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + + await expect(wrappedFn()).rejects.toBe(mockError); + }); + + it('should not double-capture already-captured errors', async () => { + const mockError = new Error('Already captured error'); + // Mark the error as already captured by Sentry + Object.defineProperty(mockError, '__sentry_captured__', { value: true, enumerable: false }); + + const mockServerFn = vi.fn().mockRejectedValue(mockError); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + + await expect(wrappedFn()).rejects.toBe(mockError); + // captureException should NOT be called since the error is already captured + expect(core.captureException).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts b/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts new file mode 100644 index 000000000000..4e116ac8de3f --- /dev/null +++ b/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts @@ -0,0 +1,534 @@ +import type { Plugin } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + analyzeModule, + getServerFunctionWrapperCode, + makeAutoInstrumentRSCPlugin, +} from '../../src/vite/makeAutoInstrumentRSCPlugin'; + +vi.spyOn(console, 'log').mockImplementation(() => { + /* noop */ +}); +vi.spyOn(console, 'warn').mockImplementation(() => { + /* noop */ +}); + +type PluginWithHooks = Plugin & { + configResolved: (config: { plugins: Array<{ name: string }> }) => void; + resolveId: (source: string) => string | null; + load: (id: string) => Promise; + transform: (code: string, id: string) => { code: string; map: null } | null; +}; + +const RSC_PLUGINS_CONFIG = { plugins: [{ name: 'react-router/rsc' }] }; +const NON_RSC_PLUGINS_CONFIG = { plugins: [{ name: 'react-router' }] }; + +/** Creates a plugin with RSC mode detected (simulates `configResolved` with RSC plugins). */ +function createPluginWithRSCDetected(options: Parameters[0] = {}): PluginWithHooks { + const plugin = makeAutoInstrumentRSCPlugin(options) as PluginWithHooks; + plugin.configResolved(RSC_PLUGINS_CONFIG); + return plugin; +} + +describe('makeAutoInstrumentRSCPlugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetModules(); + }); + + describe('resolveId', () => { + it('resolves modules with the wrapped suffix', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + expect(plugin.resolveId('/app/routes/page.tsx?sentry-rsc-wrap')).toBe('/app/routes/page.tsx?sentry-rsc-wrap'); + }); + + it('returns null for normal modules', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + expect(plugin.resolveId('/app/routes/page.tsx')).toBeNull(); + }); + }); + + describe('load', () => { + it('returns null for non-wrapped modules', async () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + await expect(plugin.load('/app/routes/page.tsx')).resolves.toBeNull(); + }); + + it('reads the original file for wrapped modules', async () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + const result = await plugin.load(`${__filename}?sentry-rsc-wrap`); + expect(result).not.toBeNull(); + expect(typeof result).toBe('string'); + expect(result).toContain('makeAutoInstrumentRSCPlugin'); + }); + + it('returns null and logs when the original file does not exist', async () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true, debug: true }) as PluginWithHooks; + const result = await plugin.load('/nonexistent/file.tsx?sentry-rsc-wrap'); + expect(result).toBeNull(); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Failed to read original file:')); + }); + }); + + describe('configResolved', () => { + it('detects RSC mode when react-router/rsc plugin is present', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + plugin.configResolved(RSC_PLUGINS_CONFIG); + + const code = "'use server';\nexport async function myAction() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + expect(result).not.toBeNull(); + }); + + it('does not detect RSC mode when only standard react-router plugin is present', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + plugin.configResolved(NON_RSC_PLUGINS_CONFIG); + + const code = "'use server';\nexport async function myAction() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + expect(result).toBeNull(); + }); + + it('does not wrap when configResolved has not been called', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + + const code = "'use server';\nexport async function myAction() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + expect(result).toBeNull(); + }); + + it('logs detection status when debug is enabled', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true, debug: true }) as PluginWithHooks; + plugin.configResolved(RSC_PLUGINS_CONFIG); + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('[Sentry RSC] RSC mode detected'); + }); + + it('logs non-detection status when debug is enabled', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true, debug: true }) as PluginWithHooks; + plugin.configResolved(NON_RSC_PLUGINS_CONFIG); + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('[Sentry RSC] RSC mode not detected'); + }); + }); + + describe('transform', () => { + it('returns null when disabled', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: false }) as PluginWithHooks; + const code = "'use server';\nexport async function myAction() {}"; + expect(plugin.transform(code, 'app/routes/rsc/actions.ts')).toBeNull(); + }); + + it('returns null for non-TS/JS files', () => { + const plugin = createPluginWithRSCDetected(); + expect(plugin.transform('some content', 'app/routes/styles.css')).toBeNull(); + }); + + it('returns null for wrapped module suffix (prevents infinite loop)', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport async function myAction() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts?sentry-rsc-wrap'); + expect(result).toBeNull(); + }); + + it('returns null for server components (no "use server" directive)', () => { + const plugin = createPluginWithRSCDetected(); + expect(plugin.transform('export default function Page() {}', 'app/routes/home.tsx')).toBeNull(); + }); + + it('returns null for "use client" files', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use client';\nexport default function ClientComponent() {}"; + expect(plugin.transform(code, 'app/routes/client.tsx')).toBeNull(); + }); + + it('returns null for files without directives or exports', () => { + const plugin = createPluginWithRSCDetected(); + expect(plugin.transform("export function helper() { return 'helper'; }", 'app/routes/utils.tsx')).toBeNull(); + }); + + // Server function auto-instrumentation ("use server" files) + it('wraps "use server" files with server function wrapper code', () => { + const plugin = createPluginWithRSCDetected(); + const code = [ + "'use server';", + 'export async function submitForm(data) { return data; }', + 'export async function getData() { return {}; }', + ].join('\n'); + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain("'use server'"); + expect(result!.code).toContain("import { wrapServerFunction } from '@sentry/react-router'"); + expect(result!.code).toContain('import * as _sentry_original from'); + expect(result!.code).toContain('app/routes/rsc/actions.ts?sentry-rsc-wrap'); + expect(result!.code).toContain('export const submitForm = wrapServerFunction("submitForm"'); + expect(result!.code).toContain('export const getData = wrapServerFunction("getData"'); + }); + + it('wraps "use server" files preceded by comments', () => { + const plugin = createPluginWithRSCDetected(); + const code = ['// Copyright 2024', '/* License */', "'use server';", 'export async function myAction() {}'].join( + '\n', + ); + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('export const myAction = wrapServerFunction("myAction"'); + }); + + it('returns null for "use server" files with no named exports', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nfunction internalHelper() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).toBeNull(); + }); + + it('skips "use server" files already containing wrapServerFunction', () => { + const plugin = createPluginWithRSCDetected(); + const code = [ + "'use server';", + "import { wrapServerFunction } from '@sentry/react-router';", + "export const action = wrapServerFunction('action', _action);", + ].join('\n'); + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).toBeNull(); + }); + + it('wraps "use server" files with export const pattern', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport const myAction = async () => {};"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('export const myAction = wrapServerFunction("myAction"'); + }); + + it('logs debug messages when wrapping server functions', () => { + const plugin = createPluginWithRSCDetected({ debug: true }); + const code = "'use server';\nexport async function myAction() {}"; + plugin.transform(code, 'app/routes/rsc/actions.ts'); + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Auto-wrapping server functions:')); + }); + + it('logs debug messages when skipping "use server" file with no exports', () => { + const plugin = createPluginWithRSCDetected({ debug: true }); + const code = "'use server';\nfunction internal() {}"; + plugin.transform(code, 'app/routes/rsc/actions.ts'); + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('[Sentry RSC] Skipping server function file with no exports:'), + ); + }); + + it('wraps "use server" files with both named and default exports', () => { + const plugin = createPluginWithRSCDetected(); + const code = [ + "'use server';", + 'export async function namedAction() { return {}; }', + 'export default async function defaultAction() { return {}; }', + ].join('\n'); + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('export const namedAction = wrapServerFunction("namedAction"'); + expect(result!.code).toContain('export default wrapServerFunction("default", _sentry_original.default)'); + }); + + it('wraps "use server" files with only default export', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport default async function serverAction() { return {}; }"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain("'use server'"); + expect(result!.code).toContain('export default wrapServerFunction("default", _sentry_original.default)'); + }); + + // Regression: ensures export class declarations are collected by the AST parser. + // While exporting a class from "use server" is uncommon, the plugin should handle + // it without crashing rather than silently skipping the export. + it('wraps export class in a "use server" file', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport class MyService { async run() {} }"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('export const MyService = wrapServerFunction("MyService"'); + }); + + // Regression: export default async function name should be treated as default, not named + it('does not extract "export default async function name" as a named export', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport default async function serverAction() { return {}; }"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + // Should wrap as default, not as a named export called "serverAction" + expect(result!.code).toContain('export default wrapServerFunction("default", _sentry_original.default)'); + expect(result!.code).not.toContain('export const serverAction'); + }); + + it('wraps "use server" files regardless of their directory location', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport async function myAction() {}"; + + // Should work from any directory, not just routes + const result = plugin.transform(code, 'app/lib/server-actions.ts'); + expect(result).not.toBeNull(); + expect(result!.code).toContain('export const myAction = wrapServerFunction("myAction"'); + }); + + it.each(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts'])('wraps "use server" files with %s extension', ext => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport async function action() {}"; + const result = plugin.transform(code, `app/routes/rsc/actions${ext}`); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('wrapServerFunction'); + }); + + it('does not log when debug is disabled', () => { + const plugin = createPluginWithRSCDetected({ debug: false }); + + plugin.transform("'use server';\nexport async function action() {}", 'app/routes/rsc/actions.ts'); + + // eslint-disable-next-line no-console + expect(console.log).not.toHaveBeenCalled(); + // eslint-disable-next-line no-console + expect(console.warn).not.toHaveBeenCalled(); + }); + }); + + describe('analyzeModule', () => { + // Named export extraction + it('extracts export const declarations', () => { + const code = "export const submitForm = wrapServerFunction('submitForm', _submitForm);"; + expect(analyzeModule(code)?.namedExports).toEqual(['submitForm']); + }); + + it('extracts export function declarations', () => { + const code = 'export function submitForm(data) { return data; }'; + expect(analyzeModule(code)?.namedExports).toEqual(['submitForm']); + }); + + it('extracts export async function declarations', () => { + const code = 'export async function fetchData() { return await fetch("/api"); }'; + expect(analyzeModule(code)?.namedExports).toEqual(['fetchData']); + }); + + it('extracts multiple exports', () => { + const code = [ + 'export async function submitForm(data) {}', + 'export async function getData() {}', + 'export const CONFIG = {};', + ].join('\n'); + expect(analyzeModule(code)?.namedExports).toEqual(expect.arrayContaining(['submitForm', 'getData', 'CONFIG'])); + expect(analyzeModule(code)?.namedExports).toHaveLength(3); + }); + + it('extracts export { a, b, c } specifiers', () => { + const code = 'function a() {}\nfunction b() {}\nexport { a, b }'; + expect(analyzeModule(code)?.namedExports).toEqual(['a', 'b']); + }); + + it('extracts aliased exports using the exported name', () => { + const code = 'function _internal() {}\nexport { _internal as publicName }'; + expect(analyzeModule(code)?.namedExports).toEqual(['publicName']); + }); + + it('returns empty array when no exports are found', () => { + const code = 'function helper() { return 42; }'; + expect(analyzeModule(code)?.namedExports).toEqual([]); + }); + + it('ignores export default', () => { + const code = 'export default function Page() {}'; + expect(analyzeModule(code)?.namedExports).toEqual([]); + }); + + it('treats export { x as default } as a default export, not a named export', () => { + const result = analyzeModule('function myFunc() {}\nexport { myFunc as default }'); + expect(result?.namedExports).toEqual([]); + expect(result?.hasDefaultExport).toBe(true); + }); + + it('deduplicates exports', () => { + const code = 'export const a = 1;\nexport { a }'; + expect(analyzeModule(code)?.namedExports).toEqual(['a']); + }); + + it('handles mixed export styles', () => { + const code = [ + 'export const a = 1;', + 'export function b() {}', + 'export async function c() {}', + 'function d() {}', + 'export { d }', + ].join('\n'); + expect(analyzeModule(code)?.namedExports).toEqual(['a', 'b', 'c', 'd']); + }); + + it('ignores type-only exports', () => { + const code = [ + 'export type MyType = string;', + 'export interface MyInterface {}', + 'export const realExport = 1;', + ].join('\n'); + expect(analyzeModule(code)?.namedExports).toEqual(['realExport']); + }); + + it('ignores inline type exports in export { type X }', () => { + const code = 'type Foo = string;\ntype Baz = number;\nconst bar = 1;\nexport { type Foo, bar, type Baz as Qux }'; + expect(analyzeModule(code)?.namedExports).toEqual(['bar']); + }); + + it('ignores type-only export specifiers mixed with regular exports', () => { + const code = ['type MyType = string;', 'const a = 1;', 'const b = 2;', 'export { type MyType, a, b }'].join('\n'); + expect(analyzeModule(code)?.namedExports).toEqual(['a', 'b']); + }); + + it('extracts export class declarations', () => { + expect(analyzeModule('export class MyClass {}')?.namedExports).toEqual(['MyClass']); + }); + + // Regression test: export default async function does not appear as named export + it('does not extract "export default async function name" as a named export', () => { + const result = analyzeModule('export default async function serverAction() { return {}; }'); + expect(result?.namedExports).toEqual([]); + expect(result?.hasDefaultExport).toBe(true); + }); + + // Directive detection + it('detects "use server" directive', () => { + const result = analyzeModule("'use server';\nexport async function action() {}"); + expect(result?.hasUseServerDirective).toBe(true); + }); + + it('detects "use server" combined with "use strict"', () => { + const result = analyzeModule("'use strict';\n'use server';\nexport async function action() {}"); + expect(result?.hasUseServerDirective).toBe(true); + }); + + it('does not treat "use server" inside a comment as a directive', () => { + const result = analyzeModule('// "use server"\nexport async function action() {}'); + expect(result?.hasUseServerDirective).toBe(false); + }); + + it('does not treat "use server" inside a string as a directive', () => { + const result = analyzeModule('const x = "use server";\nexport async function action() {}'); + expect(result?.hasUseServerDirective).toBe(false); + }); + + // Default export detection + it('detects default export', () => { + expect(analyzeModule('export default function Page() {}')?.hasDefaultExport).toBe(true); + }); + + it('reports no default export when none exists', () => { + expect(analyzeModule('export function helper() {}')?.hasDefaultExport).toBe(false); + }); + + // Manual wrapping detection + it('detects manual wrapping with wrapServerFunction import', () => { + const code = + "import { wrapServerFunction } from '@sentry/react-router';\nexport const action = wrapServerFunction('action', _action);"; + expect(analyzeModule(code)?.hasManualServerFunctionWrapping).toBe(true); + }); + + it('does not treat wrapServerFunction in a comment as manual wrapping', () => { + const result = analyzeModule( + "// import { wrapServerFunction } from '@sentry/react-router';\nexport async function action() {}", + ); + expect(result?.hasManualServerFunctionWrapping).toBe(false); + }); + + // Parse failure + it('returns null for unparseable code', () => { + expect(analyzeModule('this is not valid {{{')).toBeNull(); + }); + }); + + describe('getServerFunctionWrapperCode', () => { + it('generates wrapper code with use server directive', () => { + const result = getServerFunctionWrapperCode('/app/routes/rsc/actions.ts', ['submitForm', 'getData']); + + expect(result).toContain("'use server'"); + expect(result).toContain("import { wrapServerFunction } from '@sentry/react-router'"); + expect(result).toContain('import * as _sentry_original from'); + expect(result).toContain('/app/routes/rsc/actions.ts?sentry-rsc-wrap'); + }); + + it('wraps each named export with wrapServerFunction', () => { + const result = getServerFunctionWrapperCode('/app/routes/rsc/actions.ts', ['submitForm', 'getData']); + + expect(result).toContain( + 'export const submitForm = wrapServerFunction("submitForm", _sentry_original["submitForm"])', + ); + expect(result).toContain('export const getData = wrapServerFunction("getData", _sentry_original["getData"])'); + }); + + it('handles a single export', () => { + const result = getServerFunctionWrapperCode('/app/routes/rsc/actions.ts', ['myAction']); + + expect(result).toContain('export const myAction = wrapServerFunction("myAction", _sentry_original["myAction"])'); + }); + + it('escapes special characters in export names via JSON.stringify', () => { + const result = getServerFunctionWrapperCode('/app/routes/actions.ts', ['$action']); + + expect(result).toContain('export const $action = wrapServerFunction("$action", _sentry_original["$action"])'); + }); + + it('includes wrapped default export when includeDefault is true', () => { + const result = getServerFunctionWrapperCode('/app/routes/actions.ts', ['namedAction'], true); + + expect(result).toContain( + 'export const namedAction = wrapServerFunction("namedAction", _sentry_original["namedAction"])', + ); + expect(result).toContain('export default wrapServerFunction("default", _sentry_original.default)'); + }); + + it('does not include default export when includeDefault is false', () => { + const result = getServerFunctionWrapperCode('/app/routes/actions.ts', ['namedAction'], false); + + expect(result).toContain('export const namedAction = wrapServerFunction("namedAction"'); + expect(result).not.toContain('export default'); + }); + + it('handles file with only default export when includeDefault is true', () => { + const result = getServerFunctionWrapperCode('/app/routes/actions.ts', [], true); + + expect(result).toContain("'use server'"); + expect(result).toContain('export default wrapServerFunction("default", _sentry_original.default)'); + }); + }); + + describe('plugin creation', () => { + it('creates a plugin with the correct name and enforce value', () => { + const plugin = makeAutoInstrumentRSCPlugin(); + + expect(plugin.name).toBe('sentry-react-router-rsc-auto-instrument'); + expect(plugin.enforce).toBe('pre'); + }); + + it('defaults to enabled when no options are provided', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport async function action() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + }); + }); +}); diff --git a/packages/react-router/test/vite/plugin.test.ts b/packages/react-router/test/vite/plugin.test.ts index f01254ca8869..227f6685a89f 100644 --- a/packages/react-router/test/vite/plugin.test.ts +++ b/packages/react-router/test/vite/plugin.test.ts @@ -37,7 +37,8 @@ describe('sentryReactRouter', () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin]); + expect(result).toHaveLength(2); + expect(result).toContainEqual(mockConfigInjectorPlugin); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); @@ -47,7 +48,8 @@ describe('sentryReactRouter', () => { it('should return config injector plugin when not in build mode', async () => { const result = await sentryReactRouter({}, { command: 'serve', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin]); + expect(result).toHaveLength(2); + expect(result).toContainEqual(mockConfigInjectorPlugin); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -55,7 +57,8 @@ describe('sentryReactRouter', () => { it('should return config injector plugin in development build mode', async () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'development' }); - expect(result).toEqual([mockConfigInjectorPlugin]); + expect(result).toHaveLength(2); + expect(result).toContainEqual(mockConfigInjectorPlugin); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -66,7 +69,10 @@ describe('sentryReactRouter', () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockSourceMapsPlugin, ...mockPlugins]); + expect(result).toHaveLength(4); + expect(result).toContainEqual(mockConfigInjectorPlugin); + expect(result).toContainEqual(mockSourceMapsPlugin); + expect(result).toContainEqual(mockPlugins[0]); expect(makeConfigInjectorPlugin).toHaveBeenCalledWith({}); expect(makeCustomSentryVitePlugins).toHaveBeenCalledWith({}); expect(makeEnableSourceMapsPlugin).toHaveBeenCalledWith({}); @@ -74,6 +80,22 @@ describe('sentryReactRouter', () => { process.env.NODE_ENV = originalNodeEnv; }); + it('should always include RSC auto-instrument plugin by default', async () => { + const result = await sentryReactRouter({}, { command: 'serve', mode: 'development' }); + + expect(result).toContainEqual(expect.objectContaining({ name: 'sentry-react-router-rsc-auto-instrument' })); + }); + + it('should not include RSC auto-instrument plugin when enabled is explicitly false', async () => { + const result = await sentryReactRouter( + { experimental_rscAutoInstrumentation: { enabled: false } }, + { command: 'serve', mode: 'development' }, + ); + + expect(result).toHaveLength(1); + expect(result).not.toContainEqual(expect.objectContaining({ name: 'sentry-react-router-rsc-auto-instrument' })); + }); + it('should pass release configuration to plugins', async () => { const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production';