From 89727f3bc4accd90ed77b1e689fab51f10307be7 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 24 Dec 2025 10:15:43 +0000 Subject: [PATCH 1/9] 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 84f02c4ed1152d4fe3784e5f0707d5de09fd57b8 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 20 Jan 2026 13:30:54 +0000 Subject: [PATCH 2/9] 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 a63d6bb562c3e97f1fd8f64445eb9f54a111fda9 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 20 Jan 2026 14:16:17 +0000 Subject: [PATCH 3/9] 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 362c0421aa31ae1d4802c7726d511de5df61a276 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 22 Jan 2026 17:18:44 +0000 Subject: [PATCH 4/9] 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 8745f1c9b5d64a993ab04dbbacb3aea2995488e4 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 22 Jan 2026 17:34:43 +0000 Subject: [PATCH 5/9] 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 c94138a27963be22530082b32e4883935bd7d8ef Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 4 Feb 2026 00:53:19 +0000 Subject: [PATCH 6/9] 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 969efcc4282005bb4a7514408bfc0d15b53c0869 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 5 Feb 2026 14:06:18 +0000 Subject: [PATCH 7/9] 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 28b09ce54b1d7c415117f7356f35688d421777bc Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 5 Feb 2026 14:06:40 +0000 Subject: [PATCH 8/9] 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 e7dd3613313a79abed284327d51d79f36d7a16b5 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 5 Feb 2026 16:19:45 +0000 Subject: [PATCH 9/9] 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:')); }); });