From 5778e6b449e05efe8d901083e34eb0dbcddd8237 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 24 Dec 2025 10:15:43 +0000 Subject: [PATCH 01/13] feat(react-router): Add Experimental React Server Components (RSC) instrumentation --- .../react-router-7-rsc/.gitignore | 10 + .../react-router-7-rsc/.npmrc | 2 + .../react-router-7-rsc/README.md | 5 + .../react-router-7-rsc/app/app.css | 47 +++ .../react-router-7-rsc/app/entry.client.tsx | 23 ++ .../react-router-7-rsc/app/entry.server.tsx | 18 + .../react-router-7-rsc/app/root.tsx | 57 +++ .../react-router-7-rsc/app/routes.ts | 21 + .../react-router-7-rsc/app/routes/home.tsx | 34 ++ .../app/routes/performance/dynamic-param.tsx | 10 + .../app/routes/performance/index.tsx | 16 + .../app/routes/rsc/actions.ts | 35 ++ .../app/routes/rsc/server-component-async.tsx | 44 ++ .../app/routes/rsc/server-component-error.tsx | 32 ++ .../routes/rsc/server-component-not-found.tsx | 16 + .../app/routes/rsc/server-component-param.tsx | 29 ++ .../routes/rsc/server-component-redirect.tsx | 17 + .../app/routes/rsc/server-component.tsx | 36 ++ .../app/routes/rsc/server-function-error.tsx | 32 ++ .../app/routes/rsc/server-function.tsx | 34 ++ .../react-router-7-rsc/instrument.mjs | 9 + .../react-router-7-rsc/package.json | 64 +++ .../react-router-7-rsc/playwright.config.mjs | 8 + .../react-router-7-rsc/public/.gitkeep | 0 .../react-router-7-rsc/react-router.config.ts | 5 + .../react-router-7-rsc/start-event-proxy.mjs | 6 + .../react-router-7-rsc/tests/constants.ts | 1 + .../performance/performance.server.test.ts | 107 +++++ .../tests/rsc/server-component.test.ts | 104 +++++ .../tests/rsc/server-function.test.ts | 105 +++++ .../react-router-7-rsc/tsconfig.json | 24 ++ .../react-router-7-rsc/vite.config.ts | 9 + packages/react-router/src/server/index.ts | 25 ++ packages/react-router/src/server/rsc/index.ts | 25 ++ .../src/server/rsc/responseUtils.ts | 93 +++++ packages/react-router/src/server/rsc/types.ts | 165 ++++++++ .../server/rsc/wrapMatchRSCServerRequest.ts | 181 +++++++++ .../server/rsc/wrapRouteRSCServerRequest.ts | 146 +++++++ .../src/server/rsc/wrapServerComponent.ts | 116 ++++++ .../src/server/rsc/wrapServerFunction.ts | 150 +++++++ .../test/server/rsc/responseUtils.test.ts | 240 +++++++++++ .../rsc/wrapMatchRSCServerRequest.test.ts | 325 +++++++++++++++ .../rsc/wrapRouteRSCServerRequest.test.ts | 303 ++++++++++++++ .../server/rsc/wrapServerComponent.test.ts | 375 ++++++++++++++++++ .../server/rsc/wrapServerFunction.test.ts | 214 ++++++++++ 45 files changed, 3318 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/README.md create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/app.css create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.server.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/home.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/dynamic-param.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-async.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-not-found.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-param.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-redirect.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/package.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/public/.gitkeep create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/react-router.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/constants.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/performance/performance.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-component.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-function.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/vite.config.ts create mode 100644 packages/react-router/src/server/rsc/index.ts create mode 100644 packages/react-router/src/server/rsc/responseUtils.ts create mode 100644 packages/react-router/src/server/rsc/types.ts create mode 100644 packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts create mode 100644 packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts create mode 100644 packages/react-router/src/server/rsc/wrapServerComponent.ts create mode 100644 packages/react-router/src/server/rsc/wrapServerFunction.ts create mode 100644 packages/react-router/test/server/rsc/responseUtils.test.ts create mode 100644 packages/react-router/test/server/rsc/wrapMatchRSCServerRequest.test.ts create mode 100644 packages/react-router/test/server/rsc/wrapRouteRSCServerRequest.test.ts create mode 100644 packages/react-router/test/server/rsc/wrapServerComponent.test.ts create mode 100644 packages/react-router/test/server/rsc/wrapServerFunction.test.ts 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.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.client.tsx new file mode 100644 index 000000000000..cc7961fb46ed --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.client.tsx @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/react-router'; +import { StrictMode, startTransition } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { HydratedRouter } from 'react-router/dom'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: 'https://username@domain/123', + tunnel: `http://localhost:3031/`, // proxy server + integrations: [Sentry.reactRouterTracingIntegration()], + tracesSampleRate: 1.0, + tracePropagationTargets: [/^\//], + debug: true, +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); 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..3bd1d38d8ffa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/root.tsx @@ -0,0 +1,57 @@ +import * as Sentry from '@sentry/react-router'; +import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router'; +import type { Route } from './+types/root'; +import stylesheet from './app.css?url'; + +export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +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..dff6af8aba5f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes.ts @@ -0,0 +1,21 @@ +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'), + // RSC Server Function tests + route('server-function', 'routes/rsc/server-function.tsx'), + route('server-function-error', 'routes/rsc/server-function-error.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.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions.ts new file mode 100644 index 000000000000..0ae0caec75c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions.ts @@ -0,0 +1,35 @@ +'use server'; + +import { wrapServerFunction } from '@sentry/react-router'; + +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 const submitForm = wrapServerFunction('submitForm', _submitForm); + +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 submitFormWithError = wrapServerFunction('submitFormWithError', _submitFormWithError); + +async function _getData(): Promise<{ timestamp: number; data: string }> { + await new Promise(resolve => setTimeout(resolve, 20)); + + return { + timestamp: Date.now(), + data: 'Fetched from server function', + }; +} + +export const getData = wrapServerFunction('getData', _getData); 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..6606aea631bf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-async.tsx @@ -0,0 +1,44 @@ +import { wrapServerComponent } from '@sentry/react-router'; +import type { Route } from './+types/server-component-async'; + +async function fetchData(): Promise<{ title: string; content: string }> { + // Simulate async data fetch + await new Promise(resolve => setTimeout(resolve, 50)); + return { + title: 'Async Server Component', + content: 'This content was fetched asynchronously on the server.', + }; +} + +// Wrapped async server component for RSC mode +async function _AsyncServerComponent(_props: Route.ComponentProps) { + const data = await fetchData(); + + return ( +
+

{data.title}

+

{data.content}

+
+ ); +} + +export const ServerComponent = wrapServerComponent(_AsyncServerComponent, { + componentRoute: '/rsc/server-component-async', + componentType: 'Page', +}); + +// Loader fetches data in standard mode +export async function loader() { + const data = await fetchData(); + return data; +} + +// Default export for standard framework mode +// export default function AsyncServerComponentPage({ loaderData }: Route.ComponentProps) { +// return ( +//
+//

{loaderData?.title ?? 'Loading...'}

+//

{loaderData?.content ?? 'Loading...'}

+//
+// ); +// } 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..518f75af0b00 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-error.tsx @@ -0,0 +1,32 @@ +import { wrapServerComponent } from '@sentry/react-router'; +import type { Route } from './+types/server-component-error'; + +// Demonstrate error capture in wrapServerComponent +async function _ServerComponentWithError(_props: Route.ComponentProps) { + throw new Error('RSC Server Component Error: Mamma mia!'); +} + +export const ServerComponent = wrapServerComponent(_ServerComponentWithError, { + componentRoute: '/rsc/server-component-error', + componentType: 'Page', +}); + +// For testing, we can trigger the wrapped component via a loader +export async function loader() { + // Call the wrapped ServerComponent to test error capture + try { + await ServerComponent({} as Route.ComponentProps); + } catch (e) { + // Error is captured by Sentry, rethrow for error boundary + throw e; + } + return {}; +} + +// export default function ServerComponentErrorPage() { +// return ( +//
+//

Server Component Error 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..0fad23e20fe1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-not-found.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-component-not-found'; + +// This route demonstrates that 404 responses are NOT captured as errors +export async function loader() { + // Throw a 404 response + throw new Response('Not Found', { status: 404 }); +} + +export default function NotFoundServerComponentPage() { + return ( +
+

Not Found Server Component

+

This triggers a 404 response.

+
+ ); +} 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..8e0c1f919a55 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-param.tsx @@ -0,0 +1,29 @@ +import { wrapServerComponent } from '@sentry/react-router'; +import type { Route } from './+types/server-component-param'; + +// Wrapped parameterized server component for RSC mode +async function _ParamServerComponent({ params }: Route.ComponentProps) { + await new Promise(resolve => setTimeout(resolve, 10)); + + return ( +
+

Server Component with Parameter

+

Parameter: {params.param}

+
+ ); +} + +export const ServerComponent = wrapServerComponent(_ParamServerComponent, { + componentRoute: '/rsc/server-component/:param', + componentType: 'Page', +}); + +// Default export for standard framework mode +// export default function ParamServerComponentPage({ params }: Route.ComponentProps) { +// return ( +//
+//

Server Component with Param

+//

Param: {params.param}

+//
+// ); +// } 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..a85dadcfe961 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-redirect.tsx @@ -0,0 +1,17 @@ +import { redirect } from 'react-router'; +import type { Route } from './+types/server-component-redirect'; + +// This route demonstrates that redirects are NOT captured as errors +export async function loader() { + // Redirect to home page + throw redirect('/'); +} + +export default function RedirectServerComponentPage() { + return ( +
+

Redirect Server Component

+

You should be redirected and not see this.

+
+ ); +} 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..90469de4a3ed --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component.tsx @@ -0,0 +1,36 @@ +import { wrapServerComponent } from '@sentry/react-router'; +import type { Route } from './+types/server-component'; + +// Demonstrate wrapServerComponent - this wrapper can be used to instrument +// server components when RSC Framework Mode is enabled +async function _ServerComponent({ loaderData }: Route.ComponentProps) { + await new Promise(resolve => setTimeout(resolve, 10)); + + return ( +
+

Server Component

+

This demonstrates a wrapped server component.

+

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

+
+ ); +} + +// Export the wrapped component - used when RSC mode is enabled +export const ServerComponent = wrapServerComponent(_ServerComponent, { + componentRoute: '/rsc/server-component', + componentType: 'Page', +}); + +export async function loader() { + return { message: 'Hello from server loader!' }; +} + +// Default export for standard framework mode +// export default function ServerComponentPage({ loaderData }: Route.ComponentProps) { +// return ( +//
+//

Server Component Page

+//

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

