From 4e4090c8810375c8d156fa0d79b4d8769f6b7cba Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 24 Dec 2025 10:15:43 +0000 Subject: [PATCH 1/5] 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 acca80a94d81..1ea12e27c37c 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -11,3 +11,28 @@ export { wrapServerAction } from './wrapServerAction'; export { wrapServerLoader } from './wrapServerLoader'; export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError'; export { getMetaTagTransformer } from './getMetaTagTransformer'; + +// 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 30f52783534b5c22ccdf4e112af6db59c3c0e44a Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 20 Jan 2026 13:30:54 +0000 Subject: [PATCH 2/5] 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 1a962f080e442b7f624fbb98382f238365cf4f8d Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 20 Jan 2026 14:16:17 +0000 Subject: [PATCH 3/5] 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 1b1643077f702b287f1c338bc9bb810c2d8272eb Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 22 Jan 2026 17:18:44 +0000 Subject: [PATCH 4/5] 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 ba5c1c1264cb..5463d94d7ec6 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -8,6 +8,40 @@ export { reactRouterTracingIntegration } from './tracingIntegration'; 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 07fc2d6b9a4c736616ce438c3542c9047a45cddf Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 22 Jan 2026 17:34:43 +0000 Subject: [PATCH 5/5] 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",