diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.env b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.env
new file mode 100644
index 000000000000..9b8dc350a98d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.env
@@ -0,0 +1,2 @@
+SESSION_SECRET = "foo"
+PUBLIC_STORE_DOMAIN="mock.shop"
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.eslintignore b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.eslintignore
new file mode 100644
index 000000000000..a362bcaa13b5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.eslintignore
@@ -0,0 +1,5 @@
+build
+node_modules
+bin
+*.d.ts
+dist
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.eslintrc.cjs b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.eslintrc.cjs
new file mode 100644
index 000000000000..85eb86d14b9e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.eslintrc.cjs
@@ -0,0 +1,79 @@
+/**
+ * This is intended to be a basic starting point for linting in your app.
+ * It relies on recommended configs out of the box for simplicity, but you can
+ * and should modify this configuration to best suit your team's needs.
+ */
+
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ root: true,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ env: {
+ browser: true,
+ commonjs: true,
+ es6: true,
+ },
+
+ // Base config
+ extends: ['eslint:recommended'],
+
+ overrides: [
+ // React
+ {
+ files: ['**/*.{js,jsx,ts,tsx}'],
+ plugins: ['react', 'jsx-a11y'],
+ extends: [
+ 'plugin:react/recommended',
+ 'plugin:react/jsx-runtime',
+ 'plugin:react-hooks/recommended',
+ 'plugin:jsx-a11y/recommended',
+ ],
+ settings: {
+ react: {
+ version: 'detect',
+ },
+ formComponents: ['Form'],
+ linkComponents: [
+ { name: 'Link', linkAttribute: 'to' },
+ { name: 'NavLink', linkAttribute: 'to' },
+ ],
+ 'import/resolver': {
+ typescript: {},
+ },
+ },
+ },
+
+ // Typescript
+ {
+ files: ['**/*.{ts,tsx}'],
+ plugins: ['@typescript-eslint', 'import'],
+ parser: '@typescript-eslint/parser',
+ settings: {
+ 'import/internal-regex': '^~/',
+ 'import/resolver': {
+ node: {
+ extensions: ['.ts', '.tsx'],
+ },
+ typescript: {
+ alwaysTryTypes: true,
+ },
+ },
+ },
+ extends: ['plugin:@typescript-eslint/recommended', 'plugin:import/recommended', 'plugin:import/typescript'],
+ },
+
+ // Node
+ {
+ files: ['.eslintrc.cjs', 'server.ts'],
+ env: {
+ node: true,
+ },
+ },
+ ],
+};
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.gitignore b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.gitignore
new file mode 100644
index 000000000000..bbd6215c8760
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.gitignore
@@ -0,0 +1,10 @@
+node_modules
+/.cache
+/build
+/dist
+/public/build
+/.mf
+!.env
+.shopify
+storefrontapi.generated.d.ts
+.react-router
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.npmrc b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.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/hydrogen-react-router-7/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/entry.client.tsx
new file mode 100644
index 000000000000..9c48e56befe8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/entry.client.tsx
@@ -0,0 +1,22 @@
+import { HydratedRouter } from 'react-router/dom';
+import * as Sentry from '@sentry/react-router/cloudflare';
+import { StrictMode, startTransition } from 'react';
+import { hydrateRoot } from 'react-dom/client';
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ // Could not find a working way to set the DSN in the browser side from the environment variables
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.reactRouterTracingIntegration()],
+ tracesSampleRate: 1.0,
+ tunnel: 'http://localhost:3031/', // proxy server
+});
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ );
+});
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/entry.server.tsx
new file mode 100644
index 000000000000..c2410fe87b26
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/entry.server.tsx
@@ -0,0 +1,54 @@
+import '../instrument.server';
+import { HandleErrorFunction, ServerRouter } from 'react-router';
+import { createContentSecurityPolicy } from '@shopify/hydrogen';
+import type { EntryContext } from '@shopify/remix-oxygen';
+import { renderToReadableStream } from 'react-dom/server';
+import * as Sentry from '@sentry/react-router/cloudflare';
+
+async function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ reactRouterContext: EntryContext,
+) {
+ const { nonce, header, NonceProvider } = createContentSecurityPolicy({
+ connectSrc: [
+ // Need to allow the proxy server to fetch the data
+ 'http://localhost:3031/',
+ ],
+ });
+
+ const body = Sentry.injectTraceMetaTags(await renderToReadableStream(
+
+
+ ,
+ {
+ nonce,
+ signal: request.signal,
+ },
+ ));
+
+ responseHeaders.set('Content-Type', 'text/html');
+ responseHeaders.set('Content-Security-Policy', header);
+
+ // Add the document policy header to enable JS profiling
+ // This is required for Sentry's profiling integration
+ responseHeaders.set('Document-Policy', 'js-profiling');
+
+ return new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ });
+}
+
+export const handleError: HandleErrorFunction = (error, { request }) => {
+ // React Router may abort some interrupted requests, don't log those
+ if (!request.signal.aborted) {
+ Sentry.captureException(error);
+ // optionally log the error so you can see it
+ console.error(error);
+ }
+};
+
+
+export default Sentry.wrapSentryHandleRequest(handleRequest);
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/fragments.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/fragments.ts
new file mode 100644
index 000000000000..ccf430475620
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/fragments.ts
@@ -0,0 +1,174 @@
+// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/cart
+export const CART_QUERY_FRAGMENT = `#graphql
+ fragment Money on MoneyV2 {
+ currencyCode
+ amount
+ }
+ fragment CartLine on CartLine {
+ id
+ quantity
+ attributes {
+ key
+ value
+ }
+ cost {
+ totalAmount {
+ ...Money
+ }
+ amountPerQuantity {
+ ...Money
+ }
+ compareAtAmountPerQuantity {
+ ...Money
+ }
+ }
+ merchandise {
+ ... on ProductVariant {
+ id
+ availableForSale
+ compareAtPrice {
+ ...Money
+ }
+ price {
+ ...Money
+ }
+ requiresShipping
+ title
+ image {
+ id
+ url
+ altText
+ width
+ height
+
+ }
+ product {
+ handle
+ title
+ id
+ vendor
+ }
+ selectedOptions {
+ name
+ value
+ }
+ }
+ }
+ }
+ fragment CartApiQuery on Cart {
+ updatedAt
+ id
+ checkoutUrl
+ totalQuantity
+ buyerIdentity {
+ countryCode
+ customer {
+ id
+ email
+ firstName
+ lastName
+ displayName
+ }
+ email
+ phone
+ }
+ lines(first: $numCartLines) {
+ nodes {
+ ...CartLine
+ }
+ }
+ cost {
+ subtotalAmount {
+ ...Money
+ }
+ totalAmount {
+ ...Money
+ }
+ totalDutyAmount {
+ ...Money
+ }
+ totalTaxAmount {
+ ...Money
+ }
+ }
+ note
+ attributes {
+ key
+ value
+ }
+ discountCodes {
+ code
+ applicable
+ }
+ }
+` as const;
+
+const MENU_FRAGMENT = `#graphql
+ fragment MenuItem on MenuItem {
+ id
+ resourceId
+ tags
+ title
+ type
+ url
+ }
+ fragment ChildMenuItem on MenuItem {
+ ...MenuItem
+ }
+ fragment ParentMenuItem on MenuItem {
+ ...MenuItem
+ items {
+ ...ChildMenuItem
+ }
+ }
+ fragment Menu on Menu {
+ id
+ items {
+ ...ParentMenuItem
+ }
+ }
+` as const;
+
+export const HEADER_QUERY = `#graphql
+ fragment Shop on Shop {
+ id
+ name
+ description
+ primaryDomain {
+ url
+ }
+ brand {
+ logo {
+ image {
+ url
+ }
+ }
+ }
+ }
+ query Header(
+ $country: CountryCode
+ $headerMenuHandle: String!
+ $language: LanguageCode
+ ) @inContext(language: $language, country: $country) {
+ shop {
+ ...Shop
+ }
+ menu(handle: $headerMenuHandle) {
+ ...Menu
+ }
+ }
+ ${MENU_FRAGMENT}
+` as const;
+
+export const FOOTER_QUERY = `#graphql
+ query Footer(
+ $country: CountryCode
+ $footerMenuHandle: String!
+ $language: LanguageCode
+ ) @inContext(language: $language, country: $country) {
+ menu(handle: $footerMenuHandle) {
+ ...Menu
+ }
+ }
+ ${MENU_FRAGMENT}
+` as const;
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/session.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/session.ts
new file mode 100644
index 000000000000..80d6e7b86b52
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/session.ts
@@ -0,0 +1,61 @@
+import type { HydrogenSession } from '@shopify/hydrogen';
+import { type Session, type SessionStorage, createCookieSessionStorage } from '@shopify/remix-oxygen';
+
+/**
+ * This is a custom session implementation for your Hydrogen shop.
+ * Feel free to customize it to your needs, add helper methods, or
+ * swap out the cookie-based implementation with something else!
+ */
+export class AppSession implements HydrogenSession {
+ #sessionStorage;
+ #session;
+
+ constructor(sessionStorage: SessionStorage, session: Session) {
+ this.#sessionStorage = sessionStorage;
+ this.#session = session;
+ }
+
+ static async init(request: Request, secrets: string[]) {
+ const storage = createCookieSessionStorage({
+ cookie: {
+ name: 'session',
+ httpOnly: true,
+ path: '/',
+ sameSite: 'lax',
+ secrets,
+ },
+ });
+
+ const session = await storage.getSession(request.headers.get('Cookie')).catch(() => storage.getSession());
+
+ return new this(storage, session);
+ }
+
+ get has() {
+ return this.#session.has;
+ }
+
+ get get() {
+ return this.#session.get;
+ }
+
+ get flash() {
+ return this.#session.flash;
+ }
+
+ get unset() {
+ return this.#session.unset;
+ }
+
+ get set() {
+ return this.#session.set;
+ }
+
+ destroy() {
+ return this.#sessionStorage.destroySession(this.#session);
+ }
+
+ commit() {
+ return this.#sessionStorage.commitSession(this.#session);
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/variants.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/variants.ts
new file mode 100644
index 000000000000..6fddd5f66ee0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/variants.ts
@@ -0,0 +1,41 @@
+import { useLocation } from 'react-router';
+import type { SelectedOption } from '@shopify/hydrogen/storefront-api-types';
+import { useMemo } from 'react';
+
+export function useVariantUrl(handle: string, selectedOptions: SelectedOption[]) {
+ const { pathname } = useLocation();
+
+ return useMemo(() => {
+ return getVariantUrl({
+ handle,
+ pathname,
+ searchParams: new URLSearchParams(),
+ selectedOptions,
+ });
+ }, [handle, selectedOptions, pathname]);
+}
+
+export function getVariantUrl({
+ handle,
+ pathname,
+ searchParams,
+ selectedOptions,
+}: {
+ handle: string;
+ pathname: string;
+ searchParams: URLSearchParams;
+ selectedOptions: SelectedOption[];
+}) {
+ const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname);
+ const isLocalePathname = match && match.length > 0;
+
+ const path = isLocalePathname ? `${match![0]}products/${handle}` : `/products/${handle}`;
+
+ selectedOptions.forEach(option => {
+ searchParams.set(option.name, option.value);
+ });
+
+ const searchString = searchParams.toString();
+
+ return path + (searchString ? '?' + searchParams.toString() : '');
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx
new file mode 100644
index 000000000000..e38f97bd3f06
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx
@@ -0,0 +1,196 @@
+import * as Sentry from '@sentry/react-router/cloudflare';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {
+ Outlet,
+ isRouteErrorResponse,
+ type ShouldRevalidateFunction,
+ Links,
+ Meta,
+ Scripts,
+ ScrollRestoration,
+} from 'react-router';
+import {FOOTER_QUERY, HEADER_QUERY} from '~/lib/fragments';
+
+import {useNonce} from '@shopify/hydrogen';
+
+export type RootLoader = typeof loader;
+
+/**
+ * This is important to avoid re-fetching root queries on sub-navigations
+ */
+export const shouldRevalidate: ShouldRevalidateFunction = ({
+ formMethod,
+ currentUrl,
+ nextUrl,
+}) => {
+ // revalidate when a mutation is performed e.g add to cart, login...
+ if (formMethod && formMethod !== 'GET') return true;
+
+ // revalidate when manually revalidating via useRevalidator
+ if (currentUrl.toString() === nextUrl.toString()) return true;
+
+ // Defaulting to no revalidation for root loader data to improve performance.
+ // When using this feature, you risk your UI getting out of sync with your server.
+ // Use with caution. If you are uncomfortable with this optimization, update the
+ // line below to `return defaultShouldRevalidate` instead.
+ // For more details see: https://remix.run/docs/en/main/route/should-revalidate
+ return false;
+};
+
+/**
+ * The main and reset stylesheets are added in the Layout component
+ * to prevent a bug in development HMR updates.
+ *
+ * This avoids the "failed to execute 'insertBefore' on 'Node'" error
+ * that occurs after editing and navigating to another page.
+ *
+ * It's a temporary fix until the issue is resolved.
+ * https://github.com/remix-run/remix/issues/9242
+ */
+export function links() {
+ return [
+ {
+ rel: 'preconnect',
+ href: 'https://cdn.shopify.com',
+ },
+ {
+ rel: 'preconnect',
+ href: 'https://shop.app',
+ },
+ ];
+}
+
+export async function loader(args: LoaderFunctionArgs) {
+ // Start fetching non-critical data without blocking time to first byte
+ const deferredData = loadDeferredData(args);
+
+ // Await the critical data required to render initial state of the page
+ const criticalData = await loadCriticalData(args);
+
+ const {env} = args.context;
+
+ return {
+ ...deferredData,
+ ...criticalData,
+ ENV: {
+ sentryTrace: env.SENTRY_TRACE,
+ sentryBaggage: env.SENTRY_BAGGAGE,
+ },
+ publicStoreDomain: env.PUBLIC_STORE_DOMAIN,
+ consent: {
+ checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN,
+ storefrontAccessToken: env.PUBLIC_STOREFRONT_API_TOKEN,
+ withPrivacyBanner: false,
+ // localize the privacy banner
+ country: args.context.storefront.i18n.country,
+ language: args.context.storefront.i18n.language,
+ },
+ };
+}
+
+/**
+ * Load data necessary for rendering content above the fold. This is the critical data
+ * needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
+ */
+async function loadCriticalData({context}: LoaderFunctionArgs) {
+ const {storefront} = context;
+
+ const [header] = await Promise.all([
+ storefront.query(HEADER_QUERY, {
+ cache: storefront.CacheLong(),
+ variables: {
+ headerMenuHandle: 'main-menu', // Adjust to your header menu handle
+ },
+ }),
+ // Add other queries here, so that they are loaded in parallel
+ ]);
+
+ return {header};
+}
+
+/**
+ * Load data for rendering content below the fold. This data is deferred and will be
+ * fetched after the initial page load. If it's unavailable, the page should still 200.
+ * Make sure to not throw any errors here, as it will cause the page to 500.
+ */
+function loadDeferredData({context}: LoaderFunctionArgs) {
+ const {storefront, customerAccount, cart} = context;
+
+ // defer the footer query (below the fold)
+ const footer = storefront
+ .query(FOOTER_QUERY, {
+ cache: storefront.CacheLong(),
+ variables: {
+ footerMenuHandle: 'footer', // Adjust to your footer menu handle
+ },
+ })
+ .catch((error: any) => {
+ // Log query errors, but don't throw them so the page can still render
+ console.error(error);
+ return null;
+ });
+ return {
+ cart: cart.get(),
+ isLoggedIn: customerAccount.isLoggedIn(),
+ footer,
+ };
+}
+
+export function Layout({children}: {children?: React.ReactNode}) {
+ const nonce = useNonce();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function App() {
+ return ;
+}
+
+export function ErrorBoundary({
+ error
+}: {
+ error: unknown
+}) {
+ let errorMessage = 'Unknown error';
+ let errorStatus = 500;
+
+ const eventId = Sentry.captureException(error);
+
+ if (isRouteErrorResponse(error)) {
+ errorMessage = error?.data?.message ?? error.data;
+ errorStatus = error.status;
+ } else if (error instanceof Error) {
+ errorMessage = error.message;
+ }
+
+ return (
+
+
Oops
+
{errorStatus}
+ {errorMessage && (
+
+ )}
+ {eventId && (
+
+ Sentry Event ID: {eventId}
+
+ )}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes.ts
new file mode 100644
index 000000000000..f717956345d0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes.ts
@@ -0,0 +1,9 @@
+import {flatRoutes} from '@react-router/fs-routes';
+import {type RouteConfig} from '@react-router/dev/routes';
+import {hydrogenRoutes} from '@shopify/hydrogen';
+
+export default hydrogenRoutes([
+ ...(await flatRoutes()),
+ // Manual route definitions can be added to this array, in addition to or instead of using the `flatRoutes` file-based routing convention.
+ // See https://remix.run/docs/en/main/guides/routing for more details
+]) satisfies RouteConfig;
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/_index.tsx
new file mode 100644
index 000000000000..75e0a32629a2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/_index.tsx
@@ -0,0 +1,35 @@
+import { Link, useSearchParams } from 'react-router';
+import * as Sentry from '@sentry/react-router/cloudflare';
+
+declare global {
+ interface Window {
+ capturedExceptionId?: string;
+ }
+}
+
+export default function Index() {
+ const [searchParams] = useSearchParams();
+
+ if (searchParams.get('tag')) {
+ Sentry.setTags({
+ sentry_test: searchParams.get('tag'),
+ });
+ }
+
+ return (
+
+ {
+ const eventId = Sentry.captureException(new Error('I am an error!'));
+ window.capturedExceptionId = eventId;
+ }}
+ />
+
+ navigate
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/action-formdata.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/action-formdata.tsx
new file mode 100644
index 000000000000..c109c9119030
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/action-formdata.tsx
@@ -0,0 +1,16 @@
+import { Form } from 'react-router';
+
+export async function action() {
+ return { message: 'success' };
+}
+
+export default function ActionFormData() {
+ return (
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/client-error.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/client-error.tsx
new file mode 100644
index 000000000000..aeb37f8c2acb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/client-error.tsx
@@ -0,0 +1,15 @@
+export default function ErrorBoundaryCapture() {
+ return (
+
+
Client Error Page
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/loader-error.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/loader-error.tsx
new file mode 100644
index 000000000000..1548c38084ad
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/loader-error.tsx
@@ -0,0 +1,16 @@
+import { useLoaderData } from 'react-router';
+import type { LoaderFunction } from '@shopify/remix-oxygen';
+
+export default function LoaderError() {
+ useLoaderData();
+
+ return (
+
+
Loader Error
+
+ );
+}
+
+export const loader: LoaderFunction = () => {
+ throw new Error('Loader Error');
+};
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/navigate.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/navigate.tsx
new file mode 100644
index 000000000000..06ca3d7f2ae0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/navigate.tsx
@@ -0,0 +1,20 @@
+import { useLoaderData } from 'react-router';
+import type { LoaderFunction } from '@shopify/remix-oxygen';
+
+export const loader: LoaderFunction = async ({ params: { id } }) => {
+ if (id === '-1') {
+ throw new Error('Unexpected Server Error');
+ }
+
+ return null;
+};
+
+export default function LoaderError() {
+ const data = useLoaderData() as { test?: string };
+
+ return (
+
+
{data && data.test ? data.test : 'Not Found'}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/user.$id.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/user.$id.tsx
new file mode 100644
index 000000000000..13b2e0a34d1e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/user.$id.tsx
@@ -0,0 +1,3 @@
+export default function User() {
+ return I am a blank page
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/utils.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/utils.ts
new file mode 100644
index 000000000000..6fddd5f66ee0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/utils.ts
@@ -0,0 +1,41 @@
+import { useLocation } from 'react-router';
+import type { SelectedOption } from '@shopify/hydrogen/storefront-api-types';
+import { useMemo } from 'react';
+
+export function useVariantUrl(handle: string, selectedOptions: SelectedOption[]) {
+ const { pathname } = useLocation();
+
+ return useMemo(() => {
+ return getVariantUrl({
+ handle,
+ pathname,
+ searchParams: new URLSearchParams(),
+ selectedOptions,
+ });
+ }, [handle, selectedOptions, pathname]);
+}
+
+export function getVariantUrl({
+ handle,
+ pathname,
+ searchParams,
+ selectedOptions,
+}: {
+ handle: string;
+ pathname: string;
+ searchParams: URLSearchParams;
+ selectedOptions: SelectedOption[];
+}) {
+ const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname);
+ const isLocalePathname = match && match.length > 0;
+
+ const path = isLocalePathname ? `${match![0]}products/${handle}` : `/products/${handle}`;
+
+ selectedOptions.forEach(option => {
+ searchParams.set(option.name, option.value);
+ });
+
+ const searchString = searchParams.toString();
+
+ return path + (searchString ? '?' + searchParams.toString() : '');
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/env.d.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/env.d.ts
new file mode 100644
index 000000000000..ce37d9f3c464
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/env.d.ts
@@ -0,0 +1,59 @@
+///
+///
+///
+
+// Enhance TypeScript's built-in typings.
+import '@total-typescript/ts-reset';
+
+import type { CustomerAccount, HydrogenCart, HydrogenSessionData, Storefront } from '@shopify/hydrogen';
+import type { AppSession } from '~/lib/session';
+
+declare global {
+ /**
+ * A global `process` object is only available during build to access NODE_ENV.
+ */
+ const process: { env: { NODE_ENV: 'production' | 'development' } };
+
+ /**
+ * Declare expected Env parameter in fetch handler.
+ */
+ interface Env {
+ SESSION_SECRET: string;
+ PUBLIC_STOREFRONT_API_TOKEN: string;
+ PRIVATE_STOREFRONT_API_TOKEN: string;
+ PUBLIC_STORE_DOMAIN: string;
+ PUBLIC_STOREFRONT_ID: string;
+ PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string;
+ PUBLIC_CUSTOMER_ACCOUNT_API_URL: string;
+ PUBLIC_CHECKOUT_DOMAIN: string;
+ }
+}
+
+declare module 'react-router' {
+ /**
+ * Declare local additions to the Remix loader context.
+ */
+ interface AppLoadContext {
+ env: Env;
+ cart: HydrogenCart;
+ storefront: Storefront;
+ customerAccount: CustomerAccount;
+ session: AppSession;
+ waitUntil: ExecutionContext['waitUntil'];
+ }
+
+ // TODO: remove this once we've migrated to `Route.LoaderArgs` for our loaders
+ interface LoaderFunctionArgs {
+ context: AppLoadContext;
+ }
+
+ // TODO: remove this once we've migrated to `Route.ActionArgs` for our actions
+ interface ActionFunctionArgs {
+ context: AppLoadContext;
+ }
+
+ /**
+ * Declare local additions to the Remix session data.
+ */
+ interface SessionData extends HydrogenSessionData {}
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/globals.d.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/globals.d.ts
new file mode 100644
index 000000000000..4130ac6a8a09
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/globals.d.ts
@@ -0,0 +1,7 @@
+interface Window {
+ recordedTransactions?: string[];
+ capturedExceptionId?: string;
+ ENV: {
+ SENTRY_DSN: string;
+ };
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/instrument.server.mjs b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/instrument.server.mjs
new file mode 100644
index 000000000000..044e50e1b86c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/instrument.server.mjs
@@ -0,0 +1,9 @@
+import * as Sentry from "@sentry/react-router";
+Sentry.init({
+ dsn: "https://examplePublicKey@o0.ingest.sentry.io/0",
+ // Adds request headers and IP for users, for more info visit:
+ // https://docs.sentry.io/platforms/javascript/guides/react-router/configuration/options/#sendDefaultPii
+ sendDefaultPii: true,
+ tracesSampleRate: 1.0,
+ tunnel: `http://localhost:3031/`, // proxy server
+});
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json
new file mode 100644
index 000000000000..75b4f35ae507
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json
@@ -0,0 +1,58 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "build": "pnpm typecheck && shopify hydrogen build --codegen",
+ "dev": "shopify hydrogen dev --codegen",
+ "preview": "shopify hydrogen preview",
+ "lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .",
+ "typecheck": "tsc",
+ "codegen": "shopify hydrogen codegen",
+ "clean": "npx rimraf node_modules dist pnpm-lock.yaml",
+ "test:build": "pnpm install && npx playwright install && pnpm build",
+ "test:assert": "pnpm playwright test"
+ },
+ "prettier": "@shopify/prettier-config",
+ "dependencies": {
+ "@sentry/cloudflare": "latest || *",
+ "@sentry/react-router": "latest || *",
+ "@sentry/vite-plugin": "^3.1.2",
+ "@shopify/hydrogen": "2025.5.0",
+ "@shopify/remix-oxygen": "^3.0.0",
+ "graphql": "^16.10.0",
+ "graphql-tag": "^2.12.6",
+ "isbot": "^5.1.22",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router": "^7.6.0",
+ "react-router-dom": "^7.6.0"
+ },
+ "devDependencies": {
+ "@graphql-codegen/cli": "5.0.2",
+ "@playwright/test": "~1.53.2",
+ "@react-router/dev": "7.6.0",
+ "@react-router/fs-routes": "7.6.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@shopify/cli": "3.80.4",
+ "@shopify/hydrogen-codegen": "^0.3.3",
+ "@shopify/mini-oxygen": "3.2.1",
+ "@shopify/oxygen-workers-types": "^4.1.6",
+ "@shopify/prettier-config": "^1.1.2",
+ "@tailwindcss/vite": "4.0.0-alpha.17",
+ "@total-typescript/ts-reset": "^0.4.2",
+ "@types/eslint": "^8.4.10",
+ "@types/react": "^18.2.22",
+ "@types/react-dom": "^18.2.7",
+ "esbuild": "0.25.0",
+ "eslint": "^9.18.0",
+ "eslint-plugin-hydrogen": "0.12.2",
+ "prettier": "^3.4.2",
+ "typescript": "^5.2.2",
+ "vite": "^6.2.4",
+ "vite-tsconfig-paths": "^4.3.1"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/playwright.config.mjs
new file mode 100644
index 000000000000..700607cc6f95
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/playwright.config.mjs
@@ -0,0 +1,8 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm run preview`,
+ port: 3000,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/public/favicon.ico b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/public/favicon.ico
new file mode 100644
index 000000000000..f6c649733d68
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/public/favicon.ico
@@ -0,0 +1,28 @@
+
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/public/favicon.svg b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/public/favicon.svg
new file mode 100644
index 000000000000..f6c649733d68
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/public/favicon.svg
@@ -0,0 +1,28 @@
+
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/react-router.config.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/react-router.config.ts
new file mode 100644
index 000000000000..5c25f23ad404
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/react-router.config.ts
@@ -0,0 +1,14 @@
+import type {Config} from '@react-router/dev/config';
+import { sentryOnBuildEnd } from '@sentry/react-router';
+
+export default {
+ appDirectory: 'app',
+ buildDirectory: 'dist',
+ ssr: true,
+ buildEnd: async ({ viteConfig, reactRouterConfig, buildManifest }) => {
+ // ...
+ // Call this at the end of the hook
+ (await sentryOnBuildEnd({ viteConfig, reactRouterConfig, buildManifest }));
+ }
+} satisfies Config;
+
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/server.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/server.ts
new file mode 100644
index 000000000000..07638d967cf7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/server.ts
@@ -0,0 +1,132 @@
+import {
+ cartGetIdDefault,
+ cartSetIdDefault,
+ createCartHandler,
+ createCustomerAccountClient,
+ createStorefrontClient,
+ storefrontRedirect,
+} from '@shopify/hydrogen';
+import { type AppLoadContext, createRequestHandler, getStorefrontHeaders } from '@shopify/remix-oxygen';
+import { CART_QUERY_FRAGMENT } from '~/lib/fragments';
+import { AppSession } from '~/lib/session';
+import { wrapRequestHandler } from '@sentry/cloudflare';
+
+/**
+ * Export a fetch handler in module format.
+ */
+type Env = {
+ SESSION_SECRET: string;
+ PUBLIC_STOREFRONT_API_TOKEN: string;
+ PRIVATE_STOREFRONT_API_TOKEN: string;
+ PUBLIC_STORE_DOMAIN: string;
+ PUBLIC_STOREFRONT_ID: string;
+ PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string;
+ PUBLIC_CUSTOMER_ACCOUNT_API_URL: string;
+ // Add any other environment variables your app expects here
+};
+
+export default {
+ async fetch(request: Request, env: Env, executionContext: ExecutionContext): Promise {
+ return wrapRequestHandler(
+ {
+ options: {
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ tracesSampleRate: 1.0,
+ tunnel: `http://localhost:3031/`, // proxy server
+ },
+ // Need to cast to any because this is not on cloudflare
+ request: request as any,
+ context: executionContext,
+ },
+ async () => {
+ try {
+ /**
+ * Open a cache instance in the worker and a custom session instance.
+ */
+ if (!env?.SESSION_SECRET) {
+ throw new Error('SESSION_SECRET environment variable is not set');
+ }
+
+ const waitUntil = executionContext.waitUntil.bind(executionContext);
+ const [cache, session] = await Promise.all([
+ caches.open('hydrogen'),
+ AppSession.init(request, [env.SESSION_SECRET]),
+ ]);
+
+ /**
+ * Create Hydrogen's Storefront client.
+ */
+ const { storefront } = createStorefrontClient({
+ cache,
+ waitUntil,
+ i18n: { language: 'EN', country: 'US' },
+ publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN,
+ privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
+ storeDomain: env.PUBLIC_STORE_DOMAIN,
+ storefrontId: env.PUBLIC_STOREFRONT_ID,
+ storefrontHeaders: getStorefrontHeaders(request),
+ });
+
+ /**
+ * Create a client for Customer Account API.
+ */
+ const customerAccount = createCustomerAccountClient({
+ waitUntil,
+ request,
+ session,
+ customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID,
+ shopId: env.PUBLIC_STORE_DOMAIN,
+ });
+
+ /*
+ * Create a cart handler that will be used to
+ * create and update the cart in the session.
+ */
+ const cart = createCartHandler({
+ storefront,
+ customerAccount,
+ getCartId: cartGetIdDefault(request.headers),
+ setCartId: cartSetIdDefault(),
+ cartQueryFragment: CART_QUERY_FRAGMENT,
+ });
+
+ /**
+ * Create a Remix request handler and pass
+ * Hydrogen's Storefront client to the loader context.
+ */
+ const handleRequest = createRequestHandler({
+ // @ts-ignore
+ build: await import('virtual:react-router/server-build'),
+ mode: process.env.NODE_ENV,
+ getLoadContext: (): AppLoadContext => ({
+ session,
+ storefront,
+ customerAccount,
+ cart,
+ env,
+ waitUntil,
+ }),
+ });
+
+ const response = await handleRequest(request);
+
+ if (response.status === 404) {
+ /**
+ * Check for redirects only when there's a 404 from the app.
+ * If the redirect doesn't exist, then `storefrontRedirect`
+ * will pass through the 404 response.
+ */
+ return storefrontRedirect({ request, response, storefront });
+ }
+
+ return response;
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(error);
+ return new Response('An unexpected error occurred', { status: 500 });
+ }
+ },
+ );
+ },
+};
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/start-event-proxy.mjs
new file mode 100644
index 000000000000..da1e39797ee0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'hydrogen-react-router-7',
+});
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/client-errors.test.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/client-errors.test.ts
new file mode 100644
index 000000000000..a14347cdc519
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/client-errors.test.ts
@@ -0,0 +1,32 @@
+import { expect, test } from '@playwright/test';
+import { waitForError } from '@sentry-internal/test-utils';
+
+test('Sends a client-side exception to Sentry', async ({ page }) => {
+ const errorPromise = waitForError('hydrogen-react-router-7', errorEvent => {
+ return errorEvent.exception?.values?.[0].value === 'I am an error!';
+ });
+
+ await page.goto('/');
+
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ const errorEvent = await errorPromise;
+
+ expect(errorEvent).toBeDefined();
+});
+
+test('Sends a client-side ErrorBoundary exception to Sentry', async ({ page }) => {
+ const errorPromise = waitForError('hydrogen-react-router-7', errorEvent => {
+ return errorEvent.exception?.values?.[0].value === 'Sentry React Component Error';
+ });
+
+ await page.goto('/client-error');
+
+ const throwButton = page.locator('id=throw-on-click');
+ await throwButton.click();
+
+ const errorEvent = await errorPromise;
+
+ expect(errorEvent).toBeDefined();
+});
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/client-transactions.test.ts
new file mode 100644
index 000000000000..1adb44011d07
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/client-transactions.test.ts
@@ -0,0 +1,60 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Sends a pageload transaction to Sentry', async ({ page }) => {
+ const transactionPromise = waitForTransaction('hydrogen-react-router-7', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/';
+ });
+
+ await page.goto('/');
+
+ const transactionEvent = await transactionPromise;
+
+ expect(transactionEvent).toBeDefined();
+});
+
+test('Sends a navigation transaction to Sentry', async ({ page }) => {
+ const transactionPromise = waitForTransaction('hydrogen-react-router-7', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/user/:id';
+ });
+
+ await page.goto('/');
+
+ const linkElement = page.locator('id=navigation');
+ await linkElement.click();
+
+ const transactionEvent = await transactionPromise;
+
+ expect(transactionEvent).toBeDefined();
+ expect(transactionEvent).toMatchObject({
+ transaction: '/user/:id',
+ });
+});
+
+test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => {
+ await page.goto('/');
+
+ const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
+ state: 'attached',
+ });
+ const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
+ state: 'attached',
+ });
+
+ expect(sentryTraceMetaTag).toBeTruthy();
+ expect(baggageMetaTag).toBeTruthy();
+});
+
+test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => {
+ await page.goto('/user/123');
+
+ const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
+ state: 'attached',
+ });
+ const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
+ state: 'attached',
+ });
+
+ expect(sentryTraceMetaTag).toBeTruthy();
+ expect(baggageMetaTag).toBeTruthy();
+});
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/server-transactions.test.ts
new file mode 100644
index 000000000000..0455ea2e0b79
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/server-transactions.test.ts
@@ -0,0 +1,53 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test.describe.configure({ mode: 'serial' });
+
+test('Sends parameterized transaction name to Sentry', async ({ page }) => {
+ const transactionPromise = waitForTransaction('hydrogen-react-router-7', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'http.server';
+ });
+
+ await page.goto('/user/123');
+
+ const transaction = await transactionPromise;
+
+ expect(transaction).toBeDefined();
+ expect(transaction.transaction).toBe('GET /user/123');
+});
+
+test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => {
+ // We use this to identify the transactions
+ const testTag = crypto.randomUUID();
+
+ const httpServerTransactionPromise = waitForTransaction('hydrogen-react-router-7', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.tags?.['sentry_test'] === testTag;
+ });
+
+ const pageLoadTransactionPromise = waitForTransaction('hydrogen-react-router-7', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag;
+ });
+
+ page.goto(`/?tag=${testTag}`);
+
+ const pageloadTransaction = await pageLoadTransactionPromise;
+ const httpServerTransaction = await httpServerTransactionPromise;
+
+ expect(pageloadTransaction).toBeDefined();
+ expect(httpServerTransaction).toBeDefined();
+
+ const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id;
+ const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id;
+
+ const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
+ const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
+
+ expect(httpServerTransaction.transaction).toBe('GET /');
+ expect(pageloadTransaction.transaction).toBe('/');
+
+ expect(httpServerTraceId).toBeDefined();
+ expect(httpServerSpanId).toBeDefined();
+
+ expect(pageLoadTraceId).toEqual(httpServerTraceId);
+ expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
+});
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json
new file mode 100644
index 000000000000..af4a50ee6f5a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "include": [
+ "server.ts",
+ "./app/**/*.d.ts",
+ "./app/**/*.ts",
+ "./app/**/*.tsx",
+ ".react-router/types/**/*"
+ ],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "module": "ES2022",
+ "target": "ES2022",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "skipLibCheck": true,
+ "baseUrl": ".",
+ "types": ["@shopify/oxygen-workers-types"],
+ "paths": {
+ "~/*": ["app/*"]
+ },
+ "rootDirs": [".", "./.react-router/types"],
+ "noEmit": true
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/vite.config.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/vite.config.ts
new file mode 100644
index 000000000000..fbce6e7c8463
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/vite.config.ts
@@ -0,0 +1,51 @@
+import { reactRouter } from '@react-router/dev/vite';
+import { hydrogen } from '@shopify/hydrogen/vite';
+import { oxygen } from '@shopify/mini-oxygen/vite';
+import { defineConfig } from 'vite';
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { sentryReactRouter, type SentryReactRouterBuildOptions } from '@sentry/react-router';
+
+const sentryConfig: SentryReactRouterBuildOptions = {
+ org: "example-org",
+ project: "example-project",
+ // An auth token is required for uploading source maps;
+ // store it in an environment variable to keep it secure.
+ authToken: process.env.SENTRY_AUTH_TOKEN,
+ // ...
+};
+
+
+export default defineConfig(config => ({
+ plugins: [
+ hydrogen(),
+ oxygen(),
+ reactRouter(),
+ sentryReactRouter(sentryConfig, config),
+ tsconfigPaths({
+ // The dev server config errors are not relevant to this test app
+ // https://github.com/aleclarson/vite-tsconfig-paths?tab=readme-ov-file#options
+ ignoreConfigErrors: true,
+ }),
+ ],
+ // build: {
+ // // Allow a strict Content-Security-Policy
+ // // without inlining assets as base64:
+ // assetsInlineLimit: 0,
+ // minify: false,
+ // },
+ ssr: {
+ optimizeDeps: {
+ /**
+ * Include dependencies here if they throw CJS<>ESM errors.
+ * For example, for the following error:
+ *
+ * > ReferenceError: module is not defined
+ * > at /Users/.../node_modules/example-dep/index.js:1:1
+ *
+ * Include 'example-dep' in the array below.
+ * @see https://vitejs.dev/config/dep-optimization-options
+ */
+ include: ['hoist-non-react-statics', '@sentry/react-router'],
+ },
+ },
+}));
diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/wrangler.toml b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/wrangler.toml
new file mode 100644
index 000000000000..b2de8e7d1321
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/wrangler.toml
@@ -0,0 +1,3 @@
+name = "hydrogen-react-router-7"
+main = "server.ts"
+compatibility_flags = ["transformstream_enable_standard_constructor"]
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index 88e0e506d110..67ac746bae08 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -27,7 +27,18 @@
"node": {
"import": "./build/esm/index.server.js",
"require": "./build/cjs/index.server.js"
+ },
+ "worker": {
+ "import": "./build/esm/cloudflare/index.js",
+ "require": "./build/cjs/cloudflare/index.js",
+ "default": "./build/esm/cloudflare/index.js"
}
+ },
+ "./cloudflare": {
+ "import": "./build/esm/cloudflare/index.js",
+ "require": "./build/cjs/cloudflare/index.js",
+ "types": "./build/types/cloudflare/index.d.ts",
+ "default": "./build/esm/cloudflare/index.js"
}
},
"publishConfig": {
diff --git a/packages/react-router/rollup.npm.config.mjs b/packages/react-router/rollup.npm.config.mjs
index 709de91a7b6a..4a52f4ab57a7 100644
--- a/packages/react-router/rollup.npm.config.mjs
+++ b/packages/react-router/rollup.npm.config.mjs
@@ -3,7 +3,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu
export default [
...makeNPMConfigVariants(
makeBaseNPMConfig({
- entrypoints: ['src/index.server.ts', 'src/index.client.ts'],
+ entrypoints: ['src/index.server.ts', 'src/index.client.ts', 'src/cloudflare/index.ts'],
packageSpecificConfig: {
external: ['react-router', 'react-router-dom', 'react', 'react/jsx-runtime', 'vite'],
output: {
diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts
new file mode 100644
index 000000000000..e5978e7b2bea
--- /dev/null
+++ b/packages/react-router/src/cloudflare/index.ts
@@ -0,0 +1,41 @@
+import { getTraceMetaTags } from '@sentry/core';
+
+export * from '../client';
+
+export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest';
+
+/**
+ * Injects Sentry trace meta tags into the HTML response by transforming the ReadableStream.
+ * This enables distributed tracing by adding trace context to the HTML document head.
+ * @param body - ReadableStream containing the HTML response body to modify
+ * @returns A new ReadableStream with Sentry trace meta tags injected into the head section
+ */
+export function injectTraceMetaTags(body: ReadableStream): ReadableStream {
+ const headClosingTag = '';
+
+ const reader = body.getReader();
+ const stream = new ReadableStream({
+ async pull(controller) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ controller.close();
+ return;
+ }
+
+ const encoder = new TextEncoder();
+ const html = value instanceof Uint8Array ? new TextDecoder().decode(value) : String(value);
+
+ if (html.includes(headClosingTag)) {
+ const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`);
+
+ controller.enqueue(encoder.encode(modifiedHtml));
+ return;
+ }
+
+ controller.enqueue(encoder.encode(html));
+ },
+ });
+
+ return stream;
+}
diff --git a/packages/react-router/src/server/createSentryHandleRequest.tsx b/packages/react-router/src/server/createSentryHandleRequest.tsx
index 052a51399cad..d7db59be616f 100644
--- a/packages/react-router/src/server/createSentryHandleRequest.tsx
+++ b/packages/react-router/src/server/createSentryHandleRequest.tsx
@@ -3,7 +3,8 @@ import type { ReactNode } from 'react';
import React from 'react';
import type { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
import { PassThrough } from 'stream';
-import { getMetaTagTransformer, wrapSentryHandleRequest } from './wrapSentryHandleRequest';
+import { getMetaTagTransformer } from './getMetaTagTransformer';
+import { wrapSentryHandleRequest } from './wrapSentryHandleRequest';
type RenderToPipeableStreamOptions = {
[key: string]: unknown;
diff --git a/packages/react-router/src/server/getMetaTagTransformer.ts b/packages/react-router/src/server/getMetaTagTransformer.ts
new file mode 100644
index 000000000000..2b4ce76808de
--- /dev/null
+++ b/packages/react-router/src/server/getMetaTagTransformer.ts
@@ -0,0 +1,26 @@
+import type { PassThrough } from 'node:stream';
+import { Transform } from 'node:stream';
+import { getTraceMetaTags } from '@sentry/core';
+
+/**
+ * Injects Sentry trace meta tags into the HTML response by piping through a transform stream.
+ * This enables distributed tracing by adding trace context to the HTML document head.
+ *
+ * @param body - PassThrough stream containing the HTML response body to modify
+ */
+export function getMetaTagTransformer(body: PassThrough): Transform {
+ const headClosingTag = '';
+ const htmlMetaTagTransformer = new Transform({
+ transform(chunk, _encoding, callback) {
+ const html = Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk);
+ if (html.includes(headClosingTag)) {
+ const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`);
+ callback(null, modifiedHtml);
+ return;
+ }
+ callback(null, chunk);
+ },
+ });
+ htmlMetaTagTransformer.pipe(body);
+ return htmlMetaTagTransformer;
+}
diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts
index 91520059d3fb..f595146ffe68 100644
--- a/packages/react-router/src/server/index.ts
+++ b/packages/react-router/src/server/index.ts
@@ -2,8 +2,9 @@ export * from '@sentry/node';
export { init } from './sdk';
// eslint-disable-next-line deprecation/deprecation
-export { wrapSentryHandleRequest, sentryHandleRequest, getMetaTagTransformer } from './wrapSentryHandleRequest';
+export { wrapSentryHandleRequest, sentryHandleRequest } from './wrapSentryHandleRequest';
export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest';
export { wrapServerAction } from './wrapServerAction';
export { wrapServerLoader } from './wrapServerLoader';
export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError';
+export { getMetaTagTransformer } from './getMetaTagTransformer';
diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts
index e5e10f2c05b2..161b40f9e241 100644
--- a/packages/react-router/src/server/wrapSentryHandleRequest.ts
+++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts
@@ -5,13 +5,10 @@ import {
flushIfServerless,
getActiveSpan,
getRootSpan,
- getTraceMetaTags,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
import type { AppLoadContext, EntryContext } from 'react-router';
-import type { PassThrough } from 'stream';
-import { Transform } from 'stream';
type OriginalHandleRequest = (
request: Request,
@@ -70,26 +67,3 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest):
// todo(v11): remove this
/** @deprecated Use `wrapSentryHandleRequest` instead. */
export const sentryHandleRequest = wrapSentryHandleRequest;
-
-/**
- * Injects Sentry trace meta tags into the HTML response by piping through a transform stream.
- * This enables distributed tracing by adding trace context to the HTML document head.
- *
- * @param body - PassThrough stream containing the HTML response body to modify
- */
-export function getMetaTagTransformer(body: PassThrough): Transform {
- const headClosingTag = '';
- const htmlMetaTagTransformer = new Transform({
- transform(chunk, _encoding, callback) {
- const html = Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk);
- if (html.includes(headClosingTag)) {
- const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`);
- callback(null, modifiedHtml);
- return;
- }
- callback(null, chunk);
- },
- });
- htmlMetaTagTransformer.pipe(body);
- return htmlMetaTagTransformer;
-}
diff --git a/packages/react-router/test/server/createSentryHandleRequest.test.ts b/packages/react-router/test/server/createSentryHandleRequest.test.ts
index be414155bb6d..19e6d9542cbb 100644
--- a/packages/react-router/test/server/createSentryHandleRequest.test.ts
+++ b/packages/react-router/test/server/createSentryHandleRequest.test.ts
@@ -3,14 +3,18 @@ import type { EntryContext } from 'react-router';
import { PassThrough } from 'stream';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSentryHandleRequest } from '../../src/server/createSentryHandleRequest';
+import * as getMetaTagTransformerModule from '../../src/server/getMetaTagTransformer';
import * as wrapSentryHandleRequestModule from '../../src/server/wrapSentryHandleRequest';
vi.mock('../../src/server/wrapSentryHandleRequest', () => ({
wrapSentryHandleRequest: vi.fn(fn => fn),
- getMetaTagTransformer: vi.fn(body => {
- const transform = new PassThrough();
- transform.pipe(body);
- return transform;
+}));
+
+vi.mock('../../src/server/getMetaTagTransformer', () => ({
+ getMetaTagTransformer: vi.fn(bodyStream => {
+ const transformer = new PassThrough();
+ bodyStream.pipe(transformer);
+ return transformer;
}),
}));
@@ -247,7 +251,7 @@ describe('createSentryHandleRequest', () => {
});
it('should pipe to the meta tag transformer', async () => {
- const getMetaTagTransformerSpy = vi.spyOn(wrapSentryHandleRequestModule, 'getMetaTagTransformer');
+ const getMetaTagTransformerSpy = vi.spyOn(getMetaTagTransformerModule, 'getMetaTagTransformer');
const pipeSpy = vi.fn();
diff --git a/packages/react-router/test/server/getMetaTagTransformer.ts b/packages/react-router/test/server/getMetaTagTransformer.ts
new file mode 100644
index 000000000000..16334888627c
--- /dev/null
+++ b/packages/react-router/test/server/getMetaTagTransformer.ts
@@ -0,0 +1,91 @@
+import { getTraceMetaTags } from '@sentry/core';
+import { PassThrough } from 'stream';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer';
+
+vi.mock('@opentelemetry/core', () => ({
+ RPCType: { HTTP: 'http' },
+ getRPCMetadata: vi.fn(),
+}));
+
+vi.mock('@sentry/core', () => ({
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source',
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin',
+ getActiveSpan: vi.fn(),
+ getRootSpan: vi.fn(),
+ getTraceMetaTags: vi.fn(),
+}));
+
+describe('getMetaTagTransformer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (getTraceMetaTags as unknown as ReturnType).mockReturnValue(
+ '',
+ );
+ });
+
+ test('should inject meta tags before closing head tag', done => {
+ const outputStream = new PassThrough();
+ const bodyStream = new PassThrough();
+ const transformer = getMetaTagTransformer(bodyStream);
+
+ let outputData = '';
+ outputStream.on('data', chunk => {
+ outputData += chunk.toString();
+ });
+
+ outputStream.on('end', () => {
+ expect(outputData).toContain('');
+ expect(outputData).not.toContain('');
+ done();
+ });
+
+ transformer.pipe(outputStream);
+
+ bodyStream.write('Test');
+ bodyStream.end();
+ });
+
+ test('should not modify chunks without head closing tag', done => {
+ const outputStream = new PassThrough();
+ const bodyStream = new PassThrough();
+ const transformer = getMetaTagTransformer(bodyStream);
+
+ let outputData = '';
+ outputStream.on('data', chunk => {
+ outputData += chunk.toString();
+ });
+
+ outputStream.on('end', () => {
+ expect(outputData).toBe('Test');
+ expect(getTraceMetaTags).toHaveBeenCalled();
+ done();
+ });
+
+ transformer.pipe(outputStream);
+
+ bodyStream.write('Test');
+ bodyStream.end();
+ });
+
+ test('should handle buffer input', done => {
+ const outputStream = new PassThrough();
+ const bodyStream = new PassThrough();
+ const transformer = getMetaTagTransformer(bodyStream);
+
+ let outputData = '';
+ outputStream.on('data', chunk => {
+ outputData += chunk.toString();
+ });
+
+ outputStream.on('end', () => {
+ expect(outputData).toContain('');
+ done();
+ });
+
+ transformer.pipe(outputStream);
+
+ bodyStream.write(Buffer.from('Test'));
+ bodyStream.end();
+ });
+});
diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts
index f66a4822555e..e76a7f6c1dae 100644
--- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts
+++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts
@@ -1,3 +1,4 @@
+import { PassThrough } from 'node:stream';
import { RPCType } from '@opentelemetry/core';
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions';
import {
@@ -8,9 +9,9 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
-import { PassThrough } from 'stream';
import { beforeEach, describe, expect, test, vi } from 'vitest';
-import { getMetaTagTransformer, wrapSentryHandleRequest } from '../../src/server/wrapSentryHandleRequest';
+import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer';
+import { wrapSentryHandleRequest } from '../../src/server/wrapSentryHandleRequest';
vi.mock('@opentelemetry/core', () => ({
RPCType: { HTTP: 'http' },