+//
+// ); +// } 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/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..96ef67858e40 --- /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.9.2", + "@react-router/node": "^7.9.2", + "@react-router/serve": "^7.9.2", + "@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.9.2", + "@vitejs/plugin-react": "^4.5.1", + "@vitejs/plugin-rsc": "^0.5.9", + "@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": { + "skip": true, + "variants": [ + { + "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..77cffb09225b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/performance/performance.server.test.ts @@ -0,0 +1,107 @@ +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, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance'; + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.request_handler', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.request_handler', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /performance', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'node', + 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: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { 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, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with/:param'; + }); + + await page.goto(`/performance/with/some-param`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.request_handler', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.request_handler', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'node', + 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: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { 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..3264a1f374b8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-component.test.ts @@ -0,0 +1,104 @@ +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 wrapped server component called in loader', async ({ page }) => { + const errorMessage = 'RSC Server Component Error: Mamma mia!'; + const errorPromise = waitForError(APP_NAME, async 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: 'instrument', + data: { + function: 'ServerComponent', + component_route: '/rsc/server-component-error', + component_type: 'Page', + }, + }, + }, + ], + }, + 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('server component page loads with loader data', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /rsc/server-component'; + }); + + await page.goto(`/rsc/server-component`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: 'GET /rsc/server-component', + platform: 'node', + environment: 'qa', + }); + + // Verify the page renders with loader data + await expect(page.getByTestId('loader-message')).toContainText('Hello from server loader!'); + }); + + test('async server component page loads', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /rsc/server-component-async'; + }); + + await page.goto(`/rsc/server-component-async`); + + const transaction = await txPromise; + + expect(transaction).toBeDefined(); + + // Verify the page renders async content + await expect(page.getByTestId('title')).toHaveText('Async Server Component'); + await expect(page.getByTestId('content')).toHaveText('This content was fetched asynchronously on the server.'); + }); + + test('parameterized server component route works', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /rsc/server-component/:param'; + }); + + await page.goto(`/rsc/server-component/my-test-param`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + transaction: 'GET /rsc/server-component/:param', + }); + + // Verify the param was passed correctly + await expect(page.getByTestId('param')).toContainText('my-test-param'); + }); +}); 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..4d55de01064e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-function.test.ts @@ -0,0 +1,105 @@ +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 }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + // The server function is called via the action, look for the action transaction + return transactionEvent.transaction?.includes('/rsc/server-function'); + }); + + await page.goto(`/rsc/server-function`); + await page.locator('#submit').click(); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + platform: 'node', + environment: 'qa', + }); + + // Check for server function span in the transaction + const serverFunctionSpan = transaction.spans?.find( + (span: any) => span.data?.['rsc.server_function.name'] === 'submitForm', + ); + + if (serverFunctionSpan) { + 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', + }), + }); + } + + // Verify the form submission was successful + await expect(page.getByTestId('message')).toContainText('Hello, Sentry User!'); + }); + + test('captures error from wrapped server function', async ({ page }) => { + const errorMessage = 'RSC Server Function Error: Something went wrong!'; + const errorPromise = waitForError(APP_NAME, async 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: 'instrument', + 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('server function page loads correctly', async ({ page }) => { + await page.goto(`/rsc/server-function`); + + // Verify the page structure + await expect(page.locator('h1')).toHaveText('Server Function Test'); + await expect(page.locator('#name')).toHaveValue('Sentry User'); + await expect(page.locator('#submit')).toBeVisible(); + }); + + test('server function form submission with custom input', async ({ page }) => { + await page.goto(`/rsc/server-function`); + await page.fill('#name', 'Test User'); + await page.locator('#submit').click(); + + // Verify the form submission result + await expect(page.getByTestId('message')).toContainText('Hello, Test User!'); + }); +}); 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..3c579d67339a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/vite.config.ts @@ -0,0 +1,9 @@ +import { unstable_reactRouterRSC } from '@react-router/dev/vite'; +import rsc from '@vitejs/plugin-rsc/plugin'; +import { defineConfig } from 'vite'; + +// RSC Framework Mode (Preview - React Router 7.9.2+) +// This enables React Server Components support in React Router +export default defineConfig({ + plugins: [unstable_reactRouterRSC(), rsc()], +}); diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index e0b8c8981632..f5bf19a473ac 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -18,3 +18,28 @@ export { isInstrumentationApiUsed, type CreateSentryServerInstrumentationOptions, } from './createServerInstrumentation'; + +// React Server Components (RSC) - React Router v7.9.0+ +export { + wrapMatchRSCServerRequest, + wrapRouteRSCServerRequest, + wrapServerFunction, + wrapServerFunctions, + wrapServerComponent, + isServerComponentContext, +} from './rsc'; + +export type { + RSCRouteConfigEntry, + RSCPayload, + RSCMatch, + DecodedPayload, + RouterContextProvider, + MatchRSCServerRequestArgs, + MatchRSCServerRequestFn, + RouteRSCServerRequestArgs, + RouteRSCServerRequestFn, + RSCHydratedRouterProps, + 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..e1c33d51b51d --- /dev/null +++ b/packages/react-router/src/server/rsc/index.ts @@ -0,0 +1,25 @@ +export { wrapMatchRSCServerRequest } from './wrapMatchRSCServerRequest'; +export { wrapRouteRSCServerRequest } from './wrapRouteRSCServerRequest'; +export { wrapServerFunction, wrapServerFunctions } from './wrapServerFunction'; +export { wrapServerComponent, isServerComponentContext } from './wrapServerComponent'; + +export type { + RSCRouteConfigEntry, + RSCPayload, + RSCMatch, + DecodedPayload, + RouterContextProvider, + DecodeReplyFunction, + DecodeActionFunction, + DecodeFormStateFunction, + LoadServerActionFunction, + SSRCreateFromReadableStreamFunction, + BrowserCreateFromReadableStreamFunction, + MatchRSCServerRequestArgs, + MatchRSCServerRequestFn, + RouteRSCServerRequestArgs, + RouteRSCServerRequestFn, + RSCHydratedRouterProps, + 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..fd5782ec9a4c --- /dev/null +++ b/packages/react-router/src/server/rsc/responseUtils.ts @@ -0,0 +1,93 @@ +import { debug } from '@sentry/core'; +import { DEBUG_BUILD } from '../../common/debug-build'; + +/** + * WeakSet to track errors that have been captured to avoid double-capture. + * Uses WeakSet so errors are automatically removed when garbage collected. + */ +const CAPTURED_ERRORS = new WeakSet(); + +/** + * Check if an error has already been captured by Sentry. + * Only works for object errors - primitives always return false. + */ +export function isErrorCaptured(error: unknown): boolean { + return error !== null && typeof error === 'object' && CAPTURED_ERRORS.has(error); +} + +/** + * Mark an error as captured to prevent double-capture. + * Only marks object errors - primitives are silently ignored. + */ +export function markErrorAsCaptured(error: unknown): void { + if (error !== null && typeof error === 'object') { + CAPTURED_ERRORS.add(error); + } +} + +/** + * Check if an error/response is a redirect. + * React Router uses Response objects for redirects (3xx status codes). + */ +export function isRedirectResponse(error: unknown): boolean { + if (error instanceof Response) { + const status = error.status; + // 3xx status codes are redirects (301, 302, 303, 307, 308, etc.) + return status >= 300 && status < 400; + } + + // Check for redirect-like objects (internal React Router throwables) + if (error && typeof error === 'object') { + const errorObj = error as { status?: number; statusCode?: number; type?: unknown }; + + // Check for explicit redirect type (React Router internal) + if (typeof errorObj.type === 'string' && errorObj.type === 'redirect') { + return true; + } + + // Check for redirect status codes + const status = errorObj.status ?? errorObj.statusCode; + if (typeof status === 'number' && status >= 300 && status < 400) { + return true; + } + } + + return false; +} + +/** + * Check if an error/response is a not-found response (404). + */ +export function isNotFoundResponse(error: unknown): boolean { + if (error instanceof Response) { + return error.status === 404; + } + + // Check for not-found-like objects (internal React Router throwables) + if (error && typeof error === 'object') { + const errorObj = error as { status?: number; statusCode?: number; type?: unknown }; + + // Check for explicit not-found type (React Router internal) + if (typeof errorObj.type === 'string' && (errorObj.type === 'not-found' || errorObj.type === 'notFound')) { + return true; + } + + // Check for 404 status code + const status = errorObj.status ?? errorObj.statusCode; + if (status === 404) { + return true; + } + } + + return false; +} + +/** + * Safely flush events in serverless environments. + * Uses fire-and-forget pattern to avoid swallowing original errors. + */ +export function safeFlushServerless(flushFn: () => Promise): void { + flushFn().catch(e => { + DEBUG_BUILD && debug.warn('Failed to flush events in serverless environment', e); + }); +} 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..fee95cf7b91f --- /dev/null +++ b/packages/react-router/src/server/rsc/types.ts @@ -0,0 +1,165 @@ +/** + * Type definitions for React Router RSC (React Server Components) APIs. + * + * These types mirror the unstable RSC APIs from react-router v7.9.0+. + * All RSC APIs in React Router are prefixed with `unstable_` and subject to change. + */ + +/** + * RSC route configuration entry - mirrors `unstable_RSCRouteConfigEntry` from react-router. + */ +export interface RSCRouteConfigEntry { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; + path?: string; + index?: boolean; + caseSensitive?: boolean; + id?: string; + children?: RSCRouteConfigEntry[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + lazy?: () => Promise; +} + +/** + * RSC payload types - mirrors the various payload types from react-router. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RSCPayload = any; + +/** + * RSC match result - mirrors `RSCMatch` from react-router. + */ +export interface RSCMatch { + payload: RSCPayload; + statusCode: number; + headers: Headers; +} + +/** + * Decoded payload type for SSR rendering. + */ +export interface DecodedPayload { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + formState?: Promise; + _deepestRenderedBoundaryId?: string | null; +} + +/** + * Function types for RSC operations from react-server-dom packages. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type DecodeReplyFunction = (body: FormData | string, options?: any) => Promise; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type DecodeActionFunction = (body: FormData, options?: any) => Promise; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type DecodeFormStateFunction = (actionResult: any, body: FormData, options?: any) => Promise; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type LoadServerActionFunction = (id: string) => Promise; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type SSRCreateFromReadableStreamFunction = (stream: ReadableStream) => Promise; +export type BrowserCreateFromReadableStreamFunction = ( + stream: ReadableStream, + options?: { temporaryReferences?: unknown }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +) => Promise; + +/** + * Router context provider - mirrors `RouterContextProvider` from react-router. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RouterContextProvider = any; + +/** + * Arguments for `unstable_matchRSCServerRequest`. + */ +export interface MatchRSCServerRequestArgs { + /** Function that returns a temporary reference set for tracking references in RSC stream */ + createTemporaryReferenceSet: () => unknown; + /** The basename to use when matching the request */ + basename?: string; + /** Function to decode server function arguments */ + decodeReply?: DecodeReplyFunction; + /** Per-request context provider instance */ + requestContext?: RouterContextProvider; + /** Function to load a server action by ID */ + loadServerAction?: LoadServerActionFunction; + /** Function to decode server actions */ + decodeAction?: DecodeActionFunction; + /** Function to decode form state for useActionState */ + decodeFormState?: DecodeFormStateFunction; + /** Error handler for request processing errors */ + onError?: (error: unknown) => void; + /** The Request to match against */ + request: Request; + /** Route definitions */ + routes: RSCRouteConfigEntry[]; + /** Function to generate Response encoding the RSC payload */ + generateResponse: ( + match: RSCMatch, + options: { temporaryReferences: unknown; onError?: (error: unknown) => string | undefined }, + ) => Response; +} + +/** + * Function signature for `unstable_matchRSCServerRequest`. + */ +export type MatchRSCServerRequestFn = (args: MatchRSCServerRequestArgs) => Promise; + +/** + * Arguments for `unstable_routeRSCServerRequest`. + */ +export interface RouteRSCServerRequestArgs { + /** The incoming request to route */ + request: Request; + /** Function that forwards request to RSC handler and returns Response with RSC payload */ + fetchServer: (request: Request) => Promise; + /** Function to decode RSC payloads from server */ + createFromReadableStream: SSRCreateFromReadableStreamFunction; + /** Function that renders the payload to HTML */ + renderHTML: ( + getPayload: () => DecodedPayload & Promise, + ) => ReadableStream | Promise>; + /** Whether to hydrate the server response with RSC payload (default: true) */ + hydrate?: boolean; +} + +/** + * Function signature for `unstable_routeRSCServerRequest`. + */ +export type RouteRSCServerRequestFn = (args: RouteRSCServerRequestArgs) => Promise; + +/** + * Props for `unstable_RSCHydratedRouter` component. + */ +export interface RSCHydratedRouterProps { + /** Function to decode RSC payloads from server */ + createFromReadableStream: BrowserCreateFromReadableStreamFunction; + /** Optional fetch implementation */ + fetch?: (request: Request) => Promise; + /** The decoded RSC payload to hydrate */ + payload: RSCPayload; + /** Route discovery behavior: "eager" or "lazy" */ + routeDiscovery?: 'eager' | 'lazy'; + /** Function that returns a router context provider instance */ + getContext?: () => RouterContextProvider; +} + +/** + * Context for server component wrapping. + */ +export interface ServerComponentContext { + /** The parameterized route path (e.g., "/users/:id") */ + componentRoute: string; + /** The type of component */ + componentType: 'Page' | 'Layout' | 'Loading' | 'Error' | 'Template' | 'Not-found' | 'Unknown'; +} + +/** + * Options for server function wrapping. + */ +export interface WrapServerFunctionOptions { + /** Custom span name. Defaults to `serverFunction/{functionName}` */ + name?: string; + /** Additional span attributes */ + attributes?: Record; +} diff --git a/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts b/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts new file mode 100644 index 000000000000..250243211760 --- /dev/null +++ b/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts @@ -0,0 +1,181 @@ +import { + captureException, + getActiveSpan, + getIsolationScope, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SPAN_STATUS_ERROR, + startSpan, +} from '@sentry/core'; +import { isErrorCaptured, markErrorAsCaptured } from './responseUtils'; +import type { MatchRSCServerRequestArgs, MatchRSCServerRequestFn, RSCMatch } from './types'; + +/** + * Wraps `unstable_matchRSCServerRequest` from react-router with Sentry error and performance instrumentation. + * @param originalFn - The original `unstable_matchRSCServerRequest` function from react-router + * + * @example + * ```ts + * import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router"; + * import { wrapMatchRSCServerRequest } from "@sentry/react-router"; + * + * const sentryMatchRSCServerRequest = wrapMatchRSCServerRequest(matchRSCServerRequest); + * ``` + */ +export function wrapMatchRSCServerRequest(originalFn: MatchRSCServerRequestFn): MatchRSCServerRequestFn { + return async function sentryWrappedMatchRSCServerRequest(args: MatchRSCServerRequestArgs): Promise { + const { request, generateResponse, loadServerAction, onError, ...rest } = args; + + // Set transaction name based on request URL + const url = new URL(request.url); + const isolationScope = getIsolationScope(); + isolationScope.setTransactionName(`RSC ${request.method} ${url.pathname}`); + + // Update root span attributes if available + const activeSpan = getActiveSpan(); + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + if (rootSpan) { + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc', + 'rsc.request': true, + }); + } + } + + // Wrapped generateResponse that captures errors and creates spans for RSC rendering + const wrappedGenerateResponse = ( + match: RSCMatch, + options: { temporaryReferences: unknown; onError?: (error: unknown) => string | undefined }, + ): Response => { + return startSpan( + { + name: 'RSC Render', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.render', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.rsc', + 'rsc.status_code': match.statusCode, + }, + }, + span => { + try { + // Wrap the inner onError to capture RSC stream errors. + const originalOnError = options.onError; + const wrappedInnerOnError = originalOnError + ? (error: unknown): string | undefined => { + // Only capture if not already captured + if (!isErrorCaptured(error)) { + markErrorAsCaptured(error); + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'generateResponse.onError', + }, + }, + }); + } + return originalOnError(error); + } + : undefined; + + const response = generateResponse(match, { + ...options, + onError: wrappedInnerOnError, + }); + + return response; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + // Capture errors thrown directly in generateResponse + if (!isErrorCaptured(error)) { + markErrorAsCaptured(error); + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'generateResponse', + }, + }, + }); + } + throw error; + } + }, + ); + }; + + // Wrapped loadServerAction that traces server function loading and execution + const wrappedLoadServerAction = loadServerAction + ? async (actionId: string): Promise => { + return startSpan( + { + name: `Server Action: ${actionId}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.server_action', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.rsc.server_action', + 'rsc.action.id': actionId, + }, + }, + async span => { + try { + const result = await loadServerAction(actionId); + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + if (!isErrorCaptured(error)) { + markErrorAsCaptured(error); + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'loadServerAction', + action_id: actionId, + }, + }, + }); + } + throw error; + } + }, + ); + } + : undefined; + + // Enhanced onError handler that captures RSC server errors not already captured by inner wrappers + const wrappedOnError = (error: unknown): void => { + // Only capture if not already captured by generateResponse or loadServerAction wrappers + if (!isErrorCaptured(error)) { + markErrorAsCaptured(error); + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'matchRSCServerRequest.onError', + }, + }, + }); + } + + // Call original onError if provided + if (onError) { + onError(error); + } + }; + + return originalFn({ + ...rest, + request, + generateResponse: wrappedGenerateResponse, + loadServerAction: wrappedLoadServerAction, + onError: wrappedOnError, + }); + }; +} diff --git a/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts b/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts new file mode 100644 index 000000000000..594f6e2a96aa --- /dev/null +++ b/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts @@ -0,0 +1,146 @@ +import { + captureException, + getActiveSpan, + getIsolationScope, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SPAN_STATUS_ERROR, + startSpan, +} from '@sentry/core'; +import { isErrorCaptured, markErrorAsCaptured } from './responseUtils'; +import type { DecodedPayload, RouteRSCServerRequestArgs, RouteRSCServerRequestFn, RSCPayload } from './types'; + +/** + * Wraps `unstable_routeRSCServerRequest` from react-router with Sentry error and performance instrumentation. + * @param originalFn - The original `unstable_routeRSCServerRequest` function from react-router + * + * @example + * ```ts + * import { unstable_routeRSCServerRequest as routeRSCServerRequest } from "react-router"; + * import { wrapRouteRSCServerRequest } from "@sentry/react-router"; + * + * const sentryRouteRSCServerRequest = wrapRouteRSCServerRequest(routeRSCServerRequest); + * ``` + */ +export function wrapRouteRSCServerRequest(originalFn: RouteRSCServerRequestFn): RouteRSCServerRequestFn { + return async function sentryWrappedRouteRSCServerRequest(args: RouteRSCServerRequestArgs): Promise { + const { request, renderHTML, fetchServer, ...rest } = args; + + // Set transaction name based on request URL + const url = new URL(request.url); + const isolationScope = getIsolationScope(); + isolationScope.setTransactionName(`RSC SSR ${request.method} ${url.pathname}`); + + // Update root span attributes if available + const activeSpan = getActiveSpan(); + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + if (rootSpan) { + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc.ssr', + 'rsc.ssr_request': true, + }); + } + } + + // Wrapped fetchServer that traces the RSC server fetch + const wrappedFetchServer = async (req: Request): Promise => { + return startSpan( + { + name: 'RSC Fetch Server', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client.rsc', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc.fetch', + }, + }, + async span => { + try { + const response = await fetchServer(req); + span.setAttributes({ + 'http.response.status_code': response.status, + }); + return response; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + if (!isErrorCaptured(error)) { + markErrorAsCaptured(error); + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'fetchServer', + }, + }, + }); + } + throw error; + } + }, + ); + }; + + // Wrapped renderHTML that traces the SSR rendering phase + const wrappedRenderHTML = ( + getPayload: () => DecodedPayload & Promise, + ): ReadableStream | Promise> => { + return startSpan( + { + name: 'RSC SSR Render HTML', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.ssr.render', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.rsc.ssr', + }, + }, + async span => { + try { + const result = await renderHTML(getPayload); + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + if (!isErrorCaptured(error)) { + markErrorAsCaptured(error); + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'renderHTML', + }, + }, + }); + } + throw error; + } + }, + ); + }; + + try { + return await originalFn({ + ...rest, + request, + fetchServer: wrappedFetchServer, + renderHTML: wrappedRenderHTML, + }); + } catch (error) { + // Only capture errors that weren't already captured by inner wrappers + if (!isErrorCaptured(error)) { + markErrorAsCaptured(error); + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'routeRSCServerRequest', + }, + }, + }); + } + throw error; + } + }; +} 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..6824dd022c08 --- /dev/null +++ b/packages/react-router/src/server/rsc/wrapServerComponent.ts @@ -0,0 +1,116 @@ +import { + captureException, + flushIfServerless, + getActiveSpan, + getIsolationScope, + handleCallbackErrors, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, +} from '@sentry/core'; +import { isNotFoundResponse, isRedirectResponse, safeFlushServerless } from './responseUtils'; +import type { ServerComponentContext } from './types'; + +/** + * Wraps a server component with Sentry error instrumentation. + * @param serverComponent - The server component function to wrap + * @param context - Context about the component for error reporting + * + * @example + * ```ts + * import { wrapServerComponent } from "@sentry/react-router"; + * + * async function _UserPage({ params }: Route.ComponentProps) { + * const user = await getUser(params.id); + * return ; + * } + * + * export const ServerComponent = 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; + + // Use a Proxy to wrap the function while preserving its properties + return new Proxy(serverComponent, { + apply: (originalFunction, thisArg, args) => { + const isolationScope = getIsolationScope(); + + // Set transaction name with component context + const transactionName = `${componentType} Server Component (${componentRoute})`; + isolationScope.setTransactionName(transactionName); + + return handleCallbackErrors( + () => originalFunction.apply(thisArg, args), + error => { + const span = getActiveSpan(); + let shouldCapture = true; + + // Check if error is a redirect response (3xx) + if (isRedirectResponse(error)) { + shouldCapture = false; + if (span) { + span.setStatus({ code: SPAN_STATUS_OK }); + } + } + // Check if error is a not-found response (404) + else if (isNotFoundResponse(error)) { + shouldCapture = false; + if (span) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + } + } + // Regular error + else { + if (span) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + } + } + + if (shouldCapture) { + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'ServerComponent', + component_route: componentRoute, + component_type: componentType, + }, + }, + }); + } + }, + () => { + // Fire-and-forget flush to avoid swallowing original errors + safeFlushServerless(flushIfServerless); + }, + ); + }, + }); +} + +const VALID_COMPONENT_TYPES = new Set(['Page', 'Layout', 'Loading', 'Error', 'Template', 'Not-found', 'Unknown']); + +/** + * Type guard to check if a value is a valid ServerComponentContext. + */ +export function isServerComponentContext(value: unknown): value is ServerComponentContext { + if (!value || typeof value !== 'object') { + return false; + } + + const obj = value as Record; + return ( + typeof obj.componentRoute === 'string' && + obj.componentRoute.length > 0 && + typeof obj.componentType === 'string' && + VALID_COMPONENT_TYPES.has(obj.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..85660a9dbe8f --- /dev/null +++ b/packages/react-router/src/server/rsc/wrapServerFunction.ts @@ -0,0 +1,150 @@ +import { + captureException, + flushIfServerless, + getActiveSpan, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startSpan, + withIsolationScope, +} from '@sentry/core'; +import { isRedirectResponse, safeFlushServerless } from './responseUtils'; +import type { WrapServerFunctionOptions } from './types'; + +/** + * Wraps a server function (marked with `"use server"` directive) with Sentry error and performance instrumentation. + * @param functionName - The name of the server function for identification in Sentry + * @param serverFunction - The server function to wrap + * @param options - Optional configuration for the span + * + * @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 { + const wrappedFunction = async function (this: unknown, ...args: Parameters): Promise> { + // Check for active span BEFORE entering isolation scope to maintain trace continuity + // withIsolationScope may reset span context, so we capture this first + const hasActiveSpan = !!getActiveSpan(); + + return withIsolationScope(async isolationScope => { + const spanName = options.name || `serverFunction/${functionName}`; + + // Set transaction name on isolation scope + isolationScope.setTransactionName(spanName); + + 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 serverFunction.apply(this, args); + return result; + } catch (error) { + // Check if the error is a redirect (common pattern in server functions) + if (isRedirectResponse(error)) { + // Don't capture redirects as errors, but still end the span + span.setStatus({ code: SPAN_STATUS_OK }); + throw error; + } + + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'serverFunction', + server_function_name: functionName, + }, + }, + }); + throw error; + } finally { + // Fire-and-forget flush to avoid swallowing original errors + safeFlushServerless(flushIfServerless); + } + }, + ); + }) as ReturnType; + }; + + // Preserve the function name for debugging + Object.defineProperty(wrappedFunction, 'name', { + value: `sentryWrapped_${functionName}`, + configurable: true, + }); + + return wrappedFunction as T; +} + +/** + * Creates a wrapped version of a server function module. + * Useful for wrapping all exported server functions from a module. + * + * @param moduleName - The name of the module for identification + * @param serverFunctions - An object containing server functions + * @returns An object with all functions wrapped + * + * @example + * ```typescript + * // actions.ts + * "use server"; + * import { wrapServerFunctions } from "@sentry/react-router"; + * + * async function createUser(data: FormData) { ... } + * async function updateUser(data: FormData) { ... } + * async function deleteUser(id: string) { ... } + * + * export const actions = wrapServerFunctions("userActions", { + * createUser, + * updateUser, + * deleteUser, + * }); + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function wrapServerFunctions Promise>>( + moduleName: string, + serverFunctions: T, +): T { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrapped: Record Promise> = {}; + + for (const [name, fn] of Object.entries(serverFunctions)) { + if (typeof fn === 'function') { + wrapped[name] = wrapServerFunction(`${moduleName}.${name}`, fn); + } else { + wrapped[name] = fn; + } + } + + return wrapped as T; +} 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..cc7069bea2b1 --- /dev/null +++ b/packages/react-router/test/server/rsc/responseUtils.test.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + isErrorCaptured, + isNotFoundResponse, + isRedirectResponse, + markErrorAsCaptured, + safeFlushServerless, +} from '../../../src/server/rsc/responseUtils'; + +describe('responseUtils', () => { + describe('isErrorCaptured / markErrorAsCaptured', () => { + it('should return false for uncaptured errors', () => { + const error = new Error('test'); + expect(isErrorCaptured(error)).toBe(false); + }); + + it('should return true for captured errors', () => { + const error = new Error('test'); + markErrorAsCaptured(error); + expect(isErrorCaptured(error)).toBe(true); + }); + + it('should handle null errors', () => { + expect(isErrorCaptured(null)).toBe(false); + // markErrorAsCaptured should not throw for null + expect(() => markErrorAsCaptured(null)).not.toThrow(); + }); + + it('should handle undefined errors', () => { + expect(isErrorCaptured(undefined)).toBe(false); + expect(() => markErrorAsCaptured(undefined)).not.toThrow(); + }); + + it('should handle primitive errors (strings)', () => { + // Primitives cannot be tracked by WeakSet + const error = 'string error'; + markErrorAsCaptured(error); + expect(isErrorCaptured(error)).toBe(false); + }); + + it('should handle primitive errors (numbers)', () => { + const error = 42; + markErrorAsCaptured(error); + expect(isErrorCaptured(error)).toBe(false); + }); + + it('should track different error objects independently', () => { + const error1 = new Error('error 1'); + const error2 = new Error('error 2'); + + markErrorAsCaptured(error1); + + expect(isErrorCaptured(error1)).toBe(true); + expect(isErrorCaptured(error2)).toBe(false); + }); + + it('should handle object errors', () => { + const error = { message: 'custom error', code: 500 }; + markErrorAsCaptured(error); + expect(isErrorCaptured(error)).toBe(true); + }); + }); + + describe('isRedirectResponse', () => { + it('should return true for Response with 301 status', () => { + const response = new Response(null, { status: 301 }); + expect(isRedirectResponse(response)).toBe(true); + }); + + it('should return true for Response with 302 status', () => { + const response = new Response(null, { status: 302 }); + expect(isRedirectResponse(response)).toBe(true); + }); + + it('should return true for Response with 303 status', () => { + const response = new Response(null, { status: 303 }); + expect(isRedirectResponse(response)).toBe(true); + }); + + it('should return true for Response with 307 status', () => { + const response = new Response(null, { status: 307 }); + expect(isRedirectResponse(response)).toBe(true); + }); + + it('should return true for Response with 308 status', () => { + const response = new Response(null, { status: 308 }); + expect(isRedirectResponse(response)).toBe(true); + }); + + it('should return false for Response with 200 status', () => { + const response = new Response(null, { status: 200 }); + expect(isRedirectResponse(response)).toBe(false); + }); + + it('should return false for Response with 404 status', () => { + const response = new Response(null, { status: 404 }); + expect(isRedirectResponse(response)).toBe(false); + }); + + it('should return false for Response with 500 status', () => { + const response = new Response(null, { status: 500 }); + expect(isRedirectResponse(response)).toBe(false); + }); + + it('should return true for object with redirect type', () => { + const error = { type: 'redirect', url: '/new-path' }; + expect(isRedirectResponse(error)).toBe(true); + }); + + it('should return true for object with status in 3xx range', () => { + const error = { status: 302, location: '/new-path' }; + expect(isRedirectResponse(error)).toBe(true); + }); + + it('should return true for object with statusCode in 3xx range', () => { + const error = { statusCode: 307, location: '/new-path' }; + expect(isRedirectResponse(error)).toBe(true); + }); + + it('should return false for null', () => { + expect(isRedirectResponse(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isRedirectResponse(undefined)).toBe(false); + }); + + it('should return false for primitive values', () => { + expect(isRedirectResponse('error')).toBe(false); + expect(isRedirectResponse(42)).toBe(false); + expect(isRedirectResponse(true)).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', () => { + const response = new Response(null, { status: 404 }); + expect(isNotFoundResponse(response)).toBe(true); + }); + + it('should return false for Response with 200 status', () => { + const response = new Response(null, { status: 200 }); + expect(isNotFoundResponse(response)).toBe(false); + }); + + it('should return false for Response with 500 status', () => { + const response = new Response(null, { status: 500 }); + expect(isNotFoundResponse(response)).toBe(false); + }); + + it('should return false for Response with 302 status', () => { + const response = new Response(null, { status: 302 }); + expect(isNotFoundResponse(response)).toBe(false); + }); + + it('should return true for object with not-found type', () => { + const error = { type: 'not-found' }; + expect(isNotFoundResponse(error)).toBe(true); + }); + + it('should return true for object with notFound type', () => { + const error = { type: 'notFound' }; + expect(isNotFoundResponse(error)).toBe(true); + }); + + it('should return true for object with status 404', () => { + const error = { status: 404 }; + expect(isNotFoundResponse(error)).toBe(true); + }); + + it('should return true for object with statusCode 404', () => { + const error = { statusCode: 404 }; + expect(isNotFoundResponse(error)).toBe(true); + }); + + it('should return false for null', () => { + expect(isNotFoundResponse(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isNotFoundResponse(undefined)).toBe(false); + }); + + it('should return false for primitive values', () => { + expect(isNotFoundResponse('error')).toBe(false); + expect(isNotFoundResponse(42)).toBe(false); + }); + }); + + describe('safeFlushServerless', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call the flush function', async () => { + const mockFlush = vi.fn().mockResolvedValue(undefined); + + safeFlushServerless(mockFlush); + + // Wait for the promise to resolve + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockFlush).toHaveBeenCalled(); + }); + + it('should not throw when flush succeeds', () => { + const mockFlush = vi.fn().mockResolvedValue(undefined); + + expect(() => safeFlushServerless(mockFlush)).not.toThrow(); + }); + + it('should not throw when flush fails', async () => { + const mockFlush = vi.fn().mockRejectedValue(new Error('Flush failed')); + + expect(() => safeFlushServerless(mockFlush)).not.toThrow(); + + // Wait for the promise to reject (should be caught internally) + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + it('should handle flush rejection gracefully', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockFlush = vi.fn().mockRejectedValue(new Error('Network error')); + + safeFlushServerless(mockFlush); + + // Wait for the promise to reject + await new Promise(resolve => setTimeout(resolve, 0)); + + // Should not throw, error is caught internally + expect(mockFlush).toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + }); +}); diff --git a/packages/react-router/test/server/rsc/wrapMatchRSCServerRequest.test.ts b/packages/react-router/test/server/rsc/wrapMatchRSCServerRequest.test.ts new file mode 100644 index 000000000000..2504f1bedb54 --- /dev/null +++ b/packages/react-router/test/server/rsc/wrapMatchRSCServerRequest.test.ts @@ -0,0 +1,325 @@ +import * as core from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MatchRSCServerRequestArgs, MatchRSCServerRequestFn, RSCMatch } from '../../../src/server/rsc/types'; +import { wrapMatchRSCServerRequest } from '../../../src/server/rsc/wrapMatchRSCServerRequest'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startSpan: vi.fn(), + captureException: vi.fn(), + getIsolationScope: vi.fn(), + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + }; +}); + +describe('wrapMatchRSCServerRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createMockArgs = (): MatchRSCServerRequestArgs => ({ + request: new Request('http://test.com/users/123'), + routes: [{ path: '/users/:id' }], + createTemporaryReferenceSet: () => ({}), + generateResponse: vi.fn().mockReturnValue(new Response('test')), + }); + + it('should wrap the original function and call it with modified args', async () => { + const mockResponse = new Response('rsc payload'); + const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockResolvedValue(mockResponse); + const mockArgs = createMockArgs(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); + const result = await wrappedFn(mockArgs); + + expect(result).toBe(mockResponse); + expect(mockOriginalFn).toHaveBeenCalledWith( + expect.objectContaining({ + request: mockArgs.request, + routes: mockArgs.routes, + }), + ); + expect(mockSetTransactionName).toHaveBeenCalledWith('RSC GET /users/123'); + }); + + it('should update root span attributes if active span exists', async () => { + const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockResolvedValue(new Response('test')); + const mockArgs = createMockArgs(); + const mockSetTransactionName = vi.fn(); + const mockSetAttributes = vi.fn(); + const mockRootSpan = { setAttributes: mockSetAttributes }; + const mockActiveSpan = {}; + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.getActiveSpan as any).mockReturnValue(mockActiveSpan); + (core.getRootSpan as any).mockReturnValue(mockRootSpan); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + expect(core.getRootSpan).toHaveBeenCalledWith(mockActiveSpan); + expect(mockSetAttributes).toHaveBeenCalledWith({ + [core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc', + 'rsc.request': true, + }); + }); + + it('should wrap generateResponse with a span and error capture', async () => { + const mockMatch: RSCMatch = { + payload: { data: 'test' }, + statusCode: 200, + headers: new Headers(), + }; + const mockGenerateResponse = vi.fn().mockReturnValue(new Response('generated')); + const mockArgs: MatchRSCServerRequestArgs = { + ...createMockArgs(), + generateResponse: mockGenerateResponse, + }; + + let capturedGenerateResponse: any; + const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { + capturedGenerateResponse = args.generateResponse; + return new Response('test'); + }); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((options: any, fn: any) => { + return fn({ setStatus: vi.fn() }); + }); + + const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + // Call the wrapped generateResponse + capturedGenerateResponse(mockMatch, { temporaryReferences: {} }); + + expect(mockGenerateResponse).toHaveBeenCalledWith(mockMatch, expect.objectContaining({ temporaryReferences: {} })); + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'RSC Render', + attributes: expect.objectContaining({ + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.render', + 'rsc.status_code': 200, + }), + }), + expect.any(Function), + ); + }); + + it('should capture errors from generateResponse and set span status', async () => { + const testError = new Error('generateResponse failed'); + const mockGenerateResponse = vi.fn().mockImplementation(() => { + throw testError; + }); + const mockMatch: RSCMatch = { + payload: { data: 'test' }, + statusCode: 200, + headers: new Headers(), + }; + const mockArgs: MatchRSCServerRequestArgs = { + ...createMockArgs(), + generateResponse: mockGenerateResponse, + }; + + let capturedGenerateResponse: any; + const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { + capturedGenerateResponse = args.generateResponse; + return new Response('test'); + }); + + const mockSetStatus = vi.fn(); + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus })); + + const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + // Call the wrapped generateResponse and expect it to throw + expect(() => capturedGenerateResponse(mockMatch, { temporaryReferences: {} })).toThrow('generateResponse failed'); + + // Span status should be set to error + expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); + + // Error is captured in generateResponse catch block with error tracking to prevent double-capture + expect(core.captureException).toHaveBeenCalledWith(testError, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'generateResponse', + }, + }, + }); + }); + + it('should wrap loadServerAction with a span', async () => { + const mockServerAction = vi.fn(); + const mockLoadServerAction = vi.fn().mockResolvedValue(mockServerAction); + const mockArgs: MatchRSCServerRequestArgs = { + ...createMockArgs(), + loadServerAction: mockLoadServerAction, + }; + + let capturedLoadServerAction: any; + const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { + capturedLoadServerAction = args.loadServerAction; + return new Response('test'); + }); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + // Call the wrapped loadServerAction + const result = await capturedLoadServerAction('my-action-id'); + + expect(result).toBe(mockServerAction); + expect(mockLoadServerAction).toHaveBeenCalledWith('my-action-id'); + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Server Action: my-action-id', + attributes: expect.objectContaining({ + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.server_action', + 'rsc.action.id': 'my-action-id', + }), + }), + expect.any(Function), + ); + }); + + it('should capture errors from loadServerAction with action_id', async () => { + const mockError = new Error('loadServerAction failed'); + const mockLoadServerAction = vi.fn().mockRejectedValue(mockError); + const mockArgs: MatchRSCServerRequestArgs = { + ...createMockArgs(), + loadServerAction: mockLoadServerAction, + }; + + let capturedLoadServerAction: any; + const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { + capturedLoadServerAction = args.loadServerAction; + return new Response('test'); + }); + + const mockSetStatus = vi.fn(); + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus })); + + const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + // Call the wrapped loadServerAction and expect it to reject + await expect(capturedLoadServerAction('action-id')).rejects.toThrow('loadServerAction failed'); + + expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'loadServerAction', + action_id: 'action-id', + }, + }, + }); + }); + + it('should enhance onError callback', async () => { + const originalOnError = vi.fn(); + const mockArgs: MatchRSCServerRequestArgs = { + ...createMockArgs(), + onError: originalOnError, + }; + + let capturedOnError: any; + const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { + capturedOnError = args.onError; + return new Response('test'); + }); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + // Call the enhanced onError + const testError = new Error('test error'); + capturedOnError(testError); + + expect(originalOnError).toHaveBeenCalledWith(testError); + expect(core.captureException).toHaveBeenCalledWith(testError, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'matchRSCServerRequest.onError', + }, + }, + }); + }); + + it('should create onError handler even if not provided in args', async () => { + const mockArgs = createMockArgs(); + // Ensure no onError is provided + delete (mockArgs as any).onError; + + let capturedOnError: any; + const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { + capturedOnError = args.onError; + return new Response('test'); + }); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + // onError should be created by the wrapper + expect(capturedOnError).toBeDefined(); + + // Calling it should capture the exception + const testError = new Error('test error'); + capturedOnError(testError); + expect(core.captureException).toHaveBeenCalledWith(testError, expect.any(Object)); + }); + + it('should not create loadServerAction wrapper if not provided', async () => { + const mockArgs = createMockArgs(); + delete (mockArgs as any).loadServerAction; + + let capturedArgs: any; + const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { + capturedArgs = args; + return new Response('test'); + }); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + expect(capturedArgs.loadServerAction).toBeUndefined(); + }); +}); diff --git a/packages/react-router/test/server/rsc/wrapRouteRSCServerRequest.test.ts b/packages/react-router/test/server/rsc/wrapRouteRSCServerRequest.test.ts new file mode 100644 index 000000000000..66a3af9553c9 --- /dev/null +++ b/packages/react-router/test/server/rsc/wrapRouteRSCServerRequest.test.ts @@ -0,0 +1,303 @@ +import * as core from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RouteRSCServerRequestArgs, RouteRSCServerRequestFn } from '../../../src/server/rsc/types'; +import { wrapRouteRSCServerRequest } from '../../../src/server/rsc/wrapRouteRSCServerRequest'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startSpan: vi.fn(), + captureException: vi.fn(), + getIsolationScope: vi.fn(), + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + }; +}); + +describe('wrapRouteRSCServerRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createMockArgs = (): RouteRSCServerRequestArgs => ({ + request: new Request('http://test.com/users/123'), + fetchServer: vi.fn().mockResolvedValue(new Response('server response')), + createFromReadableStream: vi.fn().mockResolvedValue({ data: 'decoded' }), + renderHTML: vi.fn().mockReturnValue(new ReadableStream()), + }); + + it('should wrap the original function and call it with modified args', async () => { + const mockResponse = new Response('html'); + const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockResolvedValue(mockResponse); + const mockArgs = createMockArgs(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), setAttributes: vi.fn() })); + + const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); + const result = await wrappedFn(mockArgs); + + expect(result).toBe(mockResponse); + expect(mockOriginalFn).toHaveBeenCalledWith( + expect.objectContaining({ + request: mockArgs.request, + }), + ); + expect(mockSetTransactionName).toHaveBeenCalledWith('RSC SSR GET /users/123'); + }); + + it('should update root span attributes if active span exists', async () => { + const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockResolvedValue(new Response('html')); + const mockArgs = createMockArgs(); + const mockSetTransactionName = vi.fn(); + const mockSetAttributes = vi.fn(); + const mockRootSpan = { setAttributes: mockSetAttributes }; + const mockActiveSpan = {}; + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.getActiveSpan as any).mockReturnValue(mockActiveSpan); + (core.getRootSpan as any).mockReturnValue(mockRootSpan); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), setAttributes: vi.fn() })); + + const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + expect(core.getRootSpan).toHaveBeenCalledWith(mockActiveSpan); + expect(mockSetAttributes).toHaveBeenCalledWith({ + [core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc.ssr', + 'rsc.ssr_request': true, + }); + }); + + it('should wrap fetchServer with span and error capture', async () => { + const mockServerResponse = new Response('server response'); + const mockFetchServer = vi.fn().mockResolvedValue(mockServerResponse); + const mockArgs: RouteRSCServerRequestArgs = { + ...createMockArgs(), + fetchServer: mockFetchServer, + }; + + let capturedFetchServer: any; + const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockImplementation(async args => { + capturedFetchServer = args.fetchServer; + return new Response('html'); + }); + + const startSpanCalls: any[] = []; + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((options: any, fn: any) => { + startSpanCalls.push(options); + return fn({ setStatus: vi.fn(), setAttributes: vi.fn() }); + }); + + const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + // Call the wrapped fetchServer + const fetchRequest = new Request('http://test.com/api'); + const result = await capturedFetchServer(fetchRequest); + + expect(result).toBe(mockServerResponse); + expect(mockFetchServer).toHaveBeenCalledWith(fetchRequest); + + // Check that a span was created for fetchServer + const fetchServerSpan = startSpanCalls.find(call => call.name === 'RSC Fetch Server'); + expect(fetchServerSpan).toBeDefined(); + expect(fetchServerSpan.attributes).toEqual( + expect.objectContaining({ + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client.rsc', + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc.fetch', + }), + ); + }); + + it('should capture errors from fetchServer', async () => { + const mockError = new Error('fetchServer failed'); + const mockFetchServer = vi.fn().mockRejectedValue(mockError); + const mockArgs: RouteRSCServerRequestArgs = { + ...createMockArgs(), + fetchServer: mockFetchServer, + }; + + let capturedFetchServer: any; + const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockImplementation(async args => { + capturedFetchServer = args.fetchServer; + return new Response('html'); + }); + + const mockSetStatus = vi.fn(); + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_: any, fn: any) => + fn({ setStatus: mockSetStatus, setAttributes: vi.fn() }), + ); + + const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + // Call the wrapped fetchServer and expect it to reject + const fetchRequest = new Request('http://test.com/api'); + await expect(capturedFetchServer(fetchRequest)).rejects.toThrow('fetchServer failed'); + + expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'fetchServer', + }, + }, + }); + }); + + it('should wrap renderHTML with span', async () => { + const mockStream = new ReadableStream(); + const mockRenderHTML = vi.fn().mockResolvedValue(mockStream); + const mockArgs: RouteRSCServerRequestArgs = { + ...createMockArgs(), + renderHTML: mockRenderHTML, + }; + + let capturedRenderHTML: any; + const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockImplementation(async args => { + capturedRenderHTML = args.renderHTML; + return new Response('html'); + }); + + const startSpanCalls: any[] = []; + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((options: any, fn: any) => { + startSpanCalls.push(options); + return fn({ setStatus: vi.fn(), setAttributes: vi.fn() }); + }); + + const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + // Call the wrapped renderHTML + const getPayload = () => ({ formState: Promise.resolve(null) }); + const result = await capturedRenderHTML(getPayload); + + expect(result).toBe(mockStream); + expect(mockRenderHTML).toHaveBeenCalledWith(getPayload); + + // Check that a span was created for renderHTML + const renderHTMLSpan = startSpanCalls.find(call => call.name === 'RSC SSR Render HTML'); + expect(renderHTMLSpan).toBeDefined(); + expect(renderHTMLSpan.attributes).toEqual( + expect.objectContaining({ + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.ssr.render', + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.rsc.ssr', + }), + ); + }); + + it('should capture errors from renderHTML', async () => { + const mockError = new Error('renderHTML failed'); + const mockRenderHTML = vi.fn().mockRejectedValue(mockError); + const mockArgs: RouteRSCServerRequestArgs = { + ...createMockArgs(), + renderHTML: mockRenderHTML, + }; + + let capturedRenderHTML: any; + const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockImplementation(async args => { + capturedRenderHTML = args.renderHTML; + return new Response('html'); + }); + + const mockSetStatus = vi.fn(); + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_: any, fn: any) => + fn({ setStatus: mockSetStatus, setAttributes: vi.fn() }), + ); + + const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + // Call the wrapped renderHTML and expect it to reject + const getPayload = () => ({ formState: Promise.resolve(null) }); + await expect(capturedRenderHTML(getPayload)).rejects.toThrow('renderHTML failed'); + + expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'renderHTML', + }, + }, + }); + }); + + it('should capture uncaptured exceptions from the original function', async () => { + // Errors from fetchServer/renderHTML are captured in their wrappers and marked as captured. + // The outer try-catch captures any errors not already marked, preventing blind spots + // while avoiding double-capture. + const mockError = new Error('Original function failed'); + const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockRejectedValue(mockError); + const mockArgs = createMockArgs(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), setAttributes: vi.fn() })); + + const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); + + // Error should propagate + await expect(wrappedFn(mockArgs)).rejects.toThrow('Original function failed'); + + // Error is captured by outer try-catch since it wasn't already captured by inner wrappers + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'routeRSCServerRequest', + }, + }, + }); + }); + + it('should set response status code attribute on fetchServer span', async () => { + const mockServerResponse = new Response('ok', { status: 200 }); + const mockFetchServer = vi.fn().mockResolvedValue(mockServerResponse); + const mockArgs: RouteRSCServerRequestArgs = { + ...createMockArgs(), + fetchServer: mockFetchServer, + }; + + let capturedFetchServer: any; + const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockImplementation(async args => { + capturedFetchServer = args.fetchServer; + return new Response('html'); + }); + + const mockSetAttributes = vi.fn(); + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_: any, fn: any) => + fn({ setStatus: vi.fn(), setAttributes: mockSetAttributes }), + ); + + const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); + await wrappedFn(mockArgs); + + // Call the wrapped fetchServer + const fetchRequest = new Request('http://test.com/api'); + await capturedFetchServer(fetchRequest); + + expect(mockSetAttributes).toHaveBeenCalledWith({ + 'http.response.status_code': 200, + }); + }); +}); 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..fe9055a032e9 --- /dev/null +++ b/packages/react-router/test/server/rsc/wrapServerComponent.test.ts @@ -0,0 +1,375 @@ +import * as core from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { isServerComponentContext, 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(), + handleCallbackErrors: vi.fn(), + captureException: vi.fn(), + flushIfServerless: vi.fn().mockResolvedValue(undefined), + SPAN_STATUS_OK: { code: 1, message: 'ok' }, + SPAN_STATUS_ERROR: { code: 2, message: 'internal_error' }, + }; +}); + +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, + }); + (core.handleCallbackErrors as any).mockImplementation((fn: any) => fn()); + + 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 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 }); + (core.handleCallbackErrors as any).mockImplementation((fn: any, errorHandler: any, finallyHandler: any) => { + try { + return fn(); + } catch (error) { + errorHandler(error); + throw error; + } finally { + finallyHandler?.(); + } + }); + + 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: 'instrument', + handled: false, + data: { + function: 'ServerComponent', + component_route: '/users/:id', + 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 }); + (core.handleCallbackErrors as any).mockImplementation((fn: any, errorHandler: any, finallyHandler: any) => { + try { + return fn(); + } catch (error) { + errorHandler(error); + throw error; + } finally { + finallyHandler?.(); + } + }); + + 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 }); + (core.handleCallbackErrors as any).mockImplementation((fn: any, errorHandler: any, finallyHandler: any) => { + try { + return fn(); + } catch (error) { + errorHandler(error); + throw error; + } finally { + finallyHandler?.(); + } + }); + + 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 handle redirect-like objects with type property', () => { + const redirectObj = { type: 'redirect', location: '/new-path' }; + const mockComponent = vi.fn().mockImplementation(() => { + throw redirectObj; + }); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); + (core.handleCallbackErrors as any).mockImplementation((fn: any, errorHandler: any, finallyHandler: any) => { + try { + return fn(); + } catch (error) { + errorHandler(error); + throw error; + } finally { + finallyHandler?.(); + } + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/users/:id', + componentType: 'Layout', + }); + + expect(() => wrappedComponent()).toThrow(); + expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_OK }); + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should handle not-found objects with type property', () => { + const notFoundObj = { type: 'not-found' }; + const mockComponent = vi.fn().mockImplementation(() => { + throw notFoundObj; + }); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); + (core.handleCallbackErrors as any).mockImplementation((fn: any, errorHandler: any, finallyHandler: any) => { + try { + return fn(); + } catch (error) { + errorHandler(error); + throw error; + } finally { + finallyHandler?.(); + } + }); + + 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, + }); + (core.handleCallbackErrors as any).mockImplementation((fn: any) => fn()); + + 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 flush on completion for serverless environments', () => { + const mockComponent = vi.fn().mockReturnValue({ type: 'div' }); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.handleCallbackErrors as any).mockImplementation((fn: any, _: any, finallyHandler: any) => { + const result = fn(); + finallyHandler?.(); + return result; + }); + + 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); + (core.handleCallbackErrors as any).mockImplementation((fn: any, errorHandler: any, finallyHandler: any) => { + try { + return fn(); + } catch (error) { + errorHandler(error); + throw error; + } finally { + finallyHandler?.(); + } + }); + + 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, + }); + (core.handleCallbackErrors as any).mockImplementation((fn: any) => fn()); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/page', + componentType: 'Page', + }); + + // Proxy should preserve properties + expect((wrappedComponent as any).displayName).toBe('MyComponent'); + expect((wrappedComponent as any).customProp).toBe('value'); + }); +}); + +describe('isServerComponentContext', () => { + it('should return true for valid context', () => { + expect( + isServerComponentContext({ + componentRoute: '/users/:id', + componentType: 'Page', + }), + ).toBe(true); + }); + + it('should return false for null', () => { + expect(isServerComponentContext(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isServerComponentContext(undefined)).toBe(false); + }); + + it('should return false for non-object', () => { + expect(isServerComponentContext('string')).toBe(false); + expect(isServerComponentContext(123)).toBe(false); + }); + + it('should return false for missing componentRoute', () => { + expect( + isServerComponentContext({ + componentType: 'Page', + }), + ).toBe(false); + }); + + it('should return false for missing componentType', () => { + expect( + isServerComponentContext({ + componentRoute: '/users/:id', + }), + ).toBe(false); + }); + + it('should return false for non-string componentRoute', () => { + expect( + isServerComponentContext({ + componentRoute: 123, + componentType: 'Page', + }), + ).toBe(false); + }); + + it('should return false for non-string componentType', () => { + expect( + isServerComponentContext({ + componentRoute: '/users/:id', + componentType: 123, + }), + ).toBe(false); + }); +}); 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..bdc3db841b89 --- /dev/null +++ b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts @@ -0,0 +1,214 @@ +import * as core from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapServerFunction, wrapServerFunctions } from '../../../src/server/rsc/wrapServerFunction'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startSpan: vi.fn(), + withIsolationScope: 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.withIsolationScope as any).mockImplementation(async (fn: any) => { + return fn({ 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.withIsolationScope).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 use custom span name when provided', async () => { + const mockServerFn = vi.fn().mockResolvedValue('result'); + const mockSetTransactionName = vi.fn(); + + (core.withIsolationScope as any).mockImplementation(async (fn: any) => { + return fn({ 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.withIsolationScope as any).mockImplementation(async (fn: any) => { + return fn({ 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.withIsolationScope as any).mockImplementation(async (fn: any) => { + return fn({ 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: 'instrument', + 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.withIsolationScope as any).mockImplementation(async (fn: any) => { + return fn({ 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 preserve function name', () => { + const mockServerFn = vi.fn().mockResolvedValue('result'); + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + + expect(wrappedFn.name).toBe('sentryWrapped_testFunction'); + }); + + it('should propagate errors after capturing', async () => { + const mockError = new Error('Test error'); + const mockServerFn = vi.fn().mockRejectedValue(mockError); + const mockSetTransactionName = vi.fn(); + + (core.withIsolationScope as any).mockImplementation(async (fn: any) => { + return fn({ 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); + }); +}); + +describe('wrapServerFunctions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should wrap all functions in an object', async () => { + const mockFn1 = vi.fn().mockResolvedValue('result1'); + const mockFn2 = vi.fn().mockResolvedValue('result2'); + const mockSetTransactionName = vi.fn(); + + (core.withIsolationScope as any).mockImplementation(async (fn: any) => { + return fn({ setTransactionName: mockSetTransactionName }); + }); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); + + const wrapped = wrapServerFunctions('myModule', { + fn1: mockFn1, + fn2: mockFn2, + }); + + await wrapped.fn1(); + await wrapped.fn2(); + + expect(mockFn1).toHaveBeenCalled(); + expect(mockFn2).toHaveBeenCalled(); + expect(mockSetTransactionName).toHaveBeenCalledWith('serverFunction/myModule.fn1'); + expect(mockSetTransactionName).toHaveBeenCalledWith('serverFunction/myModule.fn2'); + }); + + it('should skip non-function values', () => { + const mockFn = vi.fn().mockResolvedValue('result'); + + const wrapped = wrapServerFunctions('myModule', { + fn: mockFn, + notAFunction: 'string value' as any, + }); + + expect(typeof wrapped.fn).toBe('function'); + expect(wrapped.notAFunction).toBe('string value'); + }); +}); From c2063ff7edfeb8a3371b7b33b8d669a878ab0dd5 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 20 Jan 2026 13:30:54 +0000 Subject: [PATCH 02/13] Fix RSC wrapper error handling and enable optional E2E tests --- .../react-router-7-rsc/package.json | 4 +- .../src/server/rsc/wrapServerComponent.ts | 12 ++- .../src/server/rsc/wrapServerFunction.ts | 81 ++++++++++--------- .../server/rsc/wrapServerComponent.test.ts | 18 +++++ .../server/rsc/wrapServerFunction.test.ts | 32 +++----- 5 files changed, 81 insertions(+), 66 deletions(-) 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 index 96ef67858e40..048a9c0edb7d 100644 --- 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 @@ -53,8 +53,8 @@ "extends": "../../package.json" }, "sentryTest": { - "skip": true, - "variants": [ + "optional": true, + "optionalVariants": [ { "build-command": "pnpm test:build-latest", "label": "react-router-7-rsc (latest)" diff --git a/packages/react-router/src/server/rsc/wrapServerComponent.ts b/packages/react-router/src/server/rsc/wrapServerComponent.ts index 6824dd022c08..bbd3ab1254f5 100644 --- a/packages/react-router/src/server/rsc/wrapServerComponent.ts +++ b/packages/react-router/src/server/rsc/wrapServerComponent.ts @@ -7,7 +7,13 @@ import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, } from '@sentry/core'; -import { isNotFoundResponse, isRedirectResponse, safeFlushServerless } from './responseUtils'; +import { + isErrorCaptured, + isNotFoundResponse, + isRedirectResponse, + markErrorAsCaptured, + safeFlushServerless, +} from './responseUtils'; import type { ServerComponentContext } from './types'; /** @@ -73,7 +79,9 @@ export function wrapServerComponent any>( } } - if (shouldCapture) { + // Only capture if not already captured by other wrappers to prevent double-capture + if (shouldCapture && !isErrorCaptured(error)) { + markErrorAsCaptured(error); captureException(error, { mechanism: { type: 'instrument', diff --git a/packages/react-router/src/server/rsc/wrapServerFunction.ts b/packages/react-router/src/server/rsc/wrapServerFunction.ts index 85660a9dbe8f..29ae58b3ccca 100644 --- a/packages/react-router/src/server/rsc/wrapServerFunction.ts +++ b/packages/react-router/src/server/rsc/wrapServerFunction.ts @@ -2,15 +2,15 @@ import { captureException, flushIfServerless, getActiveSpan, + getIsolationScope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, SPAN_STATUS_ERROR, SPAN_STATUS_OK, startSpan, - withIsolationScope, } from '@sentry/core'; -import { isRedirectResponse, safeFlushServerless } from './responseUtils'; +import { isErrorCaptured, isRedirectResponse, markErrorAsCaptured, safeFlushServerless } from './responseUtils'; import type { WrapServerFunctionOptions } from './types'; /** @@ -41,41 +41,44 @@ export function wrapServerFunction Promise>( options: WrapServerFunctionOptions = {}, ): T { const wrappedFunction = async function (this: unknown, ...args: Parameters): Promise> { - // Check for active span BEFORE entering isolation scope to maintain trace continuity - // withIsolationScope may reset span context, so we capture this first - const hasActiveSpan = !!getActiveSpan(); + const spanName = options.name || `serverFunction/${functionName}`; - return withIsolationScope(async isolationScope => { - const spanName = options.name || `serverFunction/${functionName}`; + // Set transaction name on isolation scope (consistent with other RSC wrappers) + const isolationScope = getIsolationScope(); + isolationScope.setTransactionName(spanName); - // Set transaction name on isolation scope - isolationScope.setTransactionName(spanName); + // Check for active span to determine if this should be a new transaction or child span + 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, - }, + 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 serverFunction.apply(this, args); - return result; - } catch (error) { - // Check if the error is a redirect (common pattern in server functions) - if (isRedirectResponse(error)) { - // Don't capture redirects as errors, but still end the span - span.setStatus({ code: SPAN_STATUS_OK }); - throw error; - } + }, + async span => { + try { + const result = await serverFunction.apply(this, args); + return result; + } catch (error) { + // Check if the error is a redirect (common pattern in server functions) + if (isRedirectResponse(error)) { + // Don't capture redirects as errors, but still end the span + span.setStatus({ code: SPAN_STATUS_OK }); + throw error; + } - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + + // Only capture if not already captured (error may bubble through nested server functions or components) + if (!isErrorCaptured(error)) { + markErrorAsCaptured(error); captureException(error, { mechanism: { type: 'instrument', @@ -86,14 +89,14 @@ export function wrapServerFunction Promise>( }, }, }); - throw error; - } finally { - // Fire-and-forget flush to avoid swallowing original errors - safeFlushServerless(flushIfServerless); } - }, - ); - }) as ReturnType; + throw error; + } finally { + // Fire-and-forget flush to avoid swallowing original errors + safeFlushServerless(flushIfServerless); + } + }, + ); }; // Preserve the function name for debugging diff --git a/packages/react-router/test/server/rsc/wrapServerComponent.test.ts b/packages/react-router/test/server/rsc/wrapServerComponent.test.ts index fe9055a032e9..40c803f10651 100644 --- a/packages/react-router/test/server/rsc/wrapServerComponent.test.ts +++ b/packages/react-router/test/server/rsc/wrapServerComponent.test.ts @@ -372,4 +372,22 @@ describe('isServerComponentContext', () => { }), ).toBe(false); }); + + it('should return false for empty componentRoute', () => { + expect( + isServerComponentContext({ + componentRoute: '', + componentType: 'Page', + }), + ).toBe(false); + }); + + it('should return false for invalid componentType not in VALID_COMPONENT_TYPES', () => { + expect( + isServerComponentContext({ + componentRoute: '/users/:id', + componentType: 'InvalidType', + }), + ).toBe(false); + }); }); diff --git a/packages/react-router/test/server/rsc/wrapServerFunction.test.ts b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts index bdc3db841b89..6705d2aa3008 100644 --- a/packages/react-router/test/server/rsc/wrapServerFunction.test.ts +++ b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts @@ -7,7 +7,7 @@ vi.mock('@sentry/core', async () => { return { ...actual, startSpan: vi.fn(), - withIsolationScope: vi.fn(), + getIsolationScope: vi.fn(), captureException: vi.fn(), flushIfServerless: vi.fn().mockResolvedValue(undefined), getActiveSpan: vi.fn(), @@ -24,9 +24,7 @@ describe('wrapServerFunction', () => { const mockServerFn = vi.fn().mockResolvedValue(mockResult); const mockSetTransactionName = vi.fn(); - (core.withIsolationScope as any).mockImplementation(async (fn: any) => { - return fn({ setTransactionName: mockSetTransactionName }); - }); + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); const wrappedFn = wrapServerFunction('testFunction', mockServerFn); @@ -34,7 +32,7 @@ describe('wrapServerFunction', () => { expect(result).toEqual(mockResult); expect(mockServerFn).toHaveBeenCalledWith('arg1', 'arg2'); - expect(core.withIsolationScope).toHaveBeenCalled(); + expect(core.getIsolationScope).toHaveBeenCalled(); expect(mockSetTransactionName).toHaveBeenCalledWith('serverFunction/testFunction'); expect(core.startSpan).toHaveBeenCalledWith( expect.objectContaining({ @@ -55,9 +53,7 @@ describe('wrapServerFunction', () => { const mockServerFn = vi.fn().mockResolvedValue('result'); const mockSetTransactionName = vi.fn(); - (core.withIsolationScope as any).mockImplementation(async (fn: any) => { - return fn({ setTransactionName: mockSetTransactionName }); - }); + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); const wrappedFn = wrapServerFunction('testFunction', mockServerFn, { @@ -78,9 +74,7 @@ describe('wrapServerFunction', () => { const mockServerFn = vi.fn().mockResolvedValue('result'); const mockSetTransactionName = vi.fn(); - (core.withIsolationScope as any).mockImplementation(async (fn: any) => { - return fn({ setTransactionName: mockSetTransactionName }); - }); + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); const wrappedFn = wrapServerFunction('testFunction', mockServerFn, { @@ -105,9 +99,7 @@ describe('wrapServerFunction', () => { const mockSetStatus = vi.fn(); const mockSetTransactionName = vi.fn(); - (core.withIsolationScope as any).mockImplementation(async (fn: any) => { - return fn({ setTransactionName: mockSetTransactionName }); - }); + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus })); const wrappedFn = wrapServerFunction('testFunction', mockServerFn); @@ -136,9 +128,7 @@ describe('wrapServerFunction', () => { const mockSetStatus = vi.fn(); const mockSetTransactionName = vi.fn(); - (core.withIsolationScope as any).mockImplementation(async (fn: any) => { - return fn({ setTransactionName: mockSetTransactionName }); - }); + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus })); const wrappedFn = wrapServerFunction('testFunction', mockServerFn); @@ -160,9 +150,7 @@ describe('wrapServerFunction', () => { const mockServerFn = vi.fn().mockRejectedValue(mockError); const mockSetTransactionName = vi.fn(); - (core.withIsolationScope as any).mockImplementation(async (fn: any) => { - return fn({ setTransactionName: mockSetTransactionName }); - }); + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); const wrappedFn = wrapServerFunction('testFunction', mockServerFn); @@ -181,9 +169,7 @@ describe('wrapServerFunctions', () => { const mockFn2 = vi.fn().mockResolvedValue('result2'); const mockSetTransactionName = vi.fn(); - (core.withIsolationScope as any).mockImplementation(async (fn: any) => { - return fn({ setTransactionName: mockSetTransactionName }); - }); + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); const wrapped = wrapServerFunctions('myModule', { From c2c79260a456f04089b35d3eb1b1de490446f6aa Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 20 Jan 2026 14:16:17 +0000 Subject: [PATCH 03/13] Add experimental flags --- .../src/server/rsc/wrapMatchRSCServerRequest.ts | 4 ++++ .../src/server/rsc/wrapRouteRSCServerRequest.ts | 4 ++++ .../react-router/src/server/rsc/wrapServerComponent.ts | 4 ++++ packages/react-router/src/server/rsc/wrapServerFunction.ts | 7 +++++++ 4 files changed, 19 insertions(+) diff --git a/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts b/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts index 250243211760..3c03267d3ea9 100644 --- a/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts +++ b/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts @@ -14,6 +14,10 @@ import type { MatchRSCServerRequestArgs, MatchRSCServerRequestFn, RSCMatch } fro /** * Wraps `unstable_matchRSCServerRequest` from react-router 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()`. + * * @param originalFn - The original `unstable_matchRSCServerRequest` function from react-router * * @example diff --git a/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts b/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts index 594f6e2a96aa..6ece1c08590d 100644 --- a/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts +++ b/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts @@ -14,6 +14,10 @@ import type { DecodedPayload, RouteRSCServerRequestArgs, RouteRSCServerRequestFn /** * Wraps `unstable_routeRSCServerRequest` from react-router 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()`. + * * @param originalFn - The original `unstable_routeRSCServerRequest` function from react-router * * @example diff --git a/packages/react-router/src/server/rsc/wrapServerComponent.ts b/packages/react-router/src/server/rsc/wrapServerComponent.ts index bbd3ab1254f5..1cc4ed946b4c 100644 --- a/packages/react-router/src/server/rsc/wrapServerComponent.ts +++ b/packages/react-router/src/server/rsc/wrapServerComponent.ts @@ -18,6 +18,10 @@ 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()`. + * * @param serverComponent - The server component function to wrap * @param context - Context about the component for error reporting * diff --git a/packages/react-router/src/server/rsc/wrapServerFunction.ts b/packages/react-router/src/server/rsc/wrapServerFunction.ts index 29ae58b3ccca..e82c85d1e14f 100644 --- a/packages/react-router/src/server/rsc/wrapServerFunction.ts +++ b/packages/react-router/src/server/rsc/wrapServerFunction.ts @@ -15,6 +15,10 @@ 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()`. + * * @param functionName - The name of the server function for identification in Sentry * @param serverFunction - The server function to wrap * @param options - Optional configuration for the span @@ -112,6 +116,9 @@ export function wrapServerFunction Promise>( * Creates a wrapped version of a server function module. * Useful for wrapping all exported server functions from a module. * + * @experimental This API is experimental and may change in minor releases. + * React Router RSC support requires React Router v7.9.0+ with `unstable_reactRouterRSC()`. + * * @param moduleName - The name of the module for identification * @param serverFunctions - An object containing server functions * @returns An object with all functions wrapped From d65edf6317cf94f368df086c3227b04c2c3bd219 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 22 Jan 2026 17:18:44 +0000 Subject: [PATCH 04/13] Add client passthrough stubs for RSC wrappers and update E2E test app for RSC mode --- .../react-router-7-rsc/app/entry.client.tsx | 23 ---- .../react-router-7-rsc/app/root.tsx | 6 +- .../app/routes/rsc/server-component-async.tsx | 18 +-- .../app/routes/rsc/server-component-error.tsx | 10 +- .../app/routes/rsc/server-component-param.tsx | 12 +- .../app/routes/rsc/server-component.tsx | 19 +-- .../react-router-7-rsc/app/sentry-client.tsx | 31 +++++ .../react-router-7-rsc/package.json | 16 +-- .../performance/performance.server.test.ts | 60 +++++----- .../tests/rsc/server-component.test.ts | 109 +++++++++++++++--- .../tests/rsc/server-function.test.ts | 37 ++++-- .../react-router-7-rsc/vite.config.ts | 13 +++ packages/react-router/src/client/index.ts | 34 ++++++ packages/react-router/src/index.types.ts | 4 + 14 files changed, 258 insertions(+), 134 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/sentry-client.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.client.tsx deleted file mode 100644 index cc7961fb46ed..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.client.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import * as Sentry from '@sentry/react-router'; -import { StrictMode, startTransition } from 'react'; -import { hydrateRoot } from 'react-dom/client'; -import { HydratedRouter } from 'react-router/dom'; - -Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: 'https://username@domain/123', - tunnel: `http://localhost:3031/`, // proxy server - integrations: [Sentry.reactRouterTracingIntegration()], - tracesSampleRate: 1.0, - tracePropagationTargets: [/^\//], - debug: true, -}); - -startTransition(() => { - hydrateRoot( - document, - - - , - ); -}); 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 index 3bd1d38d8ffa..468cb79fc6f5 100644 --- 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 @@ -1,7 +1,8 @@ import * as Sentry from '@sentry/react-router'; -import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from '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 }]; @@ -15,9 +16,10 @@ export function Layout({ children }: { children: React.ReactNode }) { + {children} - + {/* is not needed in RSC mode - scripts are injected by the RSC framework */} ); 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 index 6606aea631bf..bc96a16c4a66 100644 --- 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 @@ -22,23 +22,13 @@ async function _AsyncServerComponent(_props: Route.ComponentProps) { ); } -export const ServerComponent = wrapServerComponent(_AsyncServerComponent, { - componentRoute: '/rsc/server-component-async', - componentType: 'Page', -}); - // Loader fetches data in standard mode export async function loader() { const data = await fetchData(); return data; } -// Default export for standard framework mode -// export default function AsyncServerComponentPage({ loaderData }: Route.ComponentProps) { -// return ( -//
-//

{loaderData?.title ?? 'Loading...'}

-//

{loaderData?.content ?? 'Loading...'}

-//
-// ); -// } +export default wrapServerComponent(_AsyncServerComponent, { + componentRoute: '/rsc/server-component-async', + componentType: 'Page', +}); 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 index 518f75af0b00..1581ddadd8cd 100644 --- 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 @@ -6,7 +6,7 @@ async function _ServerComponentWithError(_props: Route.ComponentProps) { throw new Error('RSC Server Component Error: Mamma mia!'); } -export const ServerComponent = wrapServerComponent(_ServerComponentWithError, { +const ServerComponent = wrapServerComponent(_ServerComponentWithError, { componentRoute: '/rsc/server-component-error', componentType: 'Page', }); @@ -23,10 +23,4 @@ export async function loader() { return {}; } -// export default function ServerComponentErrorPage() { -// return ( -//
-//

Server Component Error Page

-//
-// ); -// } +export default ServerComponent; 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 index 8e0c1f919a55..3311718415da 100644 --- 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 @@ -13,17 +13,7 @@ async function _ParamServerComponent({ params }: Route.ComponentProps) { ); } -export const ServerComponent = wrapServerComponent(_ParamServerComponent, { +export default wrapServerComponent(_ParamServerComponent, { componentRoute: '/rsc/server-component/:param', componentType: 'Page', }); - -// Default export for standard framework mode -// export default function ParamServerComponentPage({ params }: Route.ComponentProps) { -// return ( -//
-//

Server Component with Param

-//

Param: {params.param}

-//
-// ); -// } 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 index 90469de4a3ed..0be52c9ca6d9 100644 --- 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 @@ -15,22 +15,11 @@ async function _ServerComponent({ loaderData }: Route.ComponentProps) { ); } -// Export the wrapped component - used when RSC mode is enabled -export const ServerComponent = wrapServerComponent(_ServerComponent, { - componentRoute: '/rsc/server-component', - componentType: 'Page', -}); - export async function loader() { return { message: 'Hello from server loader!' }; } -// Default export for standard framework mode -// export default function ServerComponentPage({ loaderData }: Route.ComponentProps) { -// return ( -//
-//

Server Component Page

-//

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

-//
-// ); -// } +export default wrapServerComponent(_ServerComponent, { + componentRoute: '/rsc/server-component', + componentType: 'Page', +}); 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/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/package.json index 048a9c0edb7d..3d716c07f24e 100644 --- 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 @@ -6,9 +6,9 @@ "dependencies": { "react": "19.1.0", "react-dom": "19.1.0", - "react-router": "^7.9.2", - "@react-router/node": "^7.9.2", - "@react-router/serve": "^7.9.2", + "react-router": "7.9.2", + "@react-router/node": "7.9.2", + "@react-router/serve": "7.9.2", "@sentry/react-router": "latest || *", "isbot": "^5.1.17" }, @@ -16,13 +16,13 @@ "@types/react": "19.1.0", "@types/react-dom": "19.1.0", "@types/node": "^22", - "@react-router/dev": "^7.9.2", - "@vitejs/plugin-react": "^4.5.1", - "@vitejs/plugin-rsc": "^0.5.9", + "@react-router/dev": "7.9.2", + "@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" + "typescript": "5.6.3", + "vite": "6.3.5" }, "scripts": { "build": "react-router build", 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 index 77cffb09225b..3de973d1a5ef 100644 --- 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 @@ -4,8 +4,12 @@ import { APP_NAME } from '../constants'; test.describe('RSC - Performance', () => { test('should send server transaction on pageload', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance'; + 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`); @@ -13,26 +17,26 @@ test.describe('RSC - 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': 'auto.http.react_router.request_handler', - 'sentry.source': 'route', + 'sentry.origin': expect.stringMatching(/auto\.http\.(otel\.http|react_router\.request_handler)/), + 'sentry.source': expect.stringMatching(/route|url/), }, op: 'http.server', - origin: 'auto.http.react_router.request_handler', + 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: 'GET /performance', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'node', + transaction_info: { source: expect.stringMatching(/route|url/) }, request: { url: expect.stringContaining('/performance'), headers: expect.any(Object), @@ -43,10 +47,10 @@ test.describe('RSC - Performance', () => { integrations: expect.arrayContaining([expect.any(String)]), name: 'sentry.javascript.react-router', version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/node', 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', @@ -55,8 +59,12 @@ test.describe('RSC - Performance', () => { }); test('should send server transaction on parameterized route', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/with/:param'; + 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`); @@ -64,26 +72,26 @@ test.describe('RSC - Performance', () => { 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': 'auto.http.react_router.request_handler', - 'sentry.source': 'route', + 'sentry.origin': expect.stringMatching(/auto\.http\.(otel\.http|react_router\.request_handler)/), + 'sentry.source': expect.stringMatching(/route|url/), }, op: 'http.server', - origin: 'auto.http.react_router.request_handler', + 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: 'GET /performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'node', + transaction_info: { source: expect.stringMatching(/route|url/) }, request: { url: expect.stringContaining('/performance/with/some-param'), headers: expect.any(Object), @@ -94,10 +102,10 @@ test.describe('RSC - Performance', () => { integrations: expect.arrayContaining([expect.any(String)]), name: 'sentry.javascript.react-router', version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/node', 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 index 3264a1f374b8..d6456bad11f8 100644 --- 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 @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('RSC - Server Component Wrapper', () => { test('captures error from wrapped server component called in loader', async ({ page }) => { const errorMessage = 'RSC Server Component Error: Mamma mia!'; - const errorPromise = waitForError(APP_NAME, async errorEvent => { + const errorPromise = waitForError(APP_NAME, errorEvent => { return errorEvent?.exception?.values?.[0]?.value === errorMessage; }); @@ -50,55 +50,126 @@ test.describe('RSC - Server Component Wrapper', () => { }); test('server component page loads with loader data', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /rsc/server-component'; + 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`); + // Verify the page renders with loader data + await expect(page.getByTestId('loader-message')).toContainText('Hello from server loader!'); + const transaction = await txPromise; expect(transaction).toMatchObject({ type: 'transaction', - transaction: 'GET /rsc/server-component', + 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) }), + ]), + }, }); - - // Verify the page renders with loader data - await expect(page.getByTestId('loader-message')).toContainText('Hello from server loader!'); }); test('async server component page loads', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /rsc/server-component-async'; + 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`); - const transaction = await txPromise; - - expect(transaction).toBeDefined(); - // Verify the page renders async content 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('parameterized server component route works', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /rsc/server-component/:param'; + 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`); + // Verify the param was passed correctly + await expect(page.getByTestId('param')).toContainText('my-test-param'); + const transaction = await txPromise; expect(transaction).toMatchObject({ - transaction: 'GET /rsc/server-component/:param', + 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) }), + ]), + }, }); - - // Verify the param was passed correctly - await expect(page.getByTestId('param')).toContainText('my-test-param'); }); }); 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 index 4d55de01064e..35ed74c34f25 100644 --- 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 @@ -4,25 +4,49 @@ import { APP_NAME } from '../constants'; test.describe('RSC - Server Function Wrapper', () => { test('creates transaction for wrapped server function via action', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - // The server function is called via the action, look for the action transaction - return transactionEvent.transaction?.includes('/rsc/server-function'); + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const matchesRoute = + transactionEvent.transaction?.includes('/rsc/server-function') || + transactionEvent.request?.url?.includes('/rsc/server-function'); + return Boolean(isServerTransaction && matchesRoute && !transactionEvent.transaction?.includes('-error')); }); await page.goto(`/rsc/server-function`); 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', + transaction: expect.stringMatching(/\/rsc\/server-function|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) }), + ]), + }, }); // Check for server function span in the transaction const serverFunctionSpan = transaction.spans?.find( - (span: any) => span.data?.['rsc.server_function.name'] === 'submitForm', + span => span.data?.['rsc.server_function.name'] === 'submitForm', ); if (serverFunctionSpan) { @@ -34,14 +58,11 @@ test.describe('RSC - Server Function Wrapper', () => { }), }); } - - // Verify the form submission was successful - await expect(page.getByTestId('message')).toContainText('Hello, Sentry User!'); }); test('captures error from wrapped server function', async ({ page }) => { const errorMessage = 'RSC Server Function Error: Something went wrong!'; - const errorPromise = waitForError(APP_NAME, async errorEvent => { + const errorPromise = waitForError(APP_NAME, errorEvent => { return errorEvent?.exception?.values?.[0]?.value === errorMessage; }); 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 index 3c579d67339a..45b45b97d368 100644 --- 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 @@ -6,4 +6,17 @@ import { defineConfig } from 'vite'; // This enables React Server Components support in React Router export default defineConfig({ plugins: [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/src/client/index.ts b/packages/react-router/src/client/index.ts index 6734b21c8583..f884158598d0 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -12,6 +12,40 @@ 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; +} + +/** + * Just a passthrough in case this is imported from the client. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function wrapServerFunctions Promise>>( + _moduleName: string, + serverFunctions: T, +): T { + return serverFunctions; +} + /** * @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..83274b58e9a9 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -28,3 +28,7 @@ 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; +export declare const wrapServerFunctions: typeof serverSdk.wrapServerFunctions; From 843b277bfcba65e7f145b2a1f9a2cfc7cb09bbe6 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 22 Jan 2026 17:34:43 +0000 Subject: [PATCH 05/13] Update react-router to 7.12.0 in RSC test app --- .../test-applications/react-router-7-rsc/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index 3d716c07f24e..5a8f65710f15 100644 --- 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 @@ -6,9 +6,9 @@ "dependencies": { "react": "19.1.0", "react-dom": "19.1.0", - "react-router": "7.9.2", - "@react-router/node": "7.9.2", - "@react-router/serve": "7.9.2", + "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" }, @@ -16,7 +16,7 @@ "@types/react": "19.1.0", "@types/react-dom": "19.1.0", "@types/node": "^22", - "@react-router/dev": "7.9.2", + "@react-router/dev": "7.12.0", "@vitejs/plugin-react": "4.5.1", "@vitejs/plugin-rsc": "0.5.14", "@playwright/test": "~1.56.0", From d402ccb7a56ce9c5789d9187d0cd2e96dd554ed3 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 4 Feb 2026 00:53:19 +0000 Subject: [PATCH 06/13] Handle 404 responses in RSC server function wrapper --- .../src/server/rsc/wrapServerFunction.ts | 15 +++++++++++++-- .../test/server/rsc/wrapServerFunction.test.ts | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/server/rsc/wrapServerFunction.ts b/packages/react-router/src/server/rsc/wrapServerFunction.ts index e82c85d1e14f..64120c4a6e83 100644 --- a/packages/react-router/src/server/rsc/wrapServerFunction.ts +++ b/packages/react-router/src/server/rsc/wrapServerFunction.ts @@ -10,7 +10,13 @@ import { SPAN_STATUS_OK, startSpan, } from '@sentry/core'; -import { isErrorCaptured, isRedirectResponse, markErrorAsCaptured, safeFlushServerless } from './responseUtils'; +import { + isErrorCaptured, + isNotFoundResponse, + isRedirectResponse, + markErrorAsCaptured, + safeFlushServerless, +} from './responseUtils'; import type { WrapServerFunctionOptions } from './types'; /** @@ -73,11 +79,16 @@ export function wrapServerFunction Promise>( } catch (error) { // Check if the error is a redirect (common pattern in server functions) if (isRedirectResponse(error)) { - // Don't capture redirects as errors, but still end the span span.setStatus({ code: SPAN_STATUS_OK }); throw error; } + // Check if the error is a not-found response (404) + if (isNotFoundResponse(error)) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + throw error; + } + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); // Only capture if not already captured (error may bubble through nested server functions or components) diff --git a/packages/react-router/test/server/rsc/wrapServerFunction.test.ts b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts index 6705d2aa3008..e4c79e289743 100644 --- a/packages/react-router/test/server/rsc/wrapServerFunction.test.ts +++ b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts @@ -138,6 +138,22 @@ describe('wrapServerFunction', () => { 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 name', () => { const mockServerFn = vi.fn().mockResolvedValue('result'); const wrappedFn = wrapServerFunction('testFunction', mockServerFn); From 72f31b6672905e55429c0f3657b25f0ef0d93057 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 5 Feb 2026 14:06:18 +0000 Subject: [PATCH 07/13] Add Vite plugin for automatic RSC server component instrumentation --- .../app/routes/rsc/server-component-async.tsx | 11 +- .../app/routes/rsc/server-component-param.tsx | 9 +- .../app/routes/rsc/server-component.tsx | 12 +- .../react-router-7-rsc/vite.config.ts | 13 +- .../server/rsc/wrapMatchRSCServerRequest.ts | 36 +- packages/react-router/src/vite/index.ts | 2 +- .../src/vite/makeAutoInstrumentRSCPlugin.ts | 198 ++++++++ packages/react-router/src/vite/plugin.ts | 5 + packages/react-router/src/vite/types.ts | 34 ++ .../vite/makeAutoInstrumentRSCPlugin.test.ts | 442 ++++++++++++++++++ .../react-router/test/vite/plugin.test.ts | 30 +- 11 files changed, 736 insertions(+), 56 deletions(-) create mode 100644 packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts create mode 100644 packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts 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 index bc96a16c4a66..d7bcf80e769a 100644 --- 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 @@ -1,8 +1,6 @@ -import { wrapServerComponent } from '@sentry/react-router'; import type { Route } from './+types/server-component-async'; async function fetchData(): Promise<{ title: string; content: string }> { - // Simulate async data fetch await new Promise(resolve => setTimeout(resolve, 50)); return { title: 'Async Server Component', @@ -10,8 +8,7 @@ async function fetchData(): Promise<{ title: string; content: string }> { }; } -// Wrapped async server component for RSC mode -async function _AsyncServerComponent(_props: Route.ComponentProps) { +export default async function AsyncServerComponent(_props: Route.ComponentProps) { const data = await fetchData(); return ( @@ -22,13 +19,7 @@ async function _AsyncServerComponent(_props: Route.ComponentProps) { ); } -// Loader fetches data in standard mode export async function loader() { const data = await fetchData(); return data; } - -export default wrapServerComponent(_AsyncServerComponent, { - componentRoute: '/rsc/server-component-async', - 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 index 3311718415da..dfb133f9b25e 100644 --- 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 @@ -1,8 +1,6 @@ -import { wrapServerComponent } from '@sentry/react-router'; import type { Route } from './+types/server-component-param'; -// Wrapped parameterized server component for RSC mode -async function _ParamServerComponent({ params }: Route.ComponentProps) { +export default async function ParamServerComponent({ params }: Route.ComponentProps) { await new Promise(resolve => setTimeout(resolve, 10)); return ( @@ -12,8 +10,3 @@ async function _ParamServerComponent({ params }: Route.ComponentProps) { ); } - -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.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component.tsx index 0be52c9ca6d9..77d7bcc1dde2 100644 --- 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 @@ -1,15 +1,12 @@ -import { wrapServerComponent } from '@sentry/react-router'; import type { Route } from './+types/server-component'; -// Demonstrate wrapServerComponent - this wrapper can be used to instrument -// server components when RSC Framework Mode is enabled -async function _ServerComponent({ loaderData }: Route.ComponentProps) { +export default async function ServerComponent({ loaderData }: Route.ComponentProps) { await new Promise(resolve => setTimeout(resolve, 10)); return (

Server Component

-

This demonstrates a wrapped server component.

+

This demonstrates an auto-wrapped server component.

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

); @@ -18,8 +15,3 @@ async function _ServerComponent({ loaderData }: Route.ComponentProps) { export async function loader() { return { message: 'Hello from server loader!' }; } - -export default wrapServerComponent(_ServerComponent, { - componentRoute: '/rsc/server-component', - componentType: 'Page', -}); 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 index 45b45b97d368..26bf3f09272e 100644 --- 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 @@ -1,11 +1,14 @@ +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'; -// RSC Framework Mode (Preview - React Router 7.9.2+) -// This enables React Server Components support in React Router -export default defineConfig({ - plugins: [unstable_reactRouterRSC(), rsc()], +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: { @@ -19,4 +22,4 @@ export default defineConfig({ external: ['chokidar'], }, }, -}); +})); diff --git a/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts b/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts index 3c03267d3ea9..2666fef5b535 100644 --- a/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts +++ b/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts @@ -67,25 +67,25 @@ export function wrapMatchRSCServerRequest(originalFn: MatchRSCServerRequestFn): span => { try { // Wrap the inner onError to capture RSC stream errors. + // Always provide a wrappedInnerOnError so Sentry captures stream errors + // even when the caller does not provide an onError callback. const originalOnError = options.onError; - const wrappedInnerOnError = originalOnError - ? (error: unknown): string | undefined => { - // Only capture if not already captured - if (!isErrorCaptured(error)) { - markErrorAsCaptured(error); - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'generateResponse.onError', - }, - }, - }); - } - return originalOnError(error); - } - : undefined; + const wrappedInnerOnError = (error: unknown): string | undefined => { + // Only capture if not already captured + if (!isErrorCaptured(error)) { + markErrorAsCaptured(error); + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'generateResponse.onError', + }, + }, + }); + } + return originalOnError ? originalOnError(error) : undefined; + }; const response = generateResponse(match, { ...options, 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..dc9b598dd7c8 --- /dev/null +++ b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts @@ -0,0 +1,198 @@ +import { readFile } from 'node:fs/promises'; +import type { Plugin } from 'vite'; +import type { AutoInstrumentRSCOptions } from './types'; + +const JS_EXTENSIONS_RE = /\.(ts|tsx|js|jsx|mjs|mts)$/; + +/** Query parameter suffix used to load the original (unwrapped) module. */ +const WRAPPED_MODULE_SUFFIX = '?sentry-rsc-wrap'; + +/** + * Extracts a route path from a file path relative to the routes directory. + * + * Only supports filesystem-based nested directory routing + * (e.g., `app/routes/rsc/page.tsx` -> `/rsc/page`). + * + * Limitations: + * - Does not support React Router's dot-delimited flat file convention + * (e.g., `app/routes/rsc.page.tsx`). + * - Does not read React Router's route config, so manually configured routes + * that differ from the filesystem path will produce incorrect `componentRoute` values. + * + * Exported for testing. + */ +export function filePathToRoute(filePath: string, routesDirectory: string): string { + const normalizedPath = filePath.replace(/\\/g, '/'); + const normalizedRoutesDir = routesDirectory.replace(/\\/g, '/'); + + // Search for the routes directory as a complete path segment (bounded by '/') + const withSlashes = `/${normalizedRoutesDir}/`; + let routesDirIndex = normalizedPath.lastIndexOf(withSlashes); + + if (routesDirIndex !== -1) { + routesDirIndex += 1; // Point past the leading '/' + } else if (normalizedPath.startsWith(`${normalizedRoutesDir}/`)) { + routesDirIndex = 0; + } else { + return '/'; + } + + let relativePath = normalizedPath.slice(routesDirIndex + normalizedRoutesDir.length); + if (relativePath.startsWith('/')) { + relativePath = relativePath.slice(1); + } + + relativePath = relativePath.replace(/\.(tsx?|jsx?|mjs|mts)$/, ''); + + if (relativePath.endsWith('/index')) { + relativePath = relativePath.slice(0, -6); + } else if (relativePath === 'index') { + relativePath = ''; + } + + // Convert React Router's `$param` convention to `:param` for route matching + relativePath = relativePath.replace(/\$([^/]+)/g, ':$1'); + + return `/${relativePath}`; +} + +/** Checks for a `'use client'` directive at the start of the module (after comments/whitespace). */ +function hasUseClientDirective(code: string): boolean { + const stripped = code.replace(/^(?:\s|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/, ''); + return /^(['"])use client\1/.test(stripped); +} + +/** Checks whether the file already contains a manual `wrapServerComponent` call. */ +function hasManualWrapping(code: string): boolean { + return code.includes('wrapServerComponent('); +} + +/** + * Naive check for `export default` — may match inside comments or strings. + * Acceptable for this experimental scope; a false positive causes the wrapper + * to import a non-existent default export, which produces a build error. + */ +function hasDefaultExport(code: string): boolean { + return /export\s+default\s+/.test(code); +} + +/** + * Generates wrapper module code that re-exports the original component wrapped + * with `wrapServerComponent` via the `?sentry-rsc-wrap` virtual module suffix. + * + * Exported for testing. + */ +export function getWrapperCode(originalId: string, componentRoute: string): string { + const wrappedId = JSON.stringify(`${originalId}${WRAPPED_MODULE_SUFFIX}`); + const wrapOptions = `{ componentRoute: ${JSON.stringify(componentRoute)}, componentType: 'Page' }`; + // The interpolation prevents ESLint's `quotes` rule from flagging the template literal. + return [ + `import { wrapServerComponent } from '${'@sentry/react-router'}';`, + `import _SentryComponent from ${wrappedId};`, + `export default wrapServerComponent(_SentryComponent, ${wrapOptions});`, + `export * from ${wrappedId};`, + ].join(''); +} + +/** + * A Vite plugin that automatically instruments React Router RSC server components. + * + * Uses a virtual module pattern (similar to `@sentry/sveltekit`'s auto-instrumentation): + * instead of rewriting exports with regex, the plugin intercepts route files in the `transform` + * hook and replaces them with a thin wrapper module that imports the original file via a + * `?sentry-rsc-wrap` query suffix, wraps the default export, and re-exports everything else. + * + * TODO: The `?sentry-rsc-wrap` suffix may appear in stack traces. Consider adding a + * `rewriteFrames` integration rule to strip it for cleaner error reporting. + * + * @experimental This plugin is experimental and may change in minor releases. + * React Router RSC support requires React Router v7.9.0+ with `unstable_reactRouterRSC()`. + * + * RSC mode is auto-detected via `configResolved` by checking for the `react-router/rsc` + * Vite plugin. No explicit flag is needed — just use `sentryReactRouter({}, env)`. + */ +export function makeAutoInstrumentRSCPlugin(options: AutoInstrumentRSCOptions = {}): Plugin { + const { enabled = true, debug = false }: AutoInstrumentRSCOptions = options; + const normalizedRoutesDir = (options.routesDirectory ?? 'app/routes').replace(/\\/g, '/'); + + 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')); + debug && + // eslint-disable-next-line no-console + console.log(`[Sentry RSC] RSC mode ${rscDetected ? 'detected' : 'not detected'}`); + }, + + resolveId(source) { + if (source.includes(WRAPPED_MODULE_SUFFIX)) { + return source; + } + return 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 { + debug && + // eslint-disable-next-line no-console + 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 normalizedId = id.replace(/\\/g, '/'); + + if (!normalizedId.includes(`/${normalizedRoutesDir}/`) && !normalizedId.startsWith(`${normalizedRoutesDir}/`)) { + return null; + } + + if (hasUseClientDirective(code)) { + debug && + // eslint-disable-next-line no-console + console.log(`[Sentry RSC] Skipping client component: ${id}`); + return null; + } + + if (hasManualWrapping(code)) { + debug && + // eslint-disable-next-line no-console + console.log(`[Sentry RSC] Skipping already wrapped: ${id}`); + return null; + } + + if (!hasDefaultExport(code)) { + debug && + // eslint-disable-next-line no-console + console.log(`[Sentry RSC] Skipping no default export: ${id}`); + return null; + } + + const componentRoute = filePathToRoute(normalizedId, normalizedRoutesDir); + + debug && + // eslint-disable-next-line no-console + console.log(`[Sentry RSC] Auto-wrapping server component: ${id} -> ${componentRoute}`); + + return { code: getWrapperCode(id, componentRoute), 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/types.ts b/packages/react-router/src/vite/types.ts index c7555630c4fa..c2e80f5031b0 100644 --- a/packages/react-router/src/vite/types.ts +++ b/packages/react-router/src/vite/types.ts @@ -74,4 +74,38 @@ export type SentryReactRouterBuildOptions = BuildTimeOptionsBase & */ sourceMapsUploadOptions?: SourceMapsOptions; // todo(v11): Remove this option (all options already exist in BuildTimeOptionsBase) + + /** + * @experimental Options for automatic RSC (React Server Components) instrumentation. + * RSC mode is auto-detected when `unstable_reactRouterRSC()` is present in the Vite config. + * Use this option to customize behavior (e.g. `debug`, `routesDirectory`) or to explicitly + * disable with `{ enabled: false }`. + */ + 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 components. + * @default true + */ + enabled?: boolean; + + /** + * Enable debug logging to see which files are being instrumented. + * @default false + */ + debug?: boolean; + + /** + * The directory containing route files, relative to the project root. + * @default 'app/routes' + */ + routesDirectory?: string; +}; 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..3633752bd7c5 --- /dev/null +++ b/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts @@ -0,0 +1,442 @@ +import type { Plugin } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + filePathToRoute, + getWrapperCode, + 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('filePathToRoute', () => { + it('converts a standard route path', () => { + expect(filePathToRoute('app/routes/rsc/server-component.tsx', 'app/routes')).toBe('/rsc/server-component'); + }); + + it('converts an index route to the parent directory path', () => { + expect(filePathToRoute('app/routes/performance/index.tsx', 'app/routes')).toBe('/performance'); + }); + + it('converts a root index route to /', () => { + expect(filePathToRoute('app/routes/index.tsx', 'app/routes')).toBe('/'); + }); + + it('converts deeply nested route paths', () => { + expect(filePathToRoute('app/routes/a/b/c.tsx', 'app/routes')).toBe('/a/b/c'); + }); + + it('normalizes Windows-style backslash paths', () => { + expect(filePathToRoute('app\\routes\\rsc\\server-component.tsx', 'app\\routes')).toBe('/rsc/server-component'); + }); + + it('uses a custom routes directory', () => { + expect(filePathToRoute('src/pages/dashboard/overview.tsx', 'src/pages')).toBe('/dashboard/overview'); + }); + + it('returns / when the routes directory is not found in the path', () => { + expect(filePathToRoute('other/directory/file.tsx', 'app/routes')).toBe('/'); + }); + + it('handles various file extensions', () => { + expect(filePathToRoute('app/routes/home.js', 'app/routes')).toBe('/home'); + expect(filePathToRoute('app/routes/home.jsx', 'app/routes')).toBe('/home'); + expect(filePathToRoute('app/routes/home.ts', 'app/routes')).toBe('/home'); + expect(filePathToRoute('app/routes/home.mjs', 'app/routes')).toBe('/home'); + expect(filePathToRoute('app/routes/home.mts', 'app/routes')).toBe('/home'); + }); + + it('handles absolute paths containing the routes directory', () => { + expect(filePathToRoute('/Users/dev/project/app/routes/dashboard.tsx', 'app/routes')).toBe('/dashboard'); + }); + + it('converts $param segments to :param', () => { + expect(filePathToRoute('app/routes/users/$userId.tsx', 'app/routes')).toBe('/users/:userId'); + }); + + it('converts multiple $param segments', () => { + expect(filePathToRoute('app/routes/$org/$repo/settings.tsx', 'app/routes')).toBe('/:org/:repo/settings'); + }); + + it('uses the last occurrence of the routes directory to determine path', () => { + expect(filePathToRoute('/project/routes-app/app/routes/page.tsx', 'routes')).toBe('/page'); + }); + + it('does not match partial directory names', () => { + expect(filePathToRoute('/project/my-routes/page.tsx', 'routes')).toBe('/'); + expect(filePathToRoute('/project/custom-routes/page.tsx', 'routes')).toBe('/'); + }); + + it('uses the correct path segment when a later directory starts with the routes directory name', () => { + expect(filePathToRoute('/project/routes/sub/routesXtra/page.tsx', 'routes')).toBe('/sub/routesXtra/page'); + }); + + it('does not interpret dot-delimited flat file convention (known limitation)', () => { + // React Router supports `routes/rsc.page.tsx` as a flat route for `/rsc/page`, + // but this function treats dots literally since it only supports directory-based routing. + expect(filePathToRoute('app/routes/rsc.page.tsx', 'app/routes')).toBe('/rsc.page'); + }); + }); + + describe('getWrapperCode', () => { + it('generates wrapper code with correct imports and exports', () => { + const result = getWrapperCode('/app/routes/page.tsx', '/page'); + + expect(result).toContain("import { wrapServerComponent } from '@sentry/react-router'"); + expect(result).toContain('import _SentryComponent from'); + expect(result).toContain('/app/routes/page.tsx?sentry-rsc-wrap'); + expect(result).toContain('componentRoute: "/page"'); + expect(result).toContain("componentType: 'Page'"); + expect(result).toContain('export default wrapServerComponent(_SentryComponent,'); + expect(result).toContain('export * from'); + }); + + it('handles route paths containing single quotes via JSON.stringify', () => { + const result = getWrapperCode('/app/routes/page.tsx', "/user's-page"); + expect(result).toContain('componentRoute: "/user\'s-page"'); + }); + + it('escapes backslashes in route paths', () => { + const result = getWrapperCode('/app/routes/page.tsx', '/path\\route'); + expect(result).toContain('componentRoute: "/path\\\\route"'); + }); + + it('uses JSON.stringify for the module id to handle special characters', () => { + const result = getWrapperCode('/app/routes/page.tsx', '/page'); + expect(result).toContain('"/app/routes/page.tsx?sentry-rsc-wrap"'); + }); + }); + + 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 result = plugin.transform( + 'export default function Page() {\n return
Page
;\n}', + 'app/routes/home.tsx', + ); + 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 result = plugin.transform( + 'export default function Page() {\n return
Page
;\n}', + 'app/routes/home.tsx', + ); + expect(result).toBeNull(); + }); + + it('does not wrap when configResolved has not been called', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + + const result = plugin.transform( + 'export default function Page() {\n return
Page
;\n}', + 'app/routes/home.tsx', + ); + 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; + expect(plugin.transform('export default function Page() {}', 'app/routes/home.tsx')).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 files outside the routes directory', () => { + const plugin = createPluginWithRSCDetected(); + expect(plugin.transform('export default function Page() {}', 'app/components/MyComponent.tsx')).toBeNull(); + }); + + it('returns null for files in a directory with a similar prefix to the routes directory', () => { + const plugin = createPluginWithRSCDetected(); + expect(plugin.transform('export default function Page() {}', 'app/routes-archive/old.tsx')).toBeNull(); + }); + + it('returns null for files in directories that partially match the routes directory', () => { + const plugin = createPluginWithRSCDetected({ routesDirectory: 'routes' }); + expect(plugin.transform('export default function Page() {}', '/project/my-routes/page.tsx')).toBeNull(); + }); + + it('returns null for wrapped module suffix (prevents infinite loop)', () => { + const plugin = createPluginWithRSCDetected(); + const result = plugin.transform('export default function Page() {}', 'app/routes/home.tsx?sentry-rsc-wrap'); + expect(result).toBeNull(); + }); + + it('returns null for files with "use client" directive', () => { + 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 with "use client" directive using double quotes', () => { + 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 with "use client" preceded by line comments', () => { + const plugin = createPluginWithRSCDetected(); + const code = [ + '// Copyright 2024 Company Inc.', + '// Licensed under MIT License', + '// See LICENSE file for details', + '// Generated by framework-codegen v3.2', + '// Do not edit manually', + "'use client';", + 'export default function ClientComponent() {}', + ].join('\n'); + expect(plugin.transform(code, 'app/routes/client.tsx')).toBeNull(); + }); + + it('returns null for files with "use client" preceded by a block comment', () => { + const plugin = createPluginWithRSCDetected(); + const code = "/* License header\n * spanning multiple lines\n */\n'use client';\nexport default function C() {}"; + expect(plugin.transform(code, 'app/routes/client.tsx')).toBeNull(); + }); + + it('returns null for files already wrapped with wrapServerComponent', () => { + const plugin = createPluginWithRSCDetected(); + const code = + "import { wrapServerComponent } from '@sentry/react-router';\nexport default wrapServerComponent(MyComponent, {});"; + expect(plugin.transform(code, 'app/routes/home.tsx')).toBeNull(); + }); + + it('returns null for files without a default export', () => { + const plugin = createPluginWithRSCDetected(); + expect(plugin.transform("export function helper() { return 'helper'; }", 'app/routes/utils.tsx')).toBeNull(); + }); + + it('returns wrapper code for a server component with named function export', () => { + const plugin = createPluginWithRSCDetected(); + const result = plugin.transform( + 'export default function HomePage() {\n return
Home
;\n}', + 'app/routes/home.tsx', + ); + + expect(result).not.toBeNull(); + expect(result!.code).toContain("import { wrapServerComponent } from '@sentry/react-router'"); + expect(result!.code).toContain('import _SentryComponent from'); + expect(result!.code).toContain('app/routes/home.tsx?sentry-rsc-wrap'); + expect(result!.code).toContain('componentRoute: "/home"'); + expect(result!.code).toContain("componentType: 'Page'"); + expect(result!.code).toContain('export default wrapServerComponent(_SentryComponent,'); + expect(result!.code).toContain('export * from'); + expect(result!.map).toBeNull(); + }); + + it('returns wrapper code for a server component with arrow function export', () => { + const plugin = createPluginWithRSCDetected(); + const result = plugin.transform('export default () =>
Arrow
', 'app/routes/arrow.tsx'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('componentRoute: "/arrow"'); + }); + + it('returns wrapper code for a server component with identifier export', () => { + const plugin = createPluginWithRSCDetected(); + const result = plugin.transform('function MyComponent() {}\nexport default MyComponent;', 'app/routes/ident.tsx'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('componentRoute: "/ident"'); + }); + + it('returns wrapper code for a server component with anonymous function export', () => { + const plugin = createPluginWithRSCDetected(); + const result = plugin.transform('export default function() { return
Anon
; }', 'app/routes/anon.tsx'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('componentRoute: "/anon"'); + }); + + it('returns wrapper code for a server component with class export', () => { + const plugin = createPluginWithRSCDetected(); + const result = plugin.transform('export default class MyComponent {}', 'app/routes/class-comp.tsx'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('componentRoute: "/class-comp"'); + }); + + it('uses a custom routes directory', () => { + const plugin = createPluginWithRSCDetected({ routesDirectory: 'src/pages' }); + const result = plugin.transform( + 'export default function Dashboard() {\n return
Dashboard
;\n}', + 'src/pages/dashboard.tsx', + ); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('componentRoute: "/dashboard"'); + }); + + it.each(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts'])('wraps files with %s extension', ext => { + const plugin = createPluginWithRSCDetected(); + const code = 'export default function Page() {\n return
Page
;\n}'; + const result = plugin.transform(code, `app/routes/home${ext}`); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('componentRoute: "/home"'); + }); + + it('logs debug messages when debug is enabled and a client component is skipped', () => { + const plugin = createPluginWithRSCDetected({ debug: true }); + plugin.transform("'use client';\nexport default function C() {}", 'app/routes/client.tsx'); + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Skipping client component:')); + }); + + it('logs debug messages when a file is already wrapped', () => { + const plugin = createPluginWithRSCDetected({ debug: true }); + plugin.transform( + "import { wrapServerComponent } from '@sentry/react-router';\nexport default wrapServerComponent(Page, {});", + 'app/routes/wrapped.tsx', + ); + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Skipping already wrapped:')); + }); + + it('logs debug messages when no default export is found', () => { + const plugin = createPluginWithRSCDetected({ debug: true }); + plugin.transform('export function helper() {}', 'app/routes/helper.tsx'); + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Skipping no default export:')); + }); + + it('logs debug messages when wrapping succeeds', () => { + const plugin = createPluginWithRSCDetected({ debug: true }); + plugin.transform('export default function Page() {\n return
Page
;\n}', 'app/routes/home.tsx'); + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Auto-wrapping server component:')); + }); + + it('does not log when debug is disabled', () => { + const plugin = createPluginWithRSCDetected({ debug: false }); + + plugin.transform("'use client';\nexport default function C() {}", 'app/routes/c.tsx'); + plugin.transform('export function helper() {}', 'app/routes/h.tsx'); + plugin.transform('export default function P() {}', 'app/routes/p.tsx'); + + // eslint-disable-next-line no-console + expect(console.log).not.toHaveBeenCalled(); + // eslint-disable-next-line no-console + expect(console.warn).not.toHaveBeenCalled(); + }); + }); + + 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 result = plugin.transform( + 'export default function Page() {\n return
Page
;\n}', + 'app/routes/home.tsx', + ); + + 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'; From 5e93086f1381559168c6a7b05d250b998bf48888 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 5 Feb 2026 14:06:40 +0000 Subject: [PATCH 08/13] Replace custom WeakSet error dedup with SDK's `__sentry_captured__` flag in RSC wrappers --- .../tests/rsc/server-component.test.ts | 31 +++++++- .../react-router-7-rsc/vite.config.ts | 6 +- .../src/server/rsc/responseUtils.ts | 39 ++++------ .../server/rsc/wrapMatchRSCServerRequest.ts | 27 +++---- .../server/rsc/wrapRouteRSCServerRequest.ts | 13 ++-- .../src/server/rsc/wrapServerComponent.ts | 18 +---- .../src/server/rsc/wrapServerFunction.ts | 16 +---- .../src/vite/makeAutoInstrumentRSCPlugin.ts | 3 +- .../test/server/rsc/responseUtils.test.ts | 72 ++++++++----------- .../vite/makeAutoInstrumentRSCPlugin.test.ts | 4 +- 10 files changed, 90 insertions(+), 139 deletions(-) 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 index d6456bad11f8..5dbf7f5e87e7 100644 --- 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 @@ -49,6 +49,34 @@ test.describe('RSC - Server Component Wrapper', () => { }); }); + test('does not send duplicate errors when error bubbles through multiple wrappers', 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; + + // The error should be captured by the innermost wrapper (wrapServerComponent), + // not by the outer request handler. This proves dedup is working — the error + // bubbles through multiple wrappers but is only captured once. + expect(error.exception?.values?.[0]?.mechanism?.data?.function).toBe('ServerComponent'); + + // If dedup were broken, a second error event (from the outer wrapper, e.g. + // matchRSCServerRequest.onError) would also be sent. Verify none arrives. + const maybeDuplicate = await Promise.race([ + waitForError(APP_NAME, errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }), + new Promise<'no-duplicate'>(resolve => setTimeout(() => resolve('no-duplicate'), 3000)), + ]); + + expect(maybeDuplicate).toBe('no-duplicate'); + }); + test('server component page loads with loader data', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, transactionEvent => { const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; @@ -60,7 +88,6 @@ test.describe('RSC - Server Component Wrapper', () => { await page.goto(`/rsc/server-component`); - // Verify the page renders with loader data await expect(page.getByTestId('loader-message')).toContainText('Hello from server loader!'); const transaction = await txPromise; @@ -101,7 +128,6 @@ test.describe('RSC - Server Component Wrapper', () => { await page.goto(`/rsc/server-component-async`); - // Verify the page renders async content await expect(page.getByTestId('title')).toHaveText('Async Server Component'); await expect(page.getByTestId('content')).toHaveText('This content was fetched asynchronously on the server.'); @@ -143,7 +169,6 @@ test.describe('RSC - Server Component Wrapper', () => { await page.goto(`/rsc/server-component/my-test-param`); - // Verify the param was passed correctly await expect(page.getByTestId('param')).toContainText('my-test-param'); const transaction = await txPromise; 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 index 26bf3f09272e..6ef5f7b97378 100644 --- 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 @@ -4,11 +4,7 @@ import rsc from '@vitejs/plugin-rsc/plugin'; import { defineConfig } from 'vite'; export default defineConfig(async env => ({ - plugins: [ - ...(await sentryReactRouter({}, env)), - unstable_reactRouterRSC(), - rsc(), - ], + 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: { diff --git a/packages/react-router/src/server/rsc/responseUtils.ts b/packages/react-router/src/server/rsc/responseUtils.ts index fd5782ec9a4c..5420a48c8f51 100644 --- a/packages/react-router/src/server/rsc/responseUtils.ts +++ b/packages/react-router/src/server/rsc/responseUtils.ts @@ -2,50 +2,37 @@ import { debug } from '@sentry/core'; import { DEBUG_BUILD } from '../../common/debug-build'; /** - * WeakSet to track errors that have been captured to avoid double-capture. - * Uses WeakSet so errors are automatically removed when garbage collected. + * Read-only check for the `__sentry_captured__` flag set by `captureException`. + * Unlike `checkOrSetAlreadyCaught` (in `@sentry/core`, `packages/core/src/utils/misc.ts`), + * this does NOT mark the error — it only reads. This avoids conflicting with + * `captureException`'s internal dedup which also calls `checkOrSetAlreadyCaught` + * and would skip already-marked errors. */ -const CAPTURED_ERRORS = new WeakSet(); - -/** - * Check if an error has already been captured by Sentry. - * Only works for object errors - primitives always return false. - */ -export function isErrorCaptured(error: unknown): boolean { - return error !== null && typeof error === 'object' && CAPTURED_ERRORS.has(error); -} - -/** - * Mark an error as captured to prevent double-capture. - * Only marks object errors - primitives are silently ignored. - */ -export function markErrorAsCaptured(error: unknown): void { - if (error !== null && typeof error === 'object') { - CAPTURED_ERRORS.add(error); +export function isAlreadyCaptured(exception: unknown): boolean { + try { + return !!(exception as { __sentry_captured__?: boolean }).__sentry_captured__; + } catch { + return false; } } /** * Check if an error/response is a redirect. - * React Router uses Response objects for redirects (3xx status codes). + * Handles both Response objects and internal React Router throwables. */ export function isRedirectResponse(error: unknown): boolean { if (error instanceof Response) { const status = error.status; - // 3xx status codes are redirects (301, 302, 303, 307, 308, etc.) return status >= 300 && status < 400; } - // Check for redirect-like objects (internal React Router throwables) if (error && typeof error === 'object') { const errorObj = error as { status?: number; statusCode?: number; type?: unknown }; - // Check for explicit redirect type (React Router internal) if (typeof errorObj.type === 'string' && errorObj.type === 'redirect') { return true; } - // Check for redirect status codes const status = errorObj.status ?? errorObj.statusCode; if (typeof status === 'number' && status >= 300 && status < 400) { return true; @@ -57,22 +44,20 @@ export function isRedirectResponse(error: unknown): boolean { /** * 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; } - // Check for not-found-like objects (internal React Router throwables) if (error && typeof error === 'object') { const errorObj = error as { status?: number; statusCode?: number; type?: unknown }; - // Check for explicit not-found type (React Router internal) if (typeof errorObj.type === 'string' && (errorObj.type === 'not-found' || errorObj.type === 'notFound')) { return true; } - // Check for 404 status code const status = errorObj.status ?? errorObj.statusCode; if (status === 404) { return true; diff --git a/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts b/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts index 2666fef5b535..6720f078de9e 100644 --- a/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts +++ b/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts @@ -9,7 +9,7 @@ import { SPAN_STATUS_ERROR, startSpan, } from '@sentry/core'; -import { isErrorCaptured, markErrorAsCaptured } from './responseUtils'; +import { isAlreadyCaptured } from './responseUtils'; import type { MatchRSCServerRequestArgs, MatchRSCServerRequestFn, RSCMatch } from './types'; /** @@ -32,12 +32,10 @@ export function wrapMatchRSCServerRequest(originalFn: MatchRSCServerRequestFn): return async function sentryWrappedMatchRSCServerRequest(args: MatchRSCServerRequestArgs): Promise { const { request, generateResponse, loadServerAction, onError, ...rest } = args; - // Set transaction name based on request URL const url = new URL(request.url); const isolationScope = getIsolationScope(); isolationScope.setTransactionName(`RSC ${request.method} ${url.pathname}`); - // Update root span attributes if available const activeSpan = getActiveSpan(); if (activeSpan) { const rootSpan = getRootSpan(activeSpan); @@ -66,14 +64,11 @@ export function wrapMatchRSCServerRequest(originalFn: MatchRSCServerRequestFn): }, span => { try { - // Wrap the inner onError to capture RSC stream errors. - // Always provide a wrappedInnerOnError so Sentry captures stream errors - // even when the caller does not provide an onError callback. + // Wrap the inner onError to capture RSC stream errors even when the caller + // does not provide an onError callback. const originalOnError = options.onError; const wrappedInnerOnError = (error: unknown): string | undefined => { - // Only capture if not already captured - if (!isErrorCaptured(error)) { - markErrorAsCaptured(error); + if (!isAlreadyCaptured(error)) { captureException(error, { mechanism: { type: 'instrument', @@ -95,9 +90,7 @@ export function wrapMatchRSCServerRequest(originalFn: MatchRSCServerRequestFn): return response; } catch (error) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - // Capture errors thrown directly in generateResponse - if (!isErrorCaptured(error)) { - markErrorAsCaptured(error); + if (!isAlreadyCaptured(error)) { captureException(error, { mechanism: { type: 'instrument', @@ -132,8 +125,7 @@ export function wrapMatchRSCServerRequest(originalFn: MatchRSCServerRequestFn): return result; } catch (error) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - if (!isErrorCaptured(error)) { - markErrorAsCaptured(error); + if (!isAlreadyCaptured(error)) { captureException(error, { mechanism: { type: 'instrument', @@ -152,11 +144,9 @@ export function wrapMatchRSCServerRequest(originalFn: MatchRSCServerRequestFn): } : undefined; - // Enhanced onError handler that captures RSC server errors not already captured by inner wrappers + // Outer onError handler — captures RSC server errors not already captured by inner wrappers const wrappedOnError = (error: unknown): void => { - // Only capture if not already captured by generateResponse or loadServerAction wrappers - if (!isErrorCaptured(error)) { - markErrorAsCaptured(error); + if (!isAlreadyCaptured(error)) { captureException(error, { mechanism: { type: 'instrument', @@ -168,7 +158,6 @@ export function wrapMatchRSCServerRequest(originalFn: MatchRSCServerRequestFn): }); } - // Call original onError if provided if (onError) { onError(error); } diff --git a/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts b/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts index 6ece1c08590d..521b83b4d319 100644 --- a/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts +++ b/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts @@ -9,7 +9,7 @@ import { SPAN_STATUS_ERROR, startSpan, } from '@sentry/core'; -import { isErrorCaptured, markErrorAsCaptured } from './responseUtils'; +import { isAlreadyCaptured } from './responseUtils'; import type { DecodedPayload, RouteRSCServerRequestArgs, RouteRSCServerRequestFn, RSCPayload } from './types'; /** @@ -32,12 +32,10 @@ export function wrapRouteRSCServerRequest(originalFn: RouteRSCServerRequestFn): return async function sentryWrappedRouteRSCServerRequest(args: RouteRSCServerRequestArgs): Promise { const { request, renderHTML, fetchServer, ...rest } = args; - // Set transaction name based on request URL const url = new URL(request.url); const isolationScope = getIsolationScope(); isolationScope.setTransactionName(`RSC SSR ${request.method} ${url.pathname}`); - // Update root span attributes if available const activeSpan = getActiveSpan(); if (activeSpan) { const rootSpan = getRootSpan(activeSpan); @@ -69,8 +67,7 @@ export function wrapRouteRSCServerRequest(originalFn: RouteRSCServerRequestFn): return response; } catch (error) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - if (!isErrorCaptured(error)) { - markErrorAsCaptured(error); + if (!isAlreadyCaptured(error)) { captureException(error, { mechanism: { type: 'instrument', @@ -105,8 +102,7 @@ export function wrapRouteRSCServerRequest(originalFn: RouteRSCServerRequestFn): return result; } catch (error) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - if (!isErrorCaptured(error)) { - markErrorAsCaptured(error); + if (!isAlreadyCaptured(error)) { captureException(error, { mechanism: { type: 'instrument', @@ -132,8 +128,7 @@ export function wrapRouteRSCServerRequest(originalFn: RouteRSCServerRequestFn): }); } catch (error) { // Only capture errors that weren't already captured by inner wrappers - if (!isErrorCaptured(error)) { - markErrorAsCaptured(error); + if (!isAlreadyCaptured(error)) { captureException(error, { mechanism: { type: 'instrument', diff --git a/packages/react-router/src/server/rsc/wrapServerComponent.ts b/packages/react-router/src/server/rsc/wrapServerComponent.ts index 1cc4ed946b4c..2a62f2458f32 100644 --- a/packages/react-router/src/server/rsc/wrapServerComponent.ts +++ b/packages/react-router/src/server/rsc/wrapServerComponent.ts @@ -7,13 +7,7 @@ import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, } from '@sentry/core'; -import { - isErrorCaptured, - isNotFoundResponse, - isRedirectResponse, - markErrorAsCaptured, - safeFlushServerless, -} from './responseUtils'; +import { isAlreadyCaptured, isNotFoundResponse, isRedirectResponse, safeFlushServerless } from './responseUtils'; import type { ServerComponentContext } from './types'; /** @@ -47,12 +41,10 @@ export function wrapServerComponent any>( ): T { const { componentRoute, componentType } = context; - // Use a Proxy to wrap the function while preserving its properties return new Proxy(serverComponent, { apply: (originalFunction, thisArg, args) => { const isolationScope = getIsolationScope(); - // Set transaction name with component context const transactionName = `${componentType} Server Component (${componentRoute})`; isolationScope.setTransactionName(transactionName); @@ -62,30 +54,25 @@ export function wrapServerComponent any>( const span = getActiveSpan(); let shouldCapture = true; - // Check if error is a redirect response (3xx) if (isRedirectResponse(error)) { shouldCapture = false; if (span) { span.setStatus({ code: SPAN_STATUS_OK }); } } - // Check if error is a not-found response (404) else if (isNotFoundResponse(error)) { shouldCapture = false; if (span) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); } } - // Regular error else { if (span) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); } } - // Only capture if not already captured by other wrappers to prevent double-capture - if (shouldCapture && !isErrorCaptured(error)) { - markErrorAsCaptured(error); + if (shouldCapture && !isAlreadyCaptured(error)) { captureException(error, { mechanism: { type: 'instrument', @@ -100,7 +87,6 @@ export function wrapServerComponent any>( } }, () => { - // Fire-and-forget flush to avoid swallowing original errors safeFlushServerless(flushIfServerless); }, ); diff --git a/packages/react-router/src/server/rsc/wrapServerFunction.ts b/packages/react-router/src/server/rsc/wrapServerFunction.ts index 64120c4a6e83..bb8554c95411 100644 --- a/packages/react-router/src/server/rsc/wrapServerFunction.ts +++ b/packages/react-router/src/server/rsc/wrapServerFunction.ts @@ -10,13 +10,7 @@ import { SPAN_STATUS_OK, startSpan, } from '@sentry/core'; -import { - isErrorCaptured, - isNotFoundResponse, - isRedirectResponse, - markErrorAsCaptured, - safeFlushServerless, -} from './responseUtils'; +import { isAlreadyCaptured, isNotFoundResponse, isRedirectResponse, safeFlushServerless } from './responseUtils'; import type { WrapServerFunctionOptions } from './types'; /** @@ -53,7 +47,6 @@ export function wrapServerFunction Promise>( const wrappedFunction = async function (this: unknown, ...args: Parameters): Promise> { const spanName = options.name || `serverFunction/${functionName}`; - // Set transaction name on isolation scope (consistent with other RSC wrappers) const isolationScope = getIsolationScope(); isolationScope.setTransactionName(spanName); @@ -77,13 +70,11 @@ export function wrapServerFunction Promise>( const result = await serverFunction.apply(this, args); return result; } catch (error) { - // Check if the error is a redirect (common pattern in server functions) if (isRedirectResponse(error)) { span.setStatus({ code: SPAN_STATUS_OK }); throw error; } - // Check if the error is a not-found response (404) if (isNotFoundResponse(error)) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); throw error; @@ -91,9 +82,7 @@ export function wrapServerFunction Promise>( span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - // Only capture if not already captured (error may bubble through nested server functions or components) - if (!isErrorCaptured(error)) { - markErrorAsCaptured(error); + if (!isAlreadyCaptured(error)) { captureException(error, { mechanism: { type: 'instrument', @@ -107,7 +96,6 @@ export function wrapServerFunction Promise>( } throw error; } finally { - // Fire-and-forget flush to avoid swallowing original errors safeFlushServerless(flushIfServerless); } }, diff --git a/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts index dc9b598dd7c8..63a9ea3af69d 100644 --- a/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts +++ b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts @@ -25,12 +25,11 @@ export function filePathToRoute(filePath: string, routesDirectory: string): stri const normalizedPath = filePath.replace(/\\/g, '/'); const normalizedRoutesDir = routesDirectory.replace(/\\/g, '/'); - // Search for the routes directory as a complete path segment (bounded by '/') const withSlashes = `/${normalizedRoutesDir}/`; let routesDirIndex = normalizedPath.lastIndexOf(withSlashes); if (routesDirIndex !== -1) { - routesDirIndex += 1; // Point past the leading '/' + routesDirIndex += 1; } else if (normalizedPath.startsWith(`${normalizedRoutesDir}/`)) { routesDirIndex = 0; } else { diff --git a/packages/react-router/test/server/rsc/responseUtils.test.ts b/packages/react-router/test/server/rsc/responseUtils.test.ts index cc7069bea2b1..c329b6bedbf2 100644 --- a/packages/react-router/test/server/rsc/responseUtils.test.ts +++ b/packages/react-router/test/server/rsc/responseUtils.test.ts @@ -1,63 +1,57 @@ +import { addNonEnumerableProperty } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - isErrorCaptured, + isAlreadyCaptured, isNotFoundResponse, isRedirectResponse, - markErrorAsCaptured, safeFlushServerless, } from '../../../src/server/rsc/responseUtils'; describe('responseUtils', () => { - describe('isErrorCaptured / markErrorAsCaptured', () => { - it('should return false for uncaptured errors', () => { - const error = new Error('test'); - expect(isErrorCaptured(error)).toBe(false); + describe('isAlreadyCaptured', () => { + it('should return false for errors without __sentry_captured__', () => { + expect(isAlreadyCaptured(new Error('test'))).toBe(false); }); - it('should return true for captured errors', () => { + it('should return true for errors with __sentry_captured__ set', () => { const error = new Error('test'); - markErrorAsCaptured(error); - expect(isErrorCaptured(error)).toBe(true); + addNonEnumerableProperty(error as unknown as Record, '__sentry_captured__', true); + expect(isAlreadyCaptured(error)).toBe(true); }); - it('should handle null errors', () => { - expect(isErrorCaptured(null)).toBe(false); - // markErrorAsCaptured should not throw for null - expect(() => markErrorAsCaptured(null)).not.toThrow(); + it('should return false for null', () => { + expect(isAlreadyCaptured(null)).toBe(false); }); - it('should handle undefined errors', () => { - expect(isErrorCaptured(undefined)).toBe(false); - expect(() => markErrorAsCaptured(undefined)).not.toThrow(); + it('should return false for undefined', () => { + expect(isAlreadyCaptured(undefined)).toBe(false); }); - it('should handle primitive errors (strings)', () => { - // Primitives cannot be tracked by WeakSet - const error = 'string error'; - markErrorAsCaptured(error); - expect(isErrorCaptured(error)).toBe(false); + it('should return false for primitives', () => { + expect(isAlreadyCaptured('string')).toBe(false); + expect(isAlreadyCaptured(42)).toBe(false); }); - it('should handle primitive errors (numbers)', () => { - const error = 42; - markErrorAsCaptured(error); - expect(isErrorCaptured(error)).toBe(false); + it('should return false for a Proxy that throws on property access', () => { + const proxy = new Proxy( + {}, + { + get() { + throw new Error('proxy trap'); + }, + }, + ); + expect(isAlreadyCaptured(proxy)).toBe(false); }); - it('should track different error objects independently', () => { - const error1 = new Error('error 1'); - const error2 = new Error('error 2'); - - markErrorAsCaptured(error1); - - expect(isErrorCaptured(error1)).toBe(true); - expect(isErrorCaptured(error2)).toBe(false); + it('should return true for truthy non-boolean __sentry_captured__ values', () => { + const error = { __sentry_captured__: 1 }; + expect(isAlreadyCaptured(error)).toBe(true); }); - it('should handle object errors', () => { - const error = { message: 'custom error', code: 500 }; - markErrorAsCaptured(error); - expect(isErrorCaptured(error)).toBe(true); + it('should return false for a frozen object without __sentry_captured__', () => { + const frozen = Object.freeze({ message: 'frozen error' }); + expect(isAlreadyCaptured(frozen)).toBe(false); }); }); @@ -201,7 +195,6 @@ describe('responseUtils', () => { safeFlushServerless(mockFlush); - // Wait for the promise to resolve await new Promise(resolve => setTimeout(resolve, 0)); expect(mockFlush).toHaveBeenCalled(); @@ -218,7 +211,6 @@ describe('responseUtils', () => { expect(() => safeFlushServerless(mockFlush)).not.toThrow(); - // Wait for the promise to reject (should be caught internally) await new Promise(resolve => setTimeout(resolve, 0)); }); @@ -228,10 +220,8 @@ describe('responseUtils', () => { safeFlushServerless(mockFlush); - // Wait for the promise to reject await new Promise(resolve => setTimeout(resolve, 0)); - // Should not throw, error is caught internally expect(mockFlush).toHaveBeenCalled(); consoleWarnSpy.mockRestore(); diff --git a/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts b/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts index 3633752bd7c5..c1312b70f675 100644 --- a/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts +++ b/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts @@ -24,9 +24,7 @@ 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 { +function createPluginWithRSCDetected(options: Parameters[0] = {}): PluginWithHooks { const plugin = makeAutoInstrumentRSCPlugin(options) as PluginWithHooks; plugin.configResolved(RSC_PLUGINS_CONFIG); return plugin; From 3e4667517e595ee91faa70bc599248835e4018bb Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 5 Feb 2026 16:19:45 +0000 Subject: [PATCH 09/13] Remove unused RSC exports and dead types from public API --- packages/react-router/src/client/index.ts | 11 ---- packages/react-router/src/index.types.ts | 1 - packages/react-router/src/server/index.ts | 24 +------- packages/react-router/src/server/rsc/index.ts | 27 +-------- packages/react-router/src/server/rsc/types.ts | 55 ++----------------- .../src/server/rsc/wrapServerComponent.ts | 6 +- .../vite/makeAutoInstrumentRSCPlugin.test.ts | 4 +- 7 files changed, 12 insertions(+), 116 deletions(-) diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index f884158598d0..226be4454eee 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -35,17 +35,6 @@ export function wrapServerFunction Promise>( return serverFunction; } -/** - * Just a passthrough in case this is imported from the client. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function wrapServerFunctions Promise>>( - _moduleName: string, - serverFunctions: T, -): T { - return serverFunctions; -} - /** * @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 83274b58e9a9..ee09fc108b10 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -31,4 +31,3 @@ export declare const unleashIntegration: typeof clientSdk.unleashIntegration; export declare const wrapServerComponent: typeof serverSdk.wrapServerComponent; export declare const wrapServerFunction: typeof serverSdk.wrapServerFunction; -export declare const wrapServerFunctions: typeof serverSdk.wrapServerFunctions; diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index f5bf19a473ac..f09d8a25eccd 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -20,26 +20,6 @@ export { } from './createServerInstrumentation'; // React Server Components (RSC) - React Router v7.9.0+ -export { - wrapMatchRSCServerRequest, - wrapRouteRSCServerRequest, - wrapServerFunction, - wrapServerFunctions, - wrapServerComponent, - isServerComponentContext, -} from './rsc'; +export { wrapServerFunction, wrapServerComponent } from './rsc'; -export type { - RSCRouteConfigEntry, - RSCPayload, - RSCMatch, - DecodedPayload, - RouterContextProvider, - MatchRSCServerRequestArgs, - MatchRSCServerRequestFn, - RouteRSCServerRequestArgs, - RouteRSCServerRequestFn, - RSCHydratedRouterProps, - ServerComponentContext, - WrapServerFunctionOptions, -} 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 index e1c33d51b51d..12d38de4ccbf 100644 --- a/packages/react-router/src/server/rsc/index.ts +++ b/packages/react-router/src/server/rsc/index.ts @@ -1,25 +1,4 @@ -export { wrapMatchRSCServerRequest } from './wrapMatchRSCServerRequest'; -export { wrapRouteRSCServerRequest } from './wrapRouteRSCServerRequest'; -export { wrapServerFunction, wrapServerFunctions } from './wrapServerFunction'; -export { wrapServerComponent, isServerComponentContext } from './wrapServerComponent'; +export { wrapServerFunction } from './wrapServerFunction'; +export { wrapServerComponent } from './wrapServerComponent'; -export type { - RSCRouteConfigEntry, - RSCPayload, - RSCMatch, - DecodedPayload, - RouterContextProvider, - DecodeReplyFunction, - DecodeActionFunction, - DecodeFormStateFunction, - LoadServerActionFunction, - SSRCreateFromReadableStreamFunction, - BrowserCreateFromReadableStreamFunction, - MatchRSCServerRequestArgs, - MatchRSCServerRequestFn, - RouteRSCServerRequestArgs, - RouteRSCServerRequestFn, - RSCHydratedRouterProps, - ServerComponentContext, - WrapServerFunctionOptions, -} from './types'; +export type { ServerComponentContext, WrapServerFunctionOptions } from './types'; diff --git a/packages/react-router/src/server/rsc/types.ts b/packages/react-router/src/server/rsc/types.ts index fee95cf7b91f..bc4bda7255dd 100644 --- a/packages/react-router/src/server/rsc/types.ts +++ b/packages/react-router/src/server/rsc/types.ts @@ -1,25 +1,3 @@ -/** - * Type definitions for React Router RSC (React Server Components) APIs. - * - * These types mirror the unstable RSC APIs from react-router v7.9.0+. - * All RSC APIs in React Router are prefixed with `unstable_` and subject to change. - */ - -/** - * RSC route configuration entry - mirrors `unstable_RSCRouteConfigEntry` from react-router. - */ -export interface RSCRouteConfigEntry { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; - path?: string; - index?: boolean; - caseSensitive?: boolean; - id?: string; - children?: RSCRouteConfigEntry[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - lazy?: () => Promise; -} - /** * RSC payload types - mirrors the various payload types from react-router. */ @@ -57,17 +35,6 @@ export type DecodeFormStateFunction = (actionResult: any, body: FormData, option export type LoadServerActionFunction = (id: string) => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type SSRCreateFromReadableStreamFunction = (stream: ReadableStream) => Promise; -export type BrowserCreateFromReadableStreamFunction = ( - stream: ReadableStream, - options?: { temporaryReferences?: unknown }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -) => Promise; - -/** - * Router context provider - mirrors `RouterContextProvider` from react-router. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type RouterContextProvider = any; /** * Arguments for `unstable_matchRSCServerRequest`. @@ -80,7 +47,8 @@ export interface MatchRSCServerRequestArgs { /** Function to decode server function arguments */ decodeReply?: DecodeReplyFunction; /** Per-request context provider instance */ - requestContext?: RouterContextProvider; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + requestContext?: any; /** Function to load a server action by ID */ loadServerAction?: LoadServerActionFunction; /** Function to decode server actions */ @@ -92,7 +60,8 @@ export interface MatchRSCServerRequestArgs { /** The Request to match against */ request: Request; /** Route definitions */ - routes: RSCRouteConfigEntry[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + routes: any[]; /** Function to generate Response encoding the RSC payload */ generateResponse: ( match: RSCMatch, @@ -128,22 +97,6 @@ export interface RouteRSCServerRequestArgs { */ export type RouteRSCServerRequestFn = (args: RouteRSCServerRequestArgs) => Promise; -/** - * Props for `unstable_RSCHydratedRouter` component. - */ -export interface RSCHydratedRouterProps { - /** Function to decode RSC payloads from server */ - createFromReadableStream: BrowserCreateFromReadableStreamFunction; - /** Optional fetch implementation */ - fetch?: (request: Request) => Promise; - /** The decoded RSC payload to hydrate */ - payload: RSCPayload; - /** Route discovery behavior: "eager" or "lazy" */ - routeDiscovery?: 'eager' | 'lazy'; - /** Function that returns a router context provider instance */ - getContext?: () => RouterContextProvider; -} - /** * Context for server component wrapping. */ diff --git a/packages/react-router/src/server/rsc/wrapServerComponent.ts b/packages/react-router/src/server/rsc/wrapServerComponent.ts index 2a62f2458f32..88fe3123d8f8 100644 --- a/packages/react-router/src/server/rsc/wrapServerComponent.ts +++ b/packages/react-router/src/server/rsc/wrapServerComponent.ts @@ -59,14 +59,12 @@ export function wrapServerComponent any>( if (span) { span.setStatus({ code: SPAN_STATUS_OK }); } - } - else if (isNotFoundResponse(error)) { + } else if (isNotFoundResponse(error)) { shouldCapture = false; if (span) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); } - } - else { + } else { if (span) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); } diff --git a/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts b/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts index c1312b70f675..7feab121dbab 100644 --- a/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts +++ b/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts @@ -168,9 +168,7 @@ describe('makeAutoInstrumentRSCPlugin', () => { 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:'), - ); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Failed to read original file:')); }); }); From 5c04b8d2f27d8b1c0edab03c3f4575ac20d48526 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 6 Feb 2026 15:01:28 +0000 Subject: [PATCH 10/13] Switch to AST parsing, keep server component manually instrumented --- .../react-router-7-rsc/app/routes.ts | 3 + .../app/routes/rsc/actions-default.ts | 12 + .../app/routes/rsc/actions.ts | 24 +- .../app/routes/rsc/server-component-async.tsx | 8 +- .../server-component-comment-directive.tsx | 25 + .../app/routes/rsc/server-component-error.tsx | 20 +- .../routes/rsc/server-component-not-found.tsx | 18 +- .../app/routes/rsc/server-component-param.tsx | 8 +- .../routes/rsc/server-component-redirect.tsx | 18 +- .../app/routes/rsc/server-component.tsx | 10 +- .../app/routes/rsc/server-function-arrow.tsx | 33 ++ .../routes/rsc/server-function-default.tsx | 33 ++ .../tests/rsc/server-component.test.ts | 96 ++-- .../tests/rsc/server-function.test.ts | 121 +++- packages/react-router/package.json | 4 +- .../src/server/rsc/responseUtils.ts | 34 +- packages/react-router/src/server/rsc/types.ts | 107 ---- .../server/rsc/wrapMatchRSCServerRequest.ts | 174 ------ .../server/rsc/wrapRouteRSCServerRequest.ts | 145 ----- .../src/server/rsc/wrapServerComponent.ts | 123 ++-- .../src/server/rsc/wrapServerFunction.ts | 160 ++---- .../src/vite/makeAutoInstrumentRSCPlugin.ts | 346 +++++++---- .../src/vite/recastTypescriptParser.ts | 91 +++ packages/react-router/src/vite/types.ts | 14 +- .../test/server/rsc/responseUtils.test.ts | 187 +----- .../rsc/wrapMatchRSCServerRequest.test.ts | 325 ----------- .../rsc/wrapRouteRSCServerRequest.test.ts | 303 ---------- .../server/rsc/wrapServerComponent.test.ts | 252 +++----- .../server/rsc/wrapServerFunction.test.ts | 77 +-- .../vite/makeAutoInstrumentRSCPlugin.test.ts | 536 +++++++++++------- 30 files changed, 1220 insertions(+), 2087 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions-default.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-comment-directive.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-arrow.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-default.tsx delete mode 100644 packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts delete mode 100644 packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts create mode 100644 packages/react-router/src/vite/recastTypescriptParser.ts delete mode 100644 packages/react-router/test/server/rsc/wrapMatchRSCServerRequest.test.ts delete mode 100644 packages/react-router/test/server/rsc/wrapRouteRSCServerRequest.test.ts 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 index dff6af8aba5f..3230eb68a6dd 100644 --- 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 @@ -10,9 +10,12 @@ export default [ 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'), 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 index 0ae0caec75c7..0f39749fc4a3 100644 --- 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 @@ -1,8 +1,6 @@ 'use server'; -import { wrapServerFunction } from '@sentry/react-router'; - -async function _submitForm(formData: FormData): Promise<{ success: boolean; message: string }> { +export async function submitForm(formData: FormData): Promise<{ success: boolean; message: string }> { const name = formData.get('name') as string; // Simulate some async work @@ -14,22 +12,16 @@ async function _submitForm(formData: FormData): Promise<{ success: boolean; mess }; } -export const submitForm = wrapServerFunction('submitForm', _submitForm); - -async function _submitFormWithError(_formData: FormData): Promise<{ success: boolean; message: string }> { +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 submitFormWithError = wrapServerFunction('submitFormWithError', _submitFormWithError); - -async function _getData(): Promise<{ timestamp: number; data: string }> { - await new Promise(resolve => setTimeout(resolve, 20)); - +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 { - timestamp: Date.now(), - data: 'Fetched from server function', + success: true, + message: `Arrow: Hello, ${name}!`, }; -} - -export const getData = wrapServerFunction('getData', _getData); +}; 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 index d7bcf80e769a..a19bcb13e0d3 100644 --- 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 @@ -1,3 +1,4 @@ +import { wrapServerComponent } from '@sentry/react-router'; import type { Route } from './+types/server-component-async'; async function fetchData(): Promise<{ title: string; content: string }> { @@ -8,7 +9,7 @@ async function fetchData(): Promise<{ title: string; content: string }> { }; } -export default async function AsyncServerComponent(_props: Route.ComponentProps) { +async function AsyncServerComponent(_props: Route.ComponentProps) { const data = await fetchData(); return ( @@ -19,6 +20,11 @@ export default async function AsyncServerComponent(_props: Route.ComponentProps) ); } +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 index 1581ddadd8cd..094b551fcfb0 100644 --- 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 @@ -1,26 +1,10 @@ import { wrapServerComponent } from '@sentry/react-router'; -import type { Route } from './+types/server-component-error'; -// Demonstrate error capture in wrapServerComponent -async function _ServerComponentWithError(_props: Route.ComponentProps) { +async function ServerComponentWithError() { throw new Error('RSC Server Component Error: Mamma mia!'); } -const ServerComponent = wrapServerComponent(_ServerComponentWithError, { +export default wrapServerComponent(ServerComponentWithError, { componentRoute: '/rsc/server-component-error', componentType: 'Page', }); - -// For testing, we can trigger the wrapped component via a loader -export async function loader() { - // Call the wrapped ServerComponent to test error capture - try { - await ServerComponent({} as Route.ComponentProps); - } catch (e) { - // Error is captured by Sentry, rethrow for error boundary - throw e; - } - return {}; -} - -export default ServerComponent; 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 index 0fad23e20fe1..98972fcaaa4b 100644 --- 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 @@ -1,16 +1,10 @@ -import type { Route } from './+types/server-component-not-found'; +import { wrapServerComponent } from '@sentry/react-router'; -// This route demonstrates that 404 responses are NOT captured as errors -export async function loader() { - // Throw a 404 response +async function NotFoundServerComponent() { throw new Response('Not Found', { status: 404 }); } -export default function NotFoundServerComponentPage() { - return ( -
-

Not Found Server Component

-

This triggers a 404 response.

-
- ); -} +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 index dfb133f9b25e..c17927404c3d 100644 --- 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 @@ -1,6 +1,7 @@ +import { wrapServerComponent } from '@sentry/react-router'; import type { Route } from './+types/server-component-param'; -export default async function ParamServerComponent({ params }: Route.ComponentProps) { +async function ParamServerComponent({ params }: Route.ComponentProps) { await new Promise(resolve => setTimeout(resolve, 10)); return ( @@ -10,3 +11,8 @@ export default async function ParamServerComponent({ params }: Route.ComponentPr ); } + +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 index a85dadcfe961..21389c6fece3 100644 --- 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 @@ -1,17 +1,11 @@ import { redirect } from 'react-router'; -import type { Route } from './+types/server-component-redirect'; +import { wrapServerComponent } from '@sentry/react-router'; -// This route demonstrates that redirects are NOT captured as errors -export async function loader() { - // Redirect to home page +async function RedirectServerComponent() { throw redirect('/'); } -export default function RedirectServerComponentPage() { - return ( -
-

Redirect Server Component

-

You should be redirected and not see this.

-
- ); -} +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 index 77d7bcc1dde2..215decf3639e 100644 --- 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 @@ -1,17 +1,23 @@ +import { wrapServerComponent } from '@sentry/react-router'; import type { Route } from './+types/server-component'; -export default async function ServerComponent({ loaderData }: Route.ComponentProps) { +async function ServerComponent({ loaderData }: Route.ComponentProps) { await new Promise(resolve => setTimeout(resolve, 10)); return (

Server Component

-

This demonstrates an auto-wrapped 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/tests/rsc/server-component.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-component.test.ts index 5dbf7f5e87e7..7856c710e8c0 100644 --- 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 @@ -3,7 +3,7 @@ import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('RSC - Server Component Wrapper', () => { - test('captures error from wrapped server component called in loader', async ({ page }) => { + 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; @@ -35,48 +35,13 @@ test.describe('RSC - Server Component Wrapper', () => { 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('does not send duplicate errors when error bubbles through multiple wrappers', 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; - - // The error should be captured by the innermost wrapper (wrapServerComponent), - // not by the outer request handler. This proves dedup is working — the error - // bubbles through multiple wrappers but is only captured once. - expect(error.exception?.values?.[0]?.mechanism?.data?.function).toBe('ServerComponent'); - - // If dedup were broken, a second error event (from the outer wrapper, e.g. - // matchRSCServerRequest.onError) would also be sent. Verify none arrives. - const maybeDuplicate = await Promise.race([ - waitForError(APP_NAME, errorEvent => { - return errorEvent?.exception?.values?.[0]?.value === errorMessage; - }), - new Promise<'no-duplicate'>(resolve => setTimeout(() => resolve('no-duplicate'), 3000)), - ]); - - expect(maybeDuplicate).toBe('no-duplicate'); - }); - test('server component page loads with loader data', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, transactionEvent => { const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; @@ -158,6 +123,65 @@ test.describe('RSC - Server Component Wrapper', () => { }); }); + 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'; 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 index 35ed74c34f25..01dc34cf9eac 100644 --- 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 @@ -4,15 +4,23 @@ 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'; - const matchesRoute = - transactionEvent.transaction?.includes('/rsc/server-function') || - transactionEvent.request?.url?.includes('/rsc/server-function'); - return Boolean(isServerTransaction && matchesRoute && !transactionEvent.transaction?.includes('-error')); + // 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.goto(`/rsc/server-function`); await page.locator('#submit').click(); // Verify the form submission was successful @@ -22,7 +30,6 @@ test.describe('RSC - Server Function Wrapper', () => { expect(transaction).toMatchObject({ type: 'transaction', - transaction: expect.stringMatching(/\/rsc\/server-function|GET \*/), platform: 'node', environment: 'qa', contexts: { @@ -31,7 +38,6 @@ test.describe('RSC - Server Function Wrapper', () => { trace_id: expect.any(String), }, }, - spans: expect.any(Array), start_timestamp: expect.any(Number), timestamp: expect.any(Number), sdk: { @@ -44,12 +50,14 @@ test.describe('RSC - Server Function Wrapper', () => { }, }); - // Check for server function span in the transaction + // 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', @@ -57,6 +65,14 @@ test.describe('RSC - Server Function Wrapper', () => { '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', + }), + ); } }); @@ -106,21 +122,88 @@ test.describe('RSC - Server Function Wrapper', () => { }); }); - test('server function page loads correctly', async ({ page }) => { - await page.goto(`/rsc/server-function`); + 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', + ); - // Verify the page structure - await expect(page.locator('h1')).toHaveText('Server Function Test'); - await expect(page.locator('#name')).toHaveValue('Sentry User'); - await expect(page.locator('#submit')).toBeVisible(); + 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('server function form submission with custom input', async ({ page }) => { - await page.goto(`/rsc/server-function`); - await page.fill('#name', 'Test User'); + 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(); - // Verify the form submission result - await expect(page.getByTestId('message')).toContainText('Hello, Test User!'); + 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/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/server/rsc/responseUtils.ts b/packages/react-router/src/server/rsc/responseUtils.ts index 5420a48c8f51..d74abee0ba6b 100644 --- a/packages/react-router/src/server/rsc/responseUtils.ts +++ b/packages/react-router/src/server/rsc/responseUtils.ts @@ -1,16 +1,14 @@ -import { debug } from '@sentry/core'; -import { DEBUG_BUILD } from '../../common/debug-build'; - /** * Read-only check for the `__sentry_captured__` flag set by `captureException`. - * Unlike `checkOrSetAlreadyCaught` (in `@sentry/core`, `packages/core/src/utils/misc.ts`), - * this does NOT mark the error — it only reads. This avoids conflicting with - * `captureException`'s internal dedup which also calls `checkOrSetAlreadyCaught` - * and would skip already-marked errors. + * 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 { __sentry_captured__?: boolean }).__sentry_captured__; + return !!(exception as Record).__sentry_captured__; } catch { return false; } @@ -27,14 +25,13 @@ export function isRedirectResponse(error: unknown): boolean { } if (error && typeof error === 'object') { - const errorObj = error as { status?: number; statusCode?: number; type?: unknown }; + const errorObj = error as { status?: number; type?: unknown }; if (typeof errorObj.type === 'string' && errorObj.type === 'redirect') { return true; } - const status = errorObj.status ?? errorObj.statusCode; - if (typeof status === 'number' && status >= 300 && status < 400) { + if (typeof errorObj.status === 'number' && errorObj.status >= 300 && errorObj.status < 400) { return true; } } @@ -52,27 +49,16 @@ export function isNotFoundResponse(error: unknown): boolean { } if (error && typeof error === 'object') { - const errorObj = error as { status?: number; statusCode?: number; type?: unknown }; + const errorObj = error as { status?: number; type?: unknown }; if (typeof errorObj.type === 'string' && (errorObj.type === 'not-found' || errorObj.type === 'notFound')) { return true; } - const status = errorObj.status ?? errorObj.statusCode; - if (status === 404) { + if (errorObj.status === 404) { return true; } } return false; } - -/** - * Safely flush events in serverless environments. - * Uses fire-and-forget pattern to avoid swallowing original errors. - */ -export function safeFlushServerless(flushFn: () => Promise): void { - flushFn().catch(e => { - DEBUG_BUILD && debug.warn('Failed to flush events in serverless environment', e); - }); -} diff --git a/packages/react-router/src/server/rsc/types.ts b/packages/react-router/src/server/rsc/types.ts index bc4bda7255dd..39141cf9c87d 100644 --- a/packages/react-router/src/server/rsc/types.ts +++ b/packages/react-router/src/server/rsc/types.ts @@ -1,118 +1,11 @@ -/** - * RSC payload types - mirrors the various payload types from react-router. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type RSCPayload = any; - -/** - * RSC match result - mirrors `RSCMatch` from react-router. - */ -export interface RSCMatch { - payload: RSCPayload; - statusCode: number; - headers: Headers; -} - -/** - * Decoded payload type for SSR rendering. - */ -export interface DecodedPayload { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - formState?: Promise; - _deepestRenderedBoundaryId?: string | null; -} - -/** - * Function types for RSC operations from react-server-dom packages. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type DecodeReplyFunction = (body: FormData | string, options?: any) => Promise; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type DecodeActionFunction = (body: FormData, options?: any) => Promise; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type DecodeFormStateFunction = (actionResult: any, body: FormData, options?: any) => Promise; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type LoadServerActionFunction = (id: string) => Promise; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type SSRCreateFromReadableStreamFunction = (stream: ReadableStream) => Promise; - -/** - * Arguments for `unstable_matchRSCServerRequest`. - */ -export interface MatchRSCServerRequestArgs { - /** Function that returns a temporary reference set for tracking references in RSC stream */ - createTemporaryReferenceSet: () => unknown; - /** The basename to use when matching the request */ - basename?: string; - /** Function to decode server function arguments */ - decodeReply?: DecodeReplyFunction; - /** Per-request context provider instance */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - requestContext?: any; - /** Function to load a server action by ID */ - loadServerAction?: LoadServerActionFunction; - /** Function to decode server actions */ - decodeAction?: DecodeActionFunction; - /** Function to decode form state for useActionState */ - decodeFormState?: DecodeFormStateFunction; - /** Error handler for request processing errors */ - onError?: (error: unknown) => void; - /** The Request to match against */ - request: Request; - /** Route definitions */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - routes: any[]; - /** Function to generate Response encoding the RSC payload */ - generateResponse: ( - match: RSCMatch, - options: { temporaryReferences: unknown; onError?: (error: unknown) => string | undefined }, - ) => Response; -} - -/** - * Function signature for `unstable_matchRSCServerRequest`. - */ -export type MatchRSCServerRequestFn = (args: MatchRSCServerRequestArgs) => Promise; - -/** - * Arguments for `unstable_routeRSCServerRequest`. - */ -export interface RouteRSCServerRequestArgs { - /** The incoming request to route */ - request: Request; - /** Function that forwards request to RSC handler and returns Response with RSC payload */ - fetchServer: (request: Request) => Promise; - /** Function to decode RSC payloads from server */ - createFromReadableStream: SSRCreateFromReadableStreamFunction; - /** Function that renders the payload to HTML */ - renderHTML: ( - getPayload: () => DecodedPayload & Promise, - ) => ReadableStream | Promise>; - /** Whether to hydrate the server response with RSC payload (default: true) */ - hydrate?: boolean; -} - -/** - * Function signature for `unstable_routeRSCServerRequest`. - */ -export type RouteRSCServerRequestFn = (args: RouteRSCServerRequestArgs) => Promise; - -/** - * Context for server component wrapping. - */ export interface ServerComponentContext { /** The parameterized route path (e.g., "/users/:id") */ componentRoute: string; - /** The type of component */ componentType: 'Page' | 'Layout' | 'Loading' | 'Error' | 'Template' | 'Not-found' | 'Unknown'; } -/** - * Options for server function wrapping. - */ export interface WrapServerFunctionOptions { /** Custom span name. Defaults to `serverFunction/{functionName}` */ name?: string; - /** Additional span attributes */ attributes?: Record; } diff --git a/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts b/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts deleted file mode 100644 index 6720f078de9e..000000000000 --- a/packages/react-router/src/server/rsc/wrapMatchRSCServerRequest.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { - captureException, - getActiveSpan, - getIsolationScope, - getRootSpan, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SPAN_STATUS_ERROR, - startSpan, -} from '@sentry/core'; -import { isAlreadyCaptured } from './responseUtils'; -import type { MatchRSCServerRequestArgs, MatchRSCServerRequestFn, RSCMatch } from './types'; - -/** - * Wraps `unstable_matchRSCServerRequest` from react-router 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()`. - * - * @param originalFn - The original `unstable_matchRSCServerRequest` function from react-router - * - * @example - * ```ts - * import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router"; - * import { wrapMatchRSCServerRequest } from "@sentry/react-router"; - * - * const sentryMatchRSCServerRequest = wrapMatchRSCServerRequest(matchRSCServerRequest); - * ``` - */ -export function wrapMatchRSCServerRequest(originalFn: MatchRSCServerRequestFn): MatchRSCServerRequestFn { - return async function sentryWrappedMatchRSCServerRequest(args: MatchRSCServerRequestArgs): Promise { - const { request, generateResponse, loadServerAction, onError, ...rest } = args; - - const url = new URL(request.url); - const isolationScope = getIsolationScope(); - isolationScope.setTransactionName(`RSC ${request.method} ${url.pathname}`); - - const activeSpan = getActiveSpan(); - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - if (rootSpan) { - rootSpan.setAttributes({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc', - 'rsc.request': true, - }); - } - } - - // Wrapped generateResponse that captures errors and creates spans for RSC rendering - const wrappedGenerateResponse = ( - match: RSCMatch, - options: { temporaryReferences: unknown; onError?: (error: unknown) => string | undefined }, - ): Response => { - return startSpan( - { - name: 'RSC Render', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.render', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.rsc', - 'rsc.status_code': match.statusCode, - }, - }, - span => { - try { - // Wrap the inner onError to capture RSC stream errors even when the caller - // does not provide an onError callback. - const originalOnError = options.onError; - const wrappedInnerOnError = (error: unknown): string | undefined => { - if (!isAlreadyCaptured(error)) { - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'generateResponse.onError', - }, - }, - }); - } - return originalOnError ? originalOnError(error) : undefined; - }; - - const response = generateResponse(match, { - ...options, - onError: wrappedInnerOnError, - }); - - return response; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - if (!isAlreadyCaptured(error)) { - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'generateResponse', - }, - }, - }); - } - throw error; - } - }, - ); - }; - - // Wrapped loadServerAction that traces server function loading and execution - const wrappedLoadServerAction = loadServerAction - ? async (actionId: string): Promise => { - return startSpan( - { - name: `Server Action: ${actionId}`, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.server_action', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.rsc.server_action', - 'rsc.action.id': actionId, - }, - }, - async span => { - try { - const result = await loadServerAction(actionId); - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - if (!isAlreadyCaptured(error)) { - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'loadServerAction', - action_id: actionId, - }, - }, - }); - } - throw error; - } - }, - ); - } - : undefined; - - // Outer onError handler — captures RSC server errors not already captured by inner wrappers - const wrappedOnError = (error: unknown): void => { - if (!isAlreadyCaptured(error)) { - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'matchRSCServerRequest.onError', - }, - }, - }); - } - - if (onError) { - onError(error); - } - }; - - return originalFn({ - ...rest, - request, - generateResponse: wrappedGenerateResponse, - loadServerAction: wrappedLoadServerAction, - onError: wrappedOnError, - }); - }; -} diff --git a/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts b/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts deleted file mode 100644 index 521b83b4d319..000000000000 --- a/packages/react-router/src/server/rsc/wrapRouteRSCServerRequest.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { - captureException, - getActiveSpan, - getIsolationScope, - getRootSpan, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SPAN_STATUS_ERROR, - startSpan, -} from '@sentry/core'; -import { isAlreadyCaptured } from './responseUtils'; -import type { DecodedPayload, RouteRSCServerRequestArgs, RouteRSCServerRequestFn, RSCPayload } from './types'; - -/** - * Wraps `unstable_routeRSCServerRequest` from react-router 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()`. - * - * @param originalFn - The original `unstable_routeRSCServerRequest` function from react-router - * - * @example - * ```ts - * import { unstable_routeRSCServerRequest as routeRSCServerRequest } from "react-router"; - * import { wrapRouteRSCServerRequest } from "@sentry/react-router"; - * - * const sentryRouteRSCServerRequest = wrapRouteRSCServerRequest(routeRSCServerRequest); - * ``` - */ -export function wrapRouteRSCServerRequest(originalFn: RouteRSCServerRequestFn): RouteRSCServerRequestFn { - return async function sentryWrappedRouteRSCServerRequest(args: RouteRSCServerRequestArgs): Promise { - const { request, renderHTML, fetchServer, ...rest } = args; - - const url = new URL(request.url); - const isolationScope = getIsolationScope(); - isolationScope.setTransactionName(`RSC SSR ${request.method} ${url.pathname}`); - - const activeSpan = getActiveSpan(); - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - if (rootSpan) { - rootSpan.setAttributes({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc.ssr', - 'rsc.ssr_request': true, - }); - } - } - - // Wrapped fetchServer that traces the RSC server fetch - const wrappedFetchServer = async (req: Request): Promise => { - return startSpan( - { - name: 'RSC Fetch Server', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client.rsc', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc.fetch', - }, - }, - async span => { - try { - const response = await fetchServer(req); - span.setAttributes({ - 'http.response.status_code': response.status, - }); - return response; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - if (!isAlreadyCaptured(error)) { - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'fetchServer', - }, - }, - }); - } - throw error; - } - }, - ); - }; - - // Wrapped renderHTML that traces the SSR rendering phase - const wrappedRenderHTML = ( - getPayload: () => DecodedPayload & Promise, - ): ReadableStream | Promise> => { - return startSpan( - { - name: 'RSC SSR Render HTML', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.ssr.render', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.rsc.ssr', - }, - }, - async span => { - try { - const result = await renderHTML(getPayload); - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - if (!isAlreadyCaptured(error)) { - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'renderHTML', - }, - }, - }); - } - throw error; - } - }, - ); - }; - - try { - return await originalFn({ - ...rest, - request, - fetchServer: wrappedFetchServer, - renderHTML: wrappedRenderHTML, - }); - } catch (error) { - // Only capture errors that weren't already captured by inner wrappers - if (!isAlreadyCaptured(error)) { - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'routeRSCServerRequest', - }, - }, - }); - } - throw error; - } - }; -} diff --git a/packages/react-router/src/server/rsc/wrapServerComponent.ts b/packages/react-router/src/server/rsc/wrapServerComponent.ts index 88fe3123d8f8..87ddf870647e 100644 --- a/packages/react-router/src/server/rsc/wrapServerComponent.ts +++ b/packages/react-router/src/server/rsc/wrapServerComponent.ts @@ -1,13 +1,14 @@ import { captureException, + debug, flushIfServerless, getActiveSpan, getIsolationScope, - handleCallbackErrors, SPAN_STATUS_ERROR, SPAN_STATUS_OK, } from '@sentry/core'; -import { isAlreadyCaptured, isNotFoundResponse, isRedirectResponse, safeFlushServerless } from './responseUtils'; +import { DEBUG_BUILD } from '../../common/debug-build'; +import { isAlreadyCaptured, isNotFoundResponse, isRedirectResponse } from './responseUtils'; import type { ServerComponentContext } from './types'; /** @@ -16,19 +17,16 @@ import type { ServerComponentContext } from './types'; * @experimental This API is experimental and may change in minor releases. * React Router RSC support requires React Router v7.9.0+ with `unstable_reactRouterRSC()`. * - * @param serverComponent - The server component function to wrap - * @param context - Context about the component for error reporting - * * @example * ```ts * import { wrapServerComponent } from "@sentry/react-router"; * - * async function _UserPage({ params }: Route.ComponentProps) { + * async function UserPage({ params }: Route.ComponentProps) { * const user = await getUser(params.id); * return ; * } * - * export const ServerComponent = wrapServerComponent(_UserPage, { + * export default wrapServerComponent(UserPage, { * componentRoute: "/users/:id", * componentType: "Page", * }); @@ -41,6 +39,8 @@ export function wrapServerComponent any>( ): 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(); @@ -48,65 +48,70 @@ export function wrapServerComponent any>( const transactionName = `${componentType} Server Component (${componentRoute})`; isolationScope.setTransactionName(transactionName); - return handleCallbackErrors( - () => originalFunction.apply(thisArg, args), - error => { - const span = getActiveSpan(); - let shouldCapture = true; + let result: ReturnType; + try { + result = originalFunction.apply(thisArg, args); + } catch (error) { + handleError(error, componentRoute, componentType); + flushIfServerless().catch(() => undefined); + throw error; + } - if (isRedirectResponse(error)) { - shouldCapture = false; - if (span) { - span.setStatus({ code: SPAN_STATUS_OK }); - } - } else if (isNotFoundResponse(error)) { - shouldCapture = false; - if (span) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - } - } else { - if (span) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_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); + } - if (shouldCapture && !isAlreadyCaptured(error)) { - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'ServerComponent', - component_route: componentRoute, - component_type: componentType, - }, - }, - }); - } - }, - () => { - safeFlushServerless(flushIfServerless); - }, - ); + return result; }, }); } -const VALID_COMPONENT_TYPES = new Set(['Page', 'Layout', 'Loading', 'Error', 'Template', 'Not-found', 'Unknown']); +function handleError(error: unknown, componentRoute: string, componentType: string): void { + const span = getActiveSpan(); -/** - * Type guard to check if a value is a valid ServerComponentContext. - */ -export function isServerComponentContext(value: unknown): value is ServerComponentContext { - if (!value || typeof value !== 'object') { - return false; + if (isRedirectResponse(error)) { + if (span) { + span.setStatus({ code: SPAN_STATUS_OK }); + } + return; } - const obj = value as Record; - return ( - typeof obj.componentRoute === 'string' && - obj.componentRoute.length > 0 && - typeof obj.componentType === 'string' && - VALID_COMPONENT_TYPES.has(obj.componentType) - ); + 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: 'instrument', + 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 index bb8554c95411..4d60de054923 100644 --- a/packages/react-router/src/server/rsc/wrapServerFunction.ts +++ b/packages/react-router/src/server/rsc/wrapServerFunction.ts @@ -1,5 +1,6 @@ import { captureException, + debug, flushIfServerless, getActiveSpan, getIsolationScope, @@ -10,7 +11,8 @@ import { SPAN_STATUS_OK, startSpan, } from '@sentry/core'; -import { isAlreadyCaptured, isNotFoundResponse, isRedirectResponse, safeFlushServerless } from './responseUtils'; +import { DEBUG_BUILD } from '../../common/debug-build'; +import { isAlreadyCaptured, isNotFoundResponse, isRedirectResponse } from './responseUtils'; import type { WrapServerFunctionOptions } from './types'; /** @@ -19,10 +21,6 @@ import type { WrapServerFunctionOptions } from './types'; * @experimental This API is experimental and may change in minor releases. * React Router RSC support requires React Router v7.9.0+ with `unstable_reactRouterRSC()`. * - * @param functionName - The name of the server function for identification in Sentry - * @param serverFunction - The server function to wrap - * @param options - Optional configuration for the span - * * @example * ```ts * // actions.ts @@ -44,116 +42,64 @@ export function wrapServerFunction Promise>( serverFunction: T, options: WrapServerFunctionOptions = {}, ): T { - const wrappedFunction = async function (this: unknown, ...args: Parameters): Promise> { - const spanName = options.name || `serverFunction/${functionName}`; + 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 isolationScope = getIsolationScope(); + isolationScope.setTransactionName(spanName); - // Check for active span to determine if this should be a new transaction or child span - const hasActiveSpan = !!getActiveSpan(); + 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, + 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 serverFunction.apply(this, args); - return result; - } catch (error) { - if (isRedirectResponse(error)) { - span.setStatus({ code: SPAN_STATUS_OK }); - throw error; - } + 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; - } + if (isNotFoundResponse(error)) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + throw error; + } - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - if (!isAlreadyCaptured(error)) { - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'serverFunction', - server_function_name: functionName, + if (!isAlreadyCaptured(error)) { + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'serverFunction', + server_function_name: functionName, + }, }, - }, - }); + }); + } + throw error; + } finally { + await flushIfServerless(); } - throw error; - } finally { - safeFlushServerless(flushIfServerless); - } - }, - ); - }; - - // Preserve the function name for debugging - Object.defineProperty(wrappedFunction, 'name', { - value: `sentryWrapped_${functionName}`, - configurable: true, + }, + ); + }, }); - - return wrappedFunction as T; -} - -/** - * Creates a wrapped version of a server function module. - * Useful for wrapping all exported server functions from a module. - * - * @experimental This API is experimental and may change in minor releases. - * React Router RSC support requires React Router v7.9.0+ with `unstable_reactRouterRSC()`. - * - * @param moduleName - The name of the module for identification - * @param serverFunctions - An object containing server functions - * @returns An object with all functions wrapped - * - * @example - * ```typescript - * // actions.ts - * "use server"; - * import { wrapServerFunctions } from "@sentry/react-router"; - * - * async function createUser(data: FormData) { ... } - * async function updateUser(data: FormData) { ... } - * async function deleteUser(id: string) { ... } - * - * export const actions = wrapServerFunctions("userActions", { - * createUser, - * updateUser, - * deleteUser, - * }); - * ``` - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function wrapServerFunctions Promise>>( - moduleName: string, - serverFunctions: T, -): T { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const wrapped: Record Promise> = {}; - - for (const [name, fn] of Object.entries(serverFunctions)) { - if (typeof fn === 'function') { - wrapped[name] = wrapServerFunction(`${moduleName}.${name}`, fn); - } else { - wrapped[name] = fn; - } - } - - return wrapped as T; } diff --git a/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts index 63a9ea3af69d..8648b37e1a0f 100644 --- a/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts +++ b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts @@ -1,118 +1,241 @@ 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'; -const JS_EXTENSIONS_RE = /\.(ts|tsx|js|jsx|mjs|mts)$/; +import t = recast.types.namedTypes; -/** Query parameter suffix used to load the original (unwrapped) module. */ +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 }; +} + /** - * Extracts a route path from a file path relative to the routes directory. - * - * Only supports filesystem-based nested directory routing - * (e.g., `app/routes/rsc/page.tsx` -> `/rsc/page`). - * - * Limitations: - * - Does not support React Router's dot-delimited flat file convention - * (e.g., `app/routes/rsc.page.tsx`). - * - Does not read React Router's route config, so manually configured routes - * that differ from the filesystem path will produce incorrect `componentRoute` values. - * - * Exported for testing. + * 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. */ -export function filePathToRoute(filePath: string, routesDirectory: string): string { - const normalizedPath = filePath.replace(/\\/g, '/'); - const normalizedRoutesDir = routesDirectory.replace(/\\/g, '/'); - - const withSlashes = `/${normalizedRoutesDir}/`; - let routesDirIndex = normalizedPath.lastIndexOf(withSlashes); - - if (routesDirIndex !== -1) { - routesDirIndex += 1; - } else if (normalizedPath.startsWith(`${normalizedRoutesDir}/`)) { - routesDirIndex = 0; - } else { - return '/'; +function collectNamedExports(node: BabelExportNamedDeclaration, into: Set): boolean { + if (node.exportKind === 'type') { + return false; } - let relativePath = normalizedPath.slice(routesDirIndex + normalizedRoutesDir.length); - if (relativePath.startsWith('/')) { - relativePath = relativePath.slice(1); - } + let hasDefault = false; - relativePath = relativePath.replace(/\.(tsx?|jsx?|mjs|mts)$/, ''); + const decl = node.declaration; + if (decl) { + if (decl.type === 'TSTypeAliasDeclaration' || decl.type === 'TSInterfaceDeclaration') { + return false; + } - if (relativePath.endsWith('/index')) { - relativePath = relativePath.slice(0, -6); - } else if (relativePath === 'index') { - relativePath = ''; + if (decl.type === 'VariableDeclaration') { + decl.declarations + .filter(declarator => declarator.type === 'VariableDeclarator' && declarator.id.type === 'Identifier') + .forEach(declarator => { + into.add((declarator.id as t.Identifier).name); + }); + } else { + const name = getDeclarationName(decl); + if (name) { + into.add(name); + } + } } - // Convert React Router's `$param` convention to `:param` for route matching - relativePath = relativePath.replace(/\$([^/]+)/g, ':$1'); + 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 `/${relativePath}`; + return hasDefault; } -/** Checks for a `'use client'` directive at the start of the module (after comments/whitespace). */ -function hasUseClientDirective(code: string): boolean { - const stripped = code.replace(/^(?:\s|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/, ''); - return /^(['"])use client\1/.test(stripped); +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; } -/** Checks whether the file already contains a manual `wrapServerComponent` call. */ -function hasManualWrapping(code: string): boolean { - return code.includes('wrapServerComponent('); +function getDeclarationName(decl: t.Declaration): string | undefined { + if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') { + const id = decl.id as t.Identifier | null | undefined; + return id?.type === 'Identifier' ? id.name : undefined; + } + return undefined; } -/** - * Naive check for `export default` — may match inside comments or strings. - * Acceptable for this experimental scope; a false positive causes the wrapper - * to import a non-existent default export, which produces a build error. - */ -function hasDefaultExport(code: string): boolean { - return /export\s+default\s+/.test(code); +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', + ); } /** - * Generates wrapper module code that re-exports the original component wrapped - * with `wrapServerComponent` via the `?sentry-rsc-wrap` virtual module suffix. + * 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 getWrapperCode(originalId: string, componentRoute: string): string { +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 wrapOptions = `{ componentRoute: ${JSON.stringify(componentRoute)}, componentType: 'Page' }`; - // The interpolation prevents ESLint's `quotes` rule from flagging the template literal. - return [ - `import { wrapServerComponent } from '${'@sentry/react-router'}';`, - `import _SentryComponent from ${wrappedId};`, - `export default wrapServerComponent(_SentryComponent, ${wrapOptions});`, - `export * from ${wrappedId};`, - ].join(''); + 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'); } -/** - * A Vite plugin that automatically instruments React Router RSC server components. - * - * Uses a virtual module pattern (similar to `@sentry/sveltekit`'s auto-instrumentation): - * instead of rewriting exports with regex, the plugin intercepts route files in the `transform` - * hook and replaces them with a thin wrapper module that imports the original file via a - * `?sentry-rsc-wrap` query suffix, wraps the default export, and re-exports everything else. - * - * TODO: The `?sentry-rsc-wrap` suffix may appear in stack traces. Consider adding a - * `rewriteFrames` integration rule to strip it for cleaner error reporting. - * - * @experimental This plugin is experimental and may change in minor releases. - * React Router RSC support requires React Router v7.9.0+ with `unstable_reactRouterRSC()`. - * - * RSC mode is auto-detected via `configResolved` by checking for the `react-router/rsc` - * Vite plugin. No explicit flag is needed — just use `sentryReactRouter({}, env)`. - */ +/** @experimental May change in minor releases. */ export function makeAutoInstrumentRSCPlugin(options: AutoInstrumentRSCOptions = {}): Plugin { - const { enabled = true, debug = false }: AutoInstrumentRSCOptions = options; - const normalizedRoutesDir = (options.routesDirectory ?? 'app/routes').replace(/\\/g, '/'); + const { enabled = true, debug = false } = options; let rscDetected = false; @@ -122,16 +245,12 @@ export function makeAutoInstrumentRSCPlugin(options: AutoInstrumentRSCOptions = configResolved(config) { rscDetected = config.plugins.some(p => p.name.startsWith('react-router/rsc')); - debug && - // eslint-disable-next-line no-console - console.log(`[Sentry RSC] RSC mode ${rscDetected ? 'detected' : 'not detected'}`); + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] RSC mode ${rscDetected ? 'detected' : 'not detected'}`); }, resolveId(source) { - if (source.includes(WRAPPED_MODULE_SUFFIX)) { - return source; - } - return null; + return source.includes(WRAPPED_MODULE_SUFFIX) ? source : null; }, async load(id: string) { @@ -142,9 +261,8 @@ export function makeAutoInstrumentRSCPlugin(options: AutoInstrumentRSCOptions = try { return await readFile(originalPath, 'utf-8'); } catch { - debug && - // eslint-disable-next-line no-console - console.log(`[Sentry RSC] Failed to read original file: ${originalPath}`); + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Failed to read original file: ${originalPath}`); return null; } }, @@ -153,45 +271,39 @@ export function makeAutoInstrumentRSCPlugin(options: AutoInstrumentRSCOptions = if (id.includes(WRAPPED_MODULE_SUFFIX)) { return null; } - if (!enabled || !rscDetected || !JS_EXTENSIONS_RE.test(id)) { return null; } - const normalizedId = id.replace(/\\/g, '/'); - - if (!normalizedId.includes(`/${normalizedRoutesDir}/`) && !normalizedId.startsWith(`${normalizedRoutesDir}/`)) { + const analysis = analyzeModule(code); + if (!analysis) { + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Skipping unparseable: ${id}`); return null; } - if (hasUseClientDirective(code)) { - debug && - // eslint-disable-next-line no-console - console.log(`[Sentry RSC] Skipping client component: ${id}`); + // Only handle "use server" files — server components must be wrapped manually + if (!analysis.hasUseServerDirective) { return null; } - if (hasManualWrapping(code)) { - debug && - // eslint-disable-next-line no-console - console.log(`[Sentry RSC] Skipping already wrapped: ${id}`); + if (analysis.hasManualServerFunctionWrapping) { + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Skipping already wrapped: ${id}`); return null; } - if (!hasDefaultExport(code)) { - debug && - // eslint-disable-next-line no-console - console.log(`[Sentry RSC] Skipping no default export: ${id}`); + 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 componentRoute = filePathToRoute(normalizedId, normalizedRoutesDir); - - debug && - // eslint-disable-next-line no-console - console.log(`[Sentry RSC] Auto-wrapping server component: ${id} -> ${componentRoute}`); - - return { code: getWrapperCode(id, componentRoute), map: 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/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 c2e80f5031b0..45ef383f5fd7 100644 --- a/packages/react-router/src/vite/types.ts +++ b/packages/react-router/src/vite/types.ts @@ -76,10 +76,10 @@ export type SentryReactRouterBuildOptions = BuildTimeOptionsBase & // todo(v11): Remove this option (all options already exist in BuildTimeOptionsBase) /** - * @experimental Options for automatic RSC (React Server Components) instrumentation. + * @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 customize behavior (e.g. `debug`, `routesDirectory`) or to explicitly - * disable with `{ enabled: false }`. + * 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; }; @@ -92,7 +92,7 @@ export type SentryReactRouterBuildOptions = BuildTimeOptionsBase & */ export type AutoInstrumentRSCOptions = { /** - * Enable or disable auto-instrumentation of server components. + * Enable or disable auto-instrumentation of server functions. * @default true */ enabled?: boolean; @@ -102,10 +102,4 @@ export type AutoInstrumentRSCOptions = { * @default false */ debug?: boolean; - - /** - * The directory containing route files, relative to the project root. - * @default 'app/routes' - */ - routesDirectory?: string; }; diff --git a/packages/react-router/test/server/rsc/responseUtils.test.ts b/packages/react-router/test/server/rsc/responseUtils.test.ts index c329b6bedbf2..630e7d44c062 100644 --- a/packages/react-router/test/server/rsc/responseUtils.test.ts +++ b/packages/react-router/test/server/rsc/responseUtils.test.ts @@ -1,11 +1,6 @@ import { addNonEnumerableProperty } from '@sentry/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - isAlreadyCaptured, - isNotFoundResponse, - isRedirectResponse, - safeFlushServerless, -} from '../../../src/server/rsc/responseUtils'; +import { describe, expect, it } from 'vitest'; +import { isAlreadyCaptured, isNotFoundResponse, isRedirectResponse } from '../../../src/server/rsc/responseUtils'; describe('responseUtils', () => { describe('isAlreadyCaptured', () => { @@ -19,110 +14,36 @@ describe('responseUtils', () => { expect(isAlreadyCaptured(error)).toBe(true); }); - it('should return false for null', () => { + it('should return false for non-object values', () => { expect(isAlreadyCaptured(null)).toBe(false); - }); - - it('should return false for undefined', () => { expect(isAlreadyCaptured(undefined)).toBe(false); - }); - - it('should return false for primitives', () => { expect(isAlreadyCaptured('string')).toBe(false); expect(isAlreadyCaptured(42)).toBe(false); }); - - it('should return false for a Proxy that throws on property access', () => { - const proxy = new Proxy( - {}, - { - get() { - throw new Error('proxy trap'); - }, - }, - ); - expect(isAlreadyCaptured(proxy)).toBe(false); - }); - - it('should return true for truthy non-boolean __sentry_captured__ values', () => { - const error = { __sentry_captured__: 1 }; - expect(isAlreadyCaptured(error)).toBe(true); - }); - - it('should return false for a frozen object without __sentry_captured__', () => { - const frozen = Object.freeze({ message: 'frozen error' }); - expect(isAlreadyCaptured(frozen)).toBe(false); - }); }); describe('isRedirectResponse', () => { - it('should return true for Response with 301 status', () => { - const response = new Response(null, { status: 301 }); - expect(isRedirectResponse(response)).toBe(true); - }); - - it('should return true for Response with 302 status', () => { - const response = new Response(null, { status: 302 }); - expect(isRedirectResponse(response)).toBe(true); - }); - - it('should return true for Response with 303 status', () => { - const response = new Response(null, { status: 303 }); - expect(isRedirectResponse(response)).toBe(true); + 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('should return true for Response with 307 status', () => { - const response = new Response(null, { status: 307 }); - expect(isRedirectResponse(response)).toBe(true); - }); - - it('should return true for Response with 308 status', () => { - const response = new Response(null, { status: 308 }); - expect(isRedirectResponse(response)).toBe(true); - }); - - it('should return false for Response with 200 status', () => { - const response = new Response(null, { status: 200 }); - expect(isRedirectResponse(response)).toBe(false); - }); - - it('should return false for Response with 404 status', () => { - const response = new Response(null, { status: 404 }); - expect(isRedirectResponse(response)).toBe(false); - }); - - it('should return false for Response with 500 status', () => { - const response = new Response(null, { status: 500 }); - expect(isRedirectResponse(response)).toBe(false); + 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', () => { - const error = { type: 'redirect', url: '/new-path' }; - expect(isRedirectResponse(error)).toBe(true); + expect(isRedirectResponse({ type: 'redirect', url: '/new-path' })).toBe(true); }); it('should return true for object with status in 3xx range', () => { - const error = { status: 302, location: '/new-path' }; - expect(isRedirectResponse(error)).toBe(true); + expect(isRedirectResponse({ status: 302, location: '/new-path' })).toBe(true); }); - it('should return true for object with statusCode in 3xx range', () => { - const error = { statusCode: 307, location: '/new-path' }; - expect(isRedirectResponse(error)).toBe(true); - }); - - it('should return false for null', () => { + it('should return false for non-object values', () => { expect(isRedirectResponse(null)).toBe(false); - }); - - it('should return false for undefined', () => { expect(isRedirectResponse(undefined)).toBe(false); - }); - - it('should return false for primitive values', () => { expect(isRedirectResponse('error')).toBe(false); expect(isRedirectResponse(42)).toBe(false); - expect(isRedirectResponse(true)).toBe(false); }); it('should return false for Error objects', () => { @@ -132,99 +53,27 @@ describe('responseUtils', () => { describe('isNotFoundResponse', () => { it('should return true for Response with 404 status', () => { - const response = new Response(null, { status: 404 }); - expect(isNotFoundResponse(response)).toBe(true); - }); - - it('should return false for Response with 200 status', () => { - const response = new Response(null, { status: 200 }); - expect(isNotFoundResponse(response)).toBe(false); + expect(isNotFoundResponse(new Response(null, { status: 404 }))).toBe(true); }); - it('should return false for Response with 500 status', () => { - const response = new Response(null, { status: 500 }); - expect(isNotFoundResponse(response)).toBe(false); + 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 false for Response with 302 status', () => { - const response = new Response(null, { status: 302 }); - expect(isNotFoundResponse(response)).toBe(false); - }); - - it('should return true for object with not-found type', () => { - const error = { type: 'not-found' }; - expect(isNotFoundResponse(error)).toBe(true); - }); - - it('should return true for object with notFound type', () => { - const error = { type: 'notFound' }; - expect(isNotFoundResponse(error)).toBe(true); + 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', () => { - const error = { status: 404 }; - expect(isNotFoundResponse(error)).toBe(true); + expect(isNotFoundResponse({ status: 404 })).toBe(true); }); - it('should return true for object with statusCode 404', () => { - const error = { statusCode: 404 }; - expect(isNotFoundResponse(error)).toBe(true); - }); - - it('should return false for null', () => { + it('should return false for non-object values', () => { expect(isNotFoundResponse(null)).toBe(false); - }); - - it('should return false for undefined', () => { expect(isNotFoundResponse(undefined)).toBe(false); - }); - - it('should return false for primitive values', () => { expect(isNotFoundResponse('error')).toBe(false); expect(isNotFoundResponse(42)).toBe(false); }); }); - - describe('safeFlushServerless', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should call the flush function', async () => { - const mockFlush = vi.fn().mockResolvedValue(undefined); - - safeFlushServerless(mockFlush); - - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(mockFlush).toHaveBeenCalled(); - }); - - it('should not throw when flush succeeds', () => { - const mockFlush = vi.fn().mockResolvedValue(undefined); - - expect(() => safeFlushServerless(mockFlush)).not.toThrow(); - }); - - it('should not throw when flush fails', async () => { - const mockFlush = vi.fn().mockRejectedValue(new Error('Flush failed')); - - expect(() => safeFlushServerless(mockFlush)).not.toThrow(); - - await new Promise(resolve => setTimeout(resolve, 0)); - }); - - it('should handle flush rejection gracefully', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const mockFlush = vi.fn().mockRejectedValue(new Error('Network error')); - - safeFlushServerless(mockFlush); - - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(mockFlush).toHaveBeenCalled(); - - consoleWarnSpy.mockRestore(); - }); - }); }); diff --git a/packages/react-router/test/server/rsc/wrapMatchRSCServerRequest.test.ts b/packages/react-router/test/server/rsc/wrapMatchRSCServerRequest.test.ts deleted file mode 100644 index 2504f1bedb54..000000000000 --- a/packages/react-router/test/server/rsc/wrapMatchRSCServerRequest.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import * as core from '@sentry/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { MatchRSCServerRequestArgs, MatchRSCServerRequestFn, RSCMatch } from '../../../src/server/rsc/types'; -import { wrapMatchRSCServerRequest } from '../../../src/server/rsc/wrapMatchRSCServerRequest'; - -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - startSpan: vi.fn(), - captureException: vi.fn(), - getIsolationScope: vi.fn(), - getActiveSpan: vi.fn(), - getRootSpan: vi.fn(), - }; -}); - -describe('wrapMatchRSCServerRequest', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - const createMockArgs = (): MatchRSCServerRequestArgs => ({ - request: new Request('http://test.com/users/123'), - routes: [{ path: '/users/:id' }], - createTemporaryReferenceSet: () => ({}), - generateResponse: vi.fn().mockReturnValue(new Response('test')), - }); - - it('should wrap the original function and call it with modified args', async () => { - const mockResponse = new Response('rsc payload'); - const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockResolvedValue(mockResponse); - const mockArgs = createMockArgs(); - const mockSetTransactionName = vi.fn(); - - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); - - const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); - const result = await wrappedFn(mockArgs); - - expect(result).toBe(mockResponse); - expect(mockOriginalFn).toHaveBeenCalledWith( - expect.objectContaining({ - request: mockArgs.request, - routes: mockArgs.routes, - }), - ); - expect(mockSetTransactionName).toHaveBeenCalledWith('RSC GET /users/123'); - }); - - it('should update root span attributes if active span exists', async () => { - const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockResolvedValue(new Response('test')); - const mockArgs = createMockArgs(); - const mockSetTransactionName = vi.fn(); - const mockSetAttributes = vi.fn(); - const mockRootSpan = { setAttributes: mockSetAttributes }; - const mockActiveSpan = {}; - - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); - (core.getActiveSpan as any).mockReturnValue(mockActiveSpan); - (core.getRootSpan as any).mockReturnValue(mockRootSpan); - (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); - - const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - expect(core.getRootSpan).toHaveBeenCalledWith(mockActiveSpan); - expect(mockSetAttributes).toHaveBeenCalledWith({ - [core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc', - 'rsc.request': true, - }); - }); - - it('should wrap generateResponse with a span and error capture', async () => { - const mockMatch: RSCMatch = { - payload: { data: 'test' }, - statusCode: 200, - headers: new Headers(), - }; - const mockGenerateResponse = vi.fn().mockReturnValue(new Response('generated')); - const mockArgs: MatchRSCServerRequestArgs = { - ...createMockArgs(), - generateResponse: mockGenerateResponse, - }; - - let capturedGenerateResponse: any; - const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { - capturedGenerateResponse = args.generateResponse; - return new Response('test'); - }); - - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((options: any, fn: any) => { - return fn({ setStatus: vi.fn() }); - }); - - const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - // Call the wrapped generateResponse - capturedGenerateResponse(mockMatch, { temporaryReferences: {} }); - - expect(mockGenerateResponse).toHaveBeenCalledWith(mockMatch, expect.objectContaining({ temporaryReferences: {} })); - expect(core.startSpan).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'RSC Render', - attributes: expect.objectContaining({ - [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.render', - 'rsc.status_code': 200, - }), - }), - expect.any(Function), - ); - }); - - it('should capture errors from generateResponse and set span status', async () => { - const testError = new Error('generateResponse failed'); - const mockGenerateResponse = vi.fn().mockImplementation(() => { - throw testError; - }); - const mockMatch: RSCMatch = { - payload: { data: 'test' }, - statusCode: 200, - headers: new Headers(), - }; - const mockArgs: MatchRSCServerRequestArgs = { - ...createMockArgs(), - generateResponse: mockGenerateResponse, - }; - - let capturedGenerateResponse: any; - const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { - capturedGenerateResponse = args.generateResponse; - return new Response('test'); - }); - - const mockSetStatus = vi.fn(); - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus })); - - const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - // Call the wrapped generateResponse and expect it to throw - expect(() => capturedGenerateResponse(mockMatch, { temporaryReferences: {} })).toThrow('generateResponse failed'); - - // Span status should be set to error - expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); - - // Error is captured in generateResponse catch block with error tracking to prevent double-capture - expect(core.captureException).toHaveBeenCalledWith(testError, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'generateResponse', - }, - }, - }); - }); - - it('should wrap loadServerAction with a span', async () => { - const mockServerAction = vi.fn(); - const mockLoadServerAction = vi.fn().mockResolvedValue(mockServerAction); - const mockArgs: MatchRSCServerRequestArgs = { - ...createMockArgs(), - loadServerAction: mockLoadServerAction, - }; - - let capturedLoadServerAction: any; - const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { - capturedLoadServerAction = args.loadServerAction; - return new Response('test'); - }); - - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); - - const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - // Call the wrapped loadServerAction - const result = await capturedLoadServerAction('my-action-id'); - - expect(result).toBe(mockServerAction); - expect(mockLoadServerAction).toHaveBeenCalledWith('my-action-id'); - expect(core.startSpan).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Server Action: my-action-id', - attributes: expect.objectContaining({ - [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.server_action', - 'rsc.action.id': 'my-action-id', - }), - }), - expect.any(Function), - ); - }); - - it('should capture errors from loadServerAction with action_id', async () => { - const mockError = new Error('loadServerAction failed'); - const mockLoadServerAction = vi.fn().mockRejectedValue(mockError); - const mockArgs: MatchRSCServerRequestArgs = { - ...createMockArgs(), - loadServerAction: mockLoadServerAction, - }; - - let capturedLoadServerAction: any; - const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { - capturedLoadServerAction = args.loadServerAction; - return new Response('test'); - }); - - const mockSetStatus = vi.fn(); - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus })); - - const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - // Call the wrapped loadServerAction and expect it to reject - await expect(capturedLoadServerAction('action-id')).rejects.toThrow('loadServerAction failed'); - - expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); - expect(core.captureException).toHaveBeenCalledWith(mockError, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'loadServerAction', - action_id: 'action-id', - }, - }, - }); - }); - - it('should enhance onError callback', async () => { - const originalOnError = vi.fn(); - const mockArgs: MatchRSCServerRequestArgs = { - ...createMockArgs(), - onError: originalOnError, - }; - - let capturedOnError: any; - const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { - capturedOnError = args.onError; - return new Response('test'); - }); - - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); - - const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - // Call the enhanced onError - const testError = new Error('test error'); - capturedOnError(testError); - - expect(originalOnError).toHaveBeenCalledWith(testError); - expect(core.captureException).toHaveBeenCalledWith(testError, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'matchRSCServerRequest.onError', - }, - }, - }); - }); - - it('should create onError handler even if not provided in args', async () => { - const mockArgs = createMockArgs(); - // Ensure no onError is provided - delete (mockArgs as any).onError; - - let capturedOnError: any; - const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { - capturedOnError = args.onError; - return new Response('test'); - }); - - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); - - const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - // onError should be created by the wrapper - expect(capturedOnError).toBeDefined(); - - // Calling it should capture the exception - const testError = new Error('test error'); - capturedOnError(testError); - expect(core.captureException).toHaveBeenCalledWith(testError, expect.any(Object)); - }); - - it('should not create loadServerAction wrapper if not provided', async () => { - const mockArgs = createMockArgs(); - delete (mockArgs as any).loadServerAction; - - let capturedArgs: any; - const mockOriginalFn: MatchRSCServerRequestFn = vi.fn().mockImplementation(async args => { - capturedArgs = args; - return new Response('test'); - }); - - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() })); - - const wrappedFn = wrapMatchRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - expect(capturedArgs.loadServerAction).toBeUndefined(); - }); -}); diff --git a/packages/react-router/test/server/rsc/wrapRouteRSCServerRequest.test.ts b/packages/react-router/test/server/rsc/wrapRouteRSCServerRequest.test.ts deleted file mode 100644 index 66a3af9553c9..000000000000 --- a/packages/react-router/test/server/rsc/wrapRouteRSCServerRequest.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import * as core from '@sentry/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { RouteRSCServerRequestArgs, RouteRSCServerRequestFn } from '../../../src/server/rsc/types'; -import { wrapRouteRSCServerRequest } from '../../../src/server/rsc/wrapRouteRSCServerRequest'; - -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - startSpan: vi.fn(), - captureException: vi.fn(), - getIsolationScope: vi.fn(), - getActiveSpan: vi.fn(), - getRootSpan: vi.fn(), - }; -}); - -describe('wrapRouteRSCServerRequest', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - const createMockArgs = (): RouteRSCServerRequestArgs => ({ - request: new Request('http://test.com/users/123'), - fetchServer: vi.fn().mockResolvedValue(new Response('server response')), - createFromReadableStream: vi.fn().mockResolvedValue({ data: 'decoded' }), - renderHTML: vi.fn().mockReturnValue(new ReadableStream()), - }); - - it('should wrap the original function and call it with modified args', async () => { - const mockResponse = new Response('html'); - const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockResolvedValue(mockResponse); - const mockArgs = createMockArgs(); - const mockSetTransactionName = vi.fn(); - - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), setAttributes: vi.fn() })); - - const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); - const result = await wrappedFn(mockArgs); - - expect(result).toBe(mockResponse); - expect(mockOriginalFn).toHaveBeenCalledWith( - expect.objectContaining({ - request: mockArgs.request, - }), - ); - expect(mockSetTransactionName).toHaveBeenCalledWith('RSC SSR GET /users/123'); - }); - - it('should update root span attributes if active span exists', async () => { - const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockResolvedValue(new Response('html')); - const mockArgs = createMockArgs(); - const mockSetTransactionName = vi.fn(); - const mockSetAttributes = vi.fn(); - const mockRootSpan = { setAttributes: mockSetAttributes }; - const mockActiveSpan = {}; - - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); - (core.getActiveSpan as any).mockReturnValue(mockActiveSpan); - (core.getRootSpan as any).mockReturnValue(mockRootSpan); - (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), setAttributes: vi.fn() })); - - const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - expect(core.getRootSpan).toHaveBeenCalledWith(mockActiveSpan); - expect(mockSetAttributes).toHaveBeenCalledWith({ - [core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc.ssr', - 'rsc.ssr_request': true, - }); - }); - - it('should wrap fetchServer with span and error capture', async () => { - const mockServerResponse = new Response('server response'); - const mockFetchServer = vi.fn().mockResolvedValue(mockServerResponse); - const mockArgs: RouteRSCServerRequestArgs = { - ...createMockArgs(), - fetchServer: mockFetchServer, - }; - - let capturedFetchServer: any; - const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockImplementation(async args => { - capturedFetchServer = args.fetchServer; - return new Response('html'); - }); - - const startSpanCalls: any[] = []; - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((options: any, fn: any) => { - startSpanCalls.push(options); - return fn({ setStatus: vi.fn(), setAttributes: vi.fn() }); - }); - - const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - // Call the wrapped fetchServer - const fetchRequest = new Request('http://test.com/api'); - const result = await capturedFetchServer(fetchRequest); - - expect(result).toBe(mockServerResponse); - expect(mockFetchServer).toHaveBeenCalledWith(fetchRequest); - - // Check that a span was created for fetchServer - const fetchServerSpan = startSpanCalls.find(call => call.name === 'RSC Fetch Server'); - expect(fetchServerSpan).toBeDefined(); - expect(fetchServerSpan.attributes).toEqual( - expect.objectContaining({ - [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client.rsc', - [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc.fetch', - }), - ); - }); - - it('should capture errors from fetchServer', async () => { - const mockError = new Error('fetchServer failed'); - const mockFetchServer = vi.fn().mockRejectedValue(mockError); - const mockArgs: RouteRSCServerRequestArgs = { - ...createMockArgs(), - fetchServer: mockFetchServer, - }; - - let capturedFetchServer: any; - const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockImplementation(async args => { - capturedFetchServer = args.fetchServer; - return new Response('html'); - }); - - const mockSetStatus = vi.fn(); - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((_: any, fn: any) => - fn({ setStatus: mockSetStatus, setAttributes: vi.fn() }), - ); - - const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - // Call the wrapped fetchServer and expect it to reject - const fetchRequest = new Request('http://test.com/api'); - await expect(capturedFetchServer(fetchRequest)).rejects.toThrow('fetchServer failed'); - - expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); - expect(core.captureException).toHaveBeenCalledWith(mockError, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'fetchServer', - }, - }, - }); - }); - - it('should wrap renderHTML with span', async () => { - const mockStream = new ReadableStream(); - const mockRenderHTML = vi.fn().mockResolvedValue(mockStream); - const mockArgs: RouteRSCServerRequestArgs = { - ...createMockArgs(), - renderHTML: mockRenderHTML, - }; - - let capturedRenderHTML: any; - const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockImplementation(async args => { - capturedRenderHTML = args.renderHTML; - return new Response('html'); - }); - - const startSpanCalls: any[] = []; - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((options: any, fn: any) => { - startSpanCalls.push(options); - return fn({ setStatus: vi.fn(), setAttributes: vi.fn() }); - }); - - const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - // Call the wrapped renderHTML - const getPayload = () => ({ formState: Promise.resolve(null) }); - const result = await capturedRenderHTML(getPayload); - - expect(result).toBe(mockStream); - expect(mockRenderHTML).toHaveBeenCalledWith(getPayload); - - // Check that a span was created for renderHTML - const renderHTMLSpan = startSpanCalls.find(call => call.name === 'RSC SSR Render HTML'); - expect(renderHTMLSpan).toBeDefined(); - expect(renderHTMLSpan.attributes).toEqual( - expect.objectContaining({ - [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.ssr.render', - [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.rsc.ssr', - }), - ); - }); - - it('should capture errors from renderHTML', async () => { - const mockError = new Error('renderHTML failed'); - const mockRenderHTML = vi.fn().mockRejectedValue(mockError); - const mockArgs: RouteRSCServerRequestArgs = { - ...createMockArgs(), - renderHTML: mockRenderHTML, - }; - - let capturedRenderHTML: any; - const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockImplementation(async args => { - capturedRenderHTML = args.renderHTML; - return new Response('html'); - }); - - const mockSetStatus = vi.fn(); - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((_: any, fn: any) => - fn({ setStatus: mockSetStatus, setAttributes: vi.fn() }), - ); - - const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - // Call the wrapped renderHTML and expect it to reject - const getPayload = () => ({ formState: Promise.resolve(null) }); - await expect(capturedRenderHTML(getPayload)).rejects.toThrow('renderHTML failed'); - - expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); - expect(core.captureException).toHaveBeenCalledWith(mockError, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'renderHTML', - }, - }, - }); - }); - - it('should capture uncaptured exceptions from the original function', async () => { - // Errors from fetchServer/renderHTML are captured in their wrappers and marked as captured. - // The outer try-catch captures any errors not already marked, preventing blind spots - // while avoiding double-capture. - const mockError = new Error('Original function failed'); - const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockRejectedValue(mockError); - const mockArgs = createMockArgs(); - - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), setAttributes: vi.fn() })); - - const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); - - // Error should propagate - await expect(wrappedFn(mockArgs)).rejects.toThrow('Original function failed'); - - // Error is captured by outer try-catch since it wasn't already captured by inner wrappers - expect(core.captureException).toHaveBeenCalledWith(mockError, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: 'routeRSCServerRequest', - }, - }, - }); - }); - - it('should set response status code attribute on fetchServer span', async () => { - const mockServerResponse = new Response('ok', { status: 200 }); - const mockFetchServer = vi.fn().mockResolvedValue(mockServerResponse); - const mockArgs: RouteRSCServerRequestArgs = { - ...createMockArgs(), - fetchServer: mockFetchServer, - }; - - let capturedFetchServer: any; - const mockOriginalFn: RouteRSCServerRequestFn = vi.fn().mockImplementation(async args => { - capturedFetchServer = args.fetchServer; - return new Response('html'); - }); - - const mockSetAttributes = vi.fn(); - (core.getIsolationScope as any).mockReturnValue({ setTransactionName: vi.fn() }); - (core.getActiveSpan as any).mockReturnValue(undefined); - (core.startSpan as any).mockImplementation((_: any, fn: any) => - fn({ setStatus: vi.fn(), setAttributes: mockSetAttributes }), - ); - - const wrappedFn = wrapRouteRSCServerRequest(mockOriginalFn); - await wrappedFn(mockArgs); - - // Call the wrapped fetchServer - const fetchRequest = new Request('http://test.com/api'); - await capturedFetchServer(fetchRequest); - - expect(mockSetAttributes).toHaveBeenCalledWith({ - 'http.response.status_code': 200, - }); - }); -}); diff --git a/packages/react-router/test/server/rsc/wrapServerComponent.test.ts b/packages/react-router/test/server/rsc/wrapServerComponent.test.ts index 40c803f10651..368c7f10020a 100644 --- a/packages/react-router/test/server/rsc/wrapServerComponent.test.ts +++ b/packages/react-router/test/server/rsc/wrapServerComponent.test.ts @@ -1,6 +1,6 @@ import * as core from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { isServerComponentContext, wrapServerComponent } from '../../../src/server/rsc/wrapServerComponent'; +import { wrapServerComponent } from '../../../src/server/rsc/wrapServerComponent'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -8,11 +8,10 @@ vi.mock('@sentry/core', async () => { ...actual, getIsolationScope: vi.fn(), getActiveSpan: vi.fn(), - handleCallbackErrors: vi.fn(), captureException: vi.fn(), flushIfServerless: vi.fn().mockResolvedValue(undefined), - SPAN_STATUS_OK: { code: 1, message: 'ok' }, - SPAN_STATUS_ERROR: { code: 2, message: 'internal_error' }, + SPAN_STATUS_OK: 1, + SPAN_STATUS_ERROR: 2, }; }); @@ -29,7 +28,6 @@ describe('wrapServerComponent', () => { (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName, }); - (core.handleCallbackErrors as any).mockImplementation((fn: any) => fn()); const wrappedComponent = wrapServerComponent(mockComponent, { componentRoute: '/users/:id', @@ -42,7 +40,7 @@ describe('wrapServerComponent', () => { expect(mockSetTransactionName).toHaveBeenCalledWith('Page Server Component (/users/:id)'); }); - it('should capture exceptions on error', () => { + it('should capture exceptions on sync error', () => { const mockError = new Error('Component render failed'); const mockComponent = vi.fn().mockImplementation(() => { throw mockError; @@ -54,16 +52,6 @@ describe('wrapServerComponent', () => { setTransactionName: mockSetTransactionName, }); (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); - (core.handleCallbackErrors as any).mockImplementation((fn: any, errorHandler: any, finallyHandler: any) => { - try { - return fn(); - } catch (error) { - errorHandler(error); - throw error; - } finally { - finallyHandler?.(); - } - }); const wrappedComponent = wrapServerComponent(mockComponent, { componentRoute: '/users/:id', @@ -85,6 +73,38 @@ describe('wrapServerComponent', () => { }); }); + 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: 'instrument', + 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, @@ -100,16 +120,6 @@ describe('wrapServerComponent', () => { setTransactionName: mockSetTransactionName, }); (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); - (core.handleCallbackErrors as any).mockImplementation((fn: any, errorHandler: any, finallyHandler: any) => { - try { - return fn(); - } catch (error) { - errorHandler(error); - throw error; - } finally { - finallyHandler?.(); - } - }); const wrappedComponent = wrapServerComponent(mockComponent, { componentRoute: '/users/:id', @@ -133,16 +143,6 @@ describe('wrapServerComponent', () => { setTransactionName: mockSetTransactionName, }); (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); - (core.handleCallbackErrors as any).mockImplementation((fn: any, errorHandler: any, finallyHandler: any) => { - try { - return fn(); - } catch (error) { - errorHandler(error); - throw error; - } finally { - finallyHandler?.(); - } - }); const wrappedComponent = wrapServerComponent(mockComponent, { componentRoute: '/users/:id', @@ -154,104 +154,74 @@ describe('wrapServerComponent', () => { expect(core.captureException).not.toHaveBeenCalled(); }); - it('should handle redirect-like objects with type property', () => { - const redirectObj = { type: 'redirect', location: '/new-path' }; - const mockComponent = vi.fn().mockImplementation(() => { - throw redirectObj; - }); - const mockSetStatus = vi.fn(); + 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, }); - (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); - (core.handleCallbackErrors as any).mockImplementation((fn: any, errorHandler: any, finallyHandler: any) => { - try { - return fn(); - } catch (error) { - errorHandler(error); - throw error; - } finally { - finallyHandler?.(); - } - }); const wrappedComponent = wrapServerComponent(mockComponent, { - componentRoute: '/users/:id', - componentType: 'Layout', + componentRoute: '/async-page', + componentType: 'Page', }); + const result = await wrappedComponent(); - expect(() => wrappedComponent()).toThrow(); - expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_OK }); - expect(core.captureException).not.toHaveBeenCalled(); + expect(result).toEqual(mockResult); + expect(mockSetTransactionName).toHaveBeenCalledWith('Page Server Component (/async-page)'); }); - it('should handle not-found objects with type property', () => { - const notFoundObj = { type: 'not-found' }; - const mockComponent = vi.fn().mockImplementation(() => { - throw notFoundObj; - }); - const mockSetStatus = vi.fn(); + 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, }); - (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); - (core.handleCallbackErrors as any).mockImplementation((fn: any, errorHandler: any, finallyHandler: any) => { - try { - return fn(); - } catch (error) { - errorHandler(error); - throw error; - } finally { - finallyHandler?.(); - } - }); const wrappedComponent = wrapServerComponent(mockComponent, { - componentRoute: '/users/:id', + componentRoute: '/page', componentType: 'Page', }); - expect(() => wrappedComponent()).toThrow(); - expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_ERROR, message: 'not_found' }); - expect(core.captureException).not.toHaveBeenCalled(); + expect(() => wrappedComponent()).not.toThrow(); }); - it('should work with async server components', async () => { - const mockResult = { type: 'div', props: { children: 'async content' } }; + 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, }); - (core.handleCallbackErrors as any).mockImplementation((fn: any) => fn()); const wrappedComponent = wrapServerComponent(mockComponent, { componentRoute: '/async-page', componentType: 'Page', }); - const result = await wrappedComponent(); + const result = wrappedComponent(); - expect(result).toEqual(mockResult); - expect(mockSetTransactionName).toHaveBeenCalledWith('Page Server Component (/async-page)'); + // 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 serverless environments', () => { + 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, }); - (core.handleCallbackErrors as any).mockImplementation((fn: any, _: any, finallyHandler: any) => { - const result = fn(); - finallyHandler?.(); - return result; - }); const wrappedComponent = wrapServerComponent(mockComponent, { componentRoute: '/page', @@ -273,16 +243,6 @@ describe('wrapServerComponent', () => { setTransactionName: mockSetTransactionName, }); (core.getActiveSpan as any).mockReturnValue(undefined); - (core.handleCallbackErrors as any).mockImplementation((fn: any, errorHandler: any, finallyHandler: any) => { - try { - return fn(); - } catch (error) { - errorHandler(error); - throw error; - } finally { - finallyHandler?.(); - } - }); const wrappedComponent = wrapServerComponent(mockComponent, { componentRoute: '/page', @@ -303,91 +263,37 @@ describe('wrapServerComponent', () => { (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName, }); - (core.handleCallbackErrors as any).mockImplementation((fn: any) => fn()); const wrappedComponent = wrapServerComponent(mockComponent, { componentRoute: '/page', componentType: 'Page', }); - // Proxy should preserve properties expect((wrappedComponent as any).displayName).toBe('MyComponent'); expect((wrappedComponent as any).customProp).toBe('value'); }); -}); - -describe('isServerComponentContext', () => { - it('should return true for valid context', () => { - expect( - isServerComponentContext({ - componentRoute: '/users/:id', - componentType: 'Page', - }), - ).toBe(true); - }); - it('should return false for null', () => { - expect(isServerComponentContext(null)).toBe(false); - }); - - it('should return false for undefined', () => { - expect(isServerComponentContext(undefined)).toBe(false); - }); + it('should not double-capture already-captured errors', () => { + const mockError = new Error('Already captured error'); + Object.defineProperty(mockError, '__sentry_captured__', { value: true, enumerable: false }); - it('should return false for non-object', () => { - expect(isServerComponentContext('string')).toBe(false); - expect(isServerComponentContext(123)).toBe(false); - }); - - it('should return false for missing componentRoute', () => { - expect( - isServerComponentContext({ - componentType: 'Page', - }), - ).toBe(false); - }); - - it('should return false for missing componentType', () => { - expect( - isServerComponentContext({ - componentRoute: '/users/:id', - }), - ).toBe(false); - }); - - it('should return false for non-string componentRoute', () => { - expect( - isServerComponentContext({ - componentRoute: 123, - componentType: 'Page', - }), - ).toBe(false); - }); + const mockComponent = vi.fn().mockImplementation(() => { + throw mockError; + }); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); - it('should return false for non-string componentType', () => { - expect( - isServerComponentContext({ - componentRoute: '/users/:id', - componentType: 123, - }), - ).toBe(false); - }); + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); - it('should return false for empty componentRoute', () => { - expect( - isServerComponentContext({ - componentRoute: '', - componentType: 'Page', - }), - ).toBe(false); - }); + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/page', + componentType: 'Page', + }); - it('should return false for invalid componentType not in VALID_COMPONENT_TYPES', () => { - expect( - isServerComponentContext({ - componentRoute: '/users/:id', - componentType: 'InvalidType', - }), - ).toBe(false); + 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 index e4c79e289743..ea2c5bdd86bd 100644 --- a/packages/react-router/test/server/rsc/wrapServerFunction.test.ts +++ b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts @@ -1,6 +1,6 @@ import * as core from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { wrapServerFunction, wrapServerFunctions } from '../../../src/server/rsc/wrapServerFunction'; +import { wrapServerFunction } from '../../../src/server/rsc/wrapServerFunction'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -49,6 +49,26 @@ describe('wrapServerFunction', () => { 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(); @@ -154,11 +174,18 @@ describe('wrapServerFunction', () => { expect(core.captureException).not.toHaveBeenCalled(); }); - it('should preserve function name', () => { - const mockServerFn = vi.fn().mockResolvedValue('result'); - const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + it('should preserve function properties via Proxy', () => { + const namedServerFn = Object.assign( + async function myServerAction(): Promise { + return 'result'; + }, + { customProp: 'value' }, + ); + const wrappedFn = wrapServerFunction('myServerAction', namedServerFn); - expect(wrappedFn.name).toBe('sentryWrapped_testFunction'); + // 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 () => { @@ -173,44 +200,22 @@ describe('wrapServerFunction', () => { await expect(wrappedFn()).rejects.toBe(mockError); }); -}); -describe('wrapServerFunctions', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + 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 }); - it('should wrap all functions in an object', async () => { - const mockFn1 = vi.fn().mockResolvedValue('result1'); - const mockFn2 = vi.fn().mockResolvedValue('result2'); + 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 wrapped = wrapServerFunctions('myModule', { - fn1: mockFn1, - fn2: mockFn2, - }); - - await wrapped.fn1(); - await wrapped.fn2(); - - expect(mockFn1).toHaveBeenCalled(); - expect(mockFn2).toHaveBeenCalled(); - expect(mockSetTransactionName).toHaveBeenCalledWith('serverFunction/myModule.fn1'); - expect(mockSetTransactionName).toHaveBeenCalledWith('serverFunction/myModule.fn2'); - }); - - it('should skip non-function values', () => { - const mockFn = vi.fn().mockResolvedValue('result'); - - const wrapped = wrapServerFunctions('myModule', { - fn: mockFn, - notAFunction: 'string value' as any, - }); + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); - expect(typeof wrapped.fn).toBe('function'); - expect(wrapped.notAFunction).toBe('string value'); + 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 index 7feab121dbab..4e116ac8de3f 100644 --- a/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts +++ b/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts @@ -1,8 +1,8 @@ import type { Plugin } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - filePathToRoute, - getWrapperCode, + analyzeModule, + getServerFunctionWrapperCode, makeAutoInstrumentRSCPlugin, } from '../../src/vite/makeAutoInstrumentRSCPlugin'; @@ -39,104 +39,6 @@ describe('makeAutoInstrumentRSCPlugin', () => { vi.resetModules(); }); - describe('filePathToRoute', () => { - it('converts a standard route path', () => { - expect(filePathToRoute('app/routes/rsc/server-component.tsx', 'app/routes')).toBe('/rsc/server-component'); - }); - - it('converts an index route to the parent directory path', () => { - expect(filePathToRoute('app/routes/performance/index.tsx', 'app/routes')).toBe('/performance'); - }); - - it('converts a root index route to /', () => { - expect(filePathToRoute('app/routes/index.tsx', 'app/routes')).toBe('/'); - }); - - it('converts deeply nested route paths', () => { - expect(filePathToRoute('app/routes/a/b/c.tsx', 'app/routes')).toBe('/a/b/c'); - }); - - it('normalizes Windows-style backslash paths', () => { - expect(filePathToRoute('app\\routes\\rsc\\server-component.tsx', 'app\\routes')).toBe('/rsc/server-component'); - }); - - it('uses a custom routes directory', () => { - expect(filePathToRoute('src/pages/dashboard/overview.tsx', 'src/pages')).toBe('/dashboard/overview'); - }); - - it('returns / when the routes directory is not found in the path', () => { - expect(filePathToRoute('other/directory/file.tsx', 'app/routes')).toBe('/'); - }); - - it('handles various file extensions', () => { - expect(filePathToRoute('app/routes/home.js', 'app/routes')).toBe('/home'); - expect(filePathToRoute('app/routes/home.jsx', 'app/routes')).toBe('/home'); - expect(filePathToRoute('app/routes/home.ts', 'app/routes')).toBe('/home'); - expect(filePathToRoute('app/routes/home.mjs', 'app/routes')).toBe('/home'); - expect(filePathToRoute('app/routes/home.mts', 'app/routes')).toBe('/home'); - }); - - it('handles absolute paths containing the routes directory', () => { - expect(filePathToRoute('/Users/dev/project/app/routes/dashboard.tsx', 'app/routes')).toBe('/dashboard'); - }); - - it('converts $param segments to :param', () => { - expect(filePathToRoute('app/routes/users/$userId.tsx', 'app/routes')).toBe('/users/:userId'); - }); - - it('converts multiple $param segments', () => { - expect(filePathToRoute('app/routes/$org/$repo/settings.tsx', 'app/routes')).toBe('/:org/:repo/settings'); - }); - - it('uses the last occurrence of the routes directory to determine path', () => { - expect(filePathToRoute('/project/routes-app/app/routes/page.tsx', 'routes')).toBe('/page'); - }); - - it('does not match partial directory names', () => { - expect(filePathToRoute('/project/my-routes/page.tsx', 'routes')).toBe('/'); - expect(filePathToRoute('/project/custom-routes/page.tsx', 'routes')).toBe('/'); - }); - - it('uses the correct path segment when a later directory starts with the routes directory name', () => { - expect(filePathToRoute('/project/routes/sub/routesXtra/page.tsx', 'routes')).toBe('/sub/routesXtra/page'); - }); - - it('does not interpret dot-delimited flat file convention (known limitation)', () => { - // React Router supports `routes/rsc.page.tsx` as a flat route for `/rsc/page`, - // but this function treats dots literally since it only supports directory-based routing. - expect(filePathToRoute('app/routes/rsc.page.tsx', 'app/routes')).toBe('/rsc.page'); - }); - }); - - describe('getWrapperCode', () => { - it('generates wrapper code with correct imports and exports', () => { - const result = getWrapperCode('/app/routes/page.tsx', '/page'); - - expect(result).toContain("import { wrapServerComponent } from '@sentry/react-router'"); - expect(result).toContain('import _SentryComponent from'); - expect(result).toContain('/app/routes/page.tsx?sentry-rsc-wrap'); - expect(result).toContain('componentRoute: "/page"'); - expect(result).toContain("componentType: 'Page'"); - expect(result).toContain('export default wrapServerComponent(_SentryComponent,'); - expect(result).toContain('export * from'); - }); - - it('handles route paths containing single quotes via JSON.stringify', () => { - const result = getWrapperCode('/app/routes/page.tsx', "/user's-page"); - expect(result).toContain('componentRoute: "/user\'s-page"'); - }); - - it('escapes backslashes in route paths', () => { - const result = getWrapperCode('/app/routes/page.tsx', '/path\\route'); - expect(result).toContain('componentRoute: "/path\\\\route"'); - }); - - it('uses JSON.stringify for the module id to handle special characters', () => { - const result = getWrapperCode('/app/routes/page.tsx', '/page'); - expect(result).toContain('"/app/routes/page.tsx?sentry-rsc-wrap"'); - }); - }); - describe('resolveId', () => { it('resolves modules with the wrapped suffix', () => { const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; @@ -177,10 +79,8 @@ describe('makeAutoInstrumentRSCPlugin', () => { const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; plugin.configResolved(RSC_PLUGINS_CONFIG); - const result = plugin.transform( - 'export default function Page() {\n return
Page
;\n}', - 'app/routes/home.tsx', - ); + const code = "'use server';\nexport async function myAction() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); expect(result).not.toBeNull(); }); @@ -188,20 +88,16 @@ describe('makeAutoInstrumentRSCPlugin', () => { const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; plugin.configResolved(NON_RSC_PLUGINS_CONFIG); - const result = plugin.transform( - 'export default function Page() {\n return
Page
;\n}', - 'app/routes/home.tsx', - ); + 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 result = plugin.transform( - 'export default function Page() {\n return
Page
;\n}', - 'app/routes/home.tsx', - ); + const code = "'use server';\nexport async function myAction() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); expect(result).toBeNull(); }); @@ -225,7 +121,8 @@ describe('makeAutoInstrumentRSCPlugin', () => { describe('transform', () => { it('returns null when disabled', () => { const plugin = makeAutoInstrumentRSCPlugin({ enabled: false }) as PluginWithHooks; - expect(plugin.transform('export default function Page() {}', 'app/routes/home.tsx')).toBeNull(); + 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', () => { @@ -233,187 +130,388 @@ describe('makeAutoInstrumentRSCPlugin', () => { expect(plugin.transform('some content', 'app/routes/styles.css')).toBeNull(); }); - it('returns null for files outside the routes directory', () => { - const plugin = createPluginWithRSCDetected(); - expect(plugin.transform('export default function Page() {}', 'app/components/MyComponent.tsx')).toBeNull(); - }); - - it('returns null for files in a directory with a similar prefix to the routes directory', () => { + it('returns null for wrapped module suffix (prevents infinite loop)', () => { const plugin = createPluginWithRSCDetected(); - expect(plugin.transform('export default function Page() {}', 'app/routes-archive/old.tsx')).toBeNull(); - }); - - it('returns null for files in directories that partially match the routes directory', () => { - const plugin = createPluginWithRSCDetected({ routesDirectory: 'routes' }); - expect(plugin.transform('export default function Page() {}', '/project/my-routes/page.tsx')).toBeNull(); + 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 wrapped module suffix (prevents infinite loop)', () => { + it('returns null for server components (no "use server" directive)', () => { const plugin = createPluginWithRSCDetected(); - const result = plugin.transform('export default function Page() {}', 'app/routes/home.tsx?sentry-rsc-wrap'); - expect(result).toBeNull(); + expect(plugin.transform('export default function Page() {}', 'app/routes/home.tsx')).toBeNull(); }); - it('returns null for files with "use client" directive', () => { + 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 with "use client" directive using double quotes', () => { + it('returns null for files without directives or exports', () => { const plugin = createPluginWithRSCDetected(); - const code = '"use client";\nexport default function ClientComponent() {}'; - expect(plugin.transform(code, 'app/routes/client.tsx')).toBeNull(); + expect(plugin.transform("export function helper() { return 'helper'; }", 'app/routes/utils.tsx')).toBeNull(); }); - it('returns null for files with "use client" preceded by line comments', () => { + // Server function auto-instrumentation ("use server" files) + it('wraps "use server" files with server function wrapper code', () => { const plugin = createPluginWithRSCDetected(); const code = [ - '// Copyright 2024 Company Inc.', - '// Licensed under MIT License', - '// See LICENSE file for details', - '// Generated by framework-codegen v3.2', - '// Do not edit manually', - "'use client';", - 'export default function ClientComponent() {}', + "'use server';", + 'export async function submitForm(data) { return data; }', + 'export async function getData() { return {}; }', ].join('\n'); - expect(plugin.transform(code, 'app/routes/client.tsx')).toBeNull(); + 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('returns null for files with "use client" preceded by a block comment', () => { + it('wraps "use server" files preceded by comments', () => { const plugin = createPluginWithRSCDetected(); - const code = "/* License header\n * spanning multiple lines\n */\n'use client';\nexport default function C() {}"; - expect(plugin.transform(code, 'app/routes/client.tsx')).toBeNull(); + 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 files already wrapped with wrapServerComponent', () => { + it('returns null for "use server" files with no named exports', () => { const plugin = createPluginWithRSCDetected(); - const code = - "import { wrapServerComponent } from '@sentry/react-router';\nexport default wrapServerComponent(MyComponent, {});"; - expect(plugin.transform(code, 'app/routes/home.tsx')).toBeNull(); + const code = "'use server';\nfunction internalHelper() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).toBeNull(); }); - it('returns null for files without a default export', () => { + it('skips "use server" files already containing wrapServerFunction', () => { const plugin = createPluginWithRSCDetected(); - expect(plugin.transform("export function helper() { return 'helper'; }", 'app/routes/utils.tsx')).toBeNull(); + 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('returns wrapper code for a server component with named function export', () => { + it('wraps "use server" files with export const pattern', () => { const plugin = createPluginWithRSCDetected(); - const result = plugin.transform( - 'export default function HomePage() {\n return
Home
;\n}', - 'app/routes/home.tsx', - ); + 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("import { wrapServerComponent } from '@sentry/react-router'"); - expect(result!.code).toContain('import _SentryComponent from'); - expect(result!.code).toContain('app/routes/home.tsx?sentry-rsc-wrap'); - expect(result!.code).toContain('componentRoute: "/home"'); - expect(result!.code).toContain("componentType: 'Page'"); - expect(result!.code).toContain('export default wrapServerComponent(_SentryComponent,'); - expect(result!.code).toContain('export * from'); - expect(result!.map).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('returns wrapper code for a server component with arrow function export', () => { + 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 result = plugin.transform('export default () =>
Arrow
', 'app/routes/arrow.tsx'); + 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('componentRoute: "/arrow"'); + expect(result!.code).toContain('export const namedAction = wrapServerFunction("namedAction"'); + expect(result!.code).toContain('export default wrapServerFunction("default", _sentry_original.default)'); }); - it('returns wrapper code for a server component with identifier export', () => { + it('wraps "use server" files with only default export', () => { const plugin = createPluginWithRSCDetected(); - const result = plugin.transform('function MyComponent() {}\nexport default MyComponent;', 'app/routes/ident.tsx'); + 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('componentRoute: "/ident"'); + expect(result!.code).toContain("'use server'"); + expect(result!.code).toContain('export default wrapServerFunction("default", _sentry_original.default)'); }); - it('returns wrapper code for a server component with anonymous function export', () => { + // 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 result = plugin.transform('export default function() { return
Anon
; }', 'app/routes/anon.tsx'); + 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('componentRoute: "/anon"'); + expect(result!.code).toContain('export const MyService = wrapServerFunction("MyService"'); }); - it('returns wrapper code for a server component with class export', () => { + // 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 result = plugin.transform('export default class MyComponent {}', 'app/routes/class-comp.tsx'); + 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('componentRoute: "/class-comp"'); + // 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('uses a custom routes directory', () => { - const plugin = createPluginWithRSCDetected({ routesDirectory: 'src/pages' }); - const result = plugin.transform( - 'export default function Dashboard() {\n return
Dashboard
;\n}', - 'src/pages/dashboard.tsx', - ); + 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('componentRoute: "/dashboard"'); + expect(result!.code).toContain('export const myAction = wrapServerFunction("myAction"'); }); - it.each(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts'])('wraps files with %s extension', ext => { + it.each(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts'])('wraps "use server" files with %s extension', ext => { const plugin = createPluginWithRSCDetected(); - const code = 'export default function Page() {\n return
Page
;\n}'; - const result = plugin.transform(code, `app/routes/home${ext}`); + 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('componentRoute: "/home"'); + expect(result!.code).toContain('wrapServerFunction'); }); - it('logs debug messages when debug is enabled and a client component is skipped', () => { - const plugin = createPluginWithRSCDetected({ debug: true }); - plugin.transform("'use client';\nexport default function C() {}", 'app/routes/client.tsx'); + 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).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Skipping client component:')); + expect(console.log).not.toHaveBeenCalled(); + // eslint-disable-next-line no-console + expect(console.warn).not.toHaveBeenCalled(); }); + }); - it('logs debug messages when a file is already wrapped', () => { - const plugin = createPluginWithRSCDetected({ debug: true }); - plugin.transform( - "import { wrapServerComponent } from '@sentry/react-router';\nexport default wrapServerComponent(Page, {});", - 'app/routes/wrapped.tsx', + 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); + }); - // eslint-disable-next-line no-console - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Skipping already wrapped:')); + // Parse failure + it('returns null for unparseable code', () => { + expect(analyzeModule('this is not valid {{{')).toBeNull(); }); + }); - it('logs debug messages when no default export is found', () => { - const plugin = createPluginWithRSCDetected({ debug: true }); - plugin.transform('export function helper() {}', 'app/routes/helper.tsx'); + describe('getServerFunctionWrapperCode', () => { + it('generates wrapper code with use server directive', () => { + const result = getServerFunctionWrapperCode('/app/routes/rsc/actions.ts', ['submitForm', 'getData']); - // eslint-disable-next-line no-console - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Skipping no default export:')); + 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('logs debug messages when wrapping succeeds', () => { - const plugin = createPluginWithRSCDetected({ debug: true }); - plugin.transform('export default function Page() {\n return
Page
;\n}', 'app/routes/home.tsx'); + it('wraps each named export with wrapServerFunction', () => { + const result = getServerFunctionWrapperCode('/app/routes/rsc/actions.ts', ['submitForm', 'getData']); - // eslint-disable-next-line no-console - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Auto-wrapping server component:')); + expect(result).toContain( + 'export const submitForm = wrapServerFunction("submitForm", _sentry_original["submitForm"])', + ); + expect(result).toContain('export const getData = wrapServerFunction("getData", _sentry_original["getData"])'); }); - it('does not log when debug is disabled', () => { - const plugin = createPluginWithRSCDetected({ debug: false }); + it('handles a single export', () => { + const result = getServerFunctionWrapperCode('/app/routes/rsc/actions.ts', ['myAction']); - plugin.transform("'use client';\nexport default function C() {}", 'app/routes/c.tsx'); - plugin.transform('export function helper() {}', 'app/routes/h.tsx'); - plugin.transform('export default function P() {}', 'app/routes/p.tsx'); + expect(result).toContain('export const myAction = wrapServerFunction("myAction", _sentry_original["myAction"])'); + }); - // eslint-disable-next-line no-console - expect(console.log).not.toHaveBeenCalled(); - // eslint-disable-next-line no-console - expect(console.warn).not.toHaveBeenCalled(); + 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)'); }); }); @@ -427,10 +525,8 @@ describe('makeAutoInstrumentRSCPlugin', () => { it('defaults to enabled when no options are provided', () => { const plugin = createPluginWithRSCDetected(); - const result = plugin.transform( - 'export default function Page() {\n return
Page
;\n}', - 'app/routes/home.tsx', - ); + const code = "'use server';\nexport async function action() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); expect(result).not.toBeNull(); }); From 141002a63ce5ebafb240a1555915059ad1c897fb Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 6 Feb 2026 16:02:55 +0000 Subject: [PATCH 11/13] Use `react_router.rsc` mechanism type for RSC error capture --- .../react-router-7-rsc/tests/rsc/server-component.test.ts | 2 +- .../react-router-7-rsc/tests/rsc/server-function.test.ts | 2 +- packages/react-router/src/server/rsc/wrapServerComponent.ts | 2 +- packages/react-router/src/server/rsc/wrapServerFunction.ts | 2 +- packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts | 4 +--- .../react-router/test/server/rsc/wrapServerComponent.test.ts | 4 ++-- .../react-router/test/server/rsc/wrapServerFunction.test.ts | 2 +- 7 files changed, 8 insertions(+), 10 deletions(-) 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 index 7856c710e8c0..522720fe7315 100644 --- 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 @@ -21,7 +21,7 @@ test.describe('RSC - Server Component Wrapper', () => { value: errorMessage, mechanism: { handled: false, - type: 'instrument', + type: 'react_router.rsc', data: { function: 'ServerComponent', component_route: '/rsc/server-component-error', 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 index 01dc34cf9eac..f72c33c881ef 100644 --- 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 @@ -95,7 +95,7 @@ test.describe('RSC - Server Function Wrapper', () => { value: errorMessage, mechanism: { handled: false, - type: 'instrument', + type: 'react_router.rsc', data: { function: 'serverFunction', server_function_name: 'submitFormWithError', diff --git a/packages/react-router/src/server/rsc/wrapServerComponent.ts b/packages/react-router/src/server/rsc/wrapServerComponent.ts index 87ddf870647e..06bd1d07eb11 100644 --- a/packages/react-router/src/server/rsc/wrapServerComponent.ts +++ b/packages/react-router/src/server/rsc/wrapServerComponent.ts @@ -104,7 +104,7 @@ function handleError(error: unknown, componentRoute: string, componentType: stri if (!isAlreadyCaptured(error)) { captureException(error, { mechanism: { - type: 'instrument', + type: 'react_router.rsc', handled: false, data: { function: 'ServerComponent', diff --git a/packages/react-router/src/server/rsc/wrapServerFunction.ts b/packages/react-router/src/server/rsc/wrapServerFunction.ts index 4d60de054923..0a0aab7ccdeb 100644 --- a/packages/react-router/src/server/rsc/wrapServerFunction.ts +++ b/packages/react-router/src/server/rsc/wrapServerFunction.ts @@ -85,7 +85,7 @@ export function wrapServerFunction Promise>( if (!isAlreadyCaptured(error)) { captureException(error, { mechanism: { - type: 'instrument', + type: 'react_router.rsc', handled: false, data: { function: 'serverFunction', diff --git a/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts index 8648b37e1a0f..b41debcda133 100644 --- a/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts +++ b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts @@ -109,9 +109,7 @@ function collectNamedExports(node: BabelExportNamedDeclaration, into: Set spec.type === 'ExportSpecifier' && (spec as BabelExportSpecifier).exportKind !== 'type', - ) + .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') { diff --git a/packages/react-router/test/server/rsc/wrapServerComponent.test.ts b/packages/react-router/test/server/rsc/wrapServerComponent.test.ts index 368c7f10020a..229d32ef0b6e 100644 --- a/packages/react-router/test/server/rsc/wrapServerComponent.test.ts +++ b/packages/react-router/test/server/rsc/wrapServerComponent.test.ts @@ -62,7 +62,7 @@ describe('wrapServerComponent', () => { expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_ERROR, message: 'internal_error' }); expect(core.captureException).toHaveBeenCalledWith(mockError, { mechanism: { - type: 'instrument', + type: 'react_router.rsc', handled: false, data: { function: 'ServerComponent', @@ -94,7 +94,7 @@ describe('wrapServerComponent', () => { expect(core.captureException).toHaveBeenCalledWith(mockError, { mechanism: { - type: 'instrument', + type: 'react_router.rsc', handled: false, data: { function: 'ServerComponent', diff --git a/packages/react-router/test/server/rsc/wrapServerFunction.test.ts b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts index ea2c5bdd86bd..6254a432a622 100644 --- a/packages/react-router/test/server/rsc/wrapServerFunction.test.ts +++ b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts @@ -128,7 +128,7 @@ describe('wrapServerFunction', () => { expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); expect(core.captureException).toHaveBeenCalledWith(mockError, { mechanism: { - type: 'instrument', + type: 'react_router.rsc', handled: false, data: { function: 'serverFunction', From c805760eb58aa372a27501132a9568e1ad7813f4 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 6 Feb 2026 16:38:06 +0000 Subject: [PATCH 12/13] Fix TypeScript build errors --- .../src/vite/makeAutoInstrumentRSCPlugin.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts index b41debcda133..76e0669a87e8 100644 --- a/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts +++ b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts @@ -94,11 +94,11 @@ function collectNamedExports(node: BabelExportNamedDeclaration, into: Set declarator.type === 'VariableDeclarator' && declarator.id.type === 'Identifier') - .forEach(declarator => { - into.add((declarator.id as t.Identifier).name); - }); + 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) { @@ -135,7 +135,7 @@ function getExportedName(node: t.Identifier | t.StringLiteral): string | undefin function getDeclarationName(decl: t.Declaration): string | undefined { if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') { - const id = decl.id as t.Identifier | null | undefined; + const id = (decl as t.FunctionDeclaration | t.ClassDeclaration).id; return id?.type === 'Identifier' ? id.name : undefined; } return undefined; From e10f4e4dd09697dedb5b195536bc06d095f7874d Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 6 Feb 2026 17:10:30 +0000 Subject: [PATCH 13/13] Fix E2E type error in waitForError callbacks after rebase --- .../react-router-7-rsc/tests/rsc/server-component.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 522720fe7315..7fd3e3cbd4c4 100644 --- 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 @@ -125,7 +125,7 @@ test.describe('RSC - Server Component Wrapper', () => { 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'); + return !!errorEvent?.request?.url?.includes('/rsc/server-component-redirect'); }); await page.goto('/rsc/server-component-redirect'); @@ -144,7 +144,7 @@ test.describe('RSC - Server Component Wrapper', () => { 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'); + return !!errorEvent?.request?.url?.includes('/rsc/server-component-not-found'); }); await page.goto('/rsc/server-component-not-found');