From 69781d91edceb4d638b4bef5b2286a816b21e9fd Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 21 Jul 2025 22:09:11 +0100 Subject: [PATCH 1/6] feat: Add support for Hydrogen with RR7 --- .../hydrogen-react-router-7/.env | 2 + .../hydrogen-react-router-7/.eslintignore | 5 + .../hydrogen-react-router-7/.eslintrc.cjs | 79 ++++++ .../hydrogen-react-router-7/.gitignore | 10 + .../hydrogen-react-router-7/.npmrc | 2 + .../app/entry.client.tsx | 23 ++ .../app/entry.server.tsx | 54 +++++ .../app/lib/fragments.ts | 174 ++++++++++++++ .../app/lib/session.ts | 61 +++++ .../app/lib/variants.ts | 41 ++++ .../hydrogen-react-router-7/app/root.tsx | 225 ++++++++++++++++++ .../hydrogen-react-router-7/app/routes.ts | 9 + .../app/routes/_index.tsx | 35 +++ .../app/routes/action-formdata.tsx | 16 ++ .../app/routes/client-error.tsx | 15 ++ .../app/routes/loader-error.tsx | 16 ++ .../app/routes/navigate.tsx | 20 ++ .../app/routes/user.$id.tsx | 3 + .../hydrogen-react-router-7/app/utils.ts | 41 ++++ .../hydrogen-react-router-7/env.d.ts | 59 +++++ .../hydrogen-react-router-7/globals.d.ts | 7 + .../instrument.server.mjs | 10 + .../hydrogen-react-router-7/package.json | 58 +++++ .../playwright.config.mjs | 8 + .../public/favicon.ico | 28 +++ .../public/favicon.svg | 28 +++ .../react-router.config.ts | 14 ++ .../hydrogen-react-router-7/server.ts | 133 +++++++++++ .../start-event-proxy.mjs | 6 + .../tests/client-errors.test.ts | 32 +++ .../tests/client-transactions.test.ts | 60 +++++ .../tests/server-transactions.test.ts | 53 +++++ .../hydrogen-react-router-7/tsconfig.json | 30 +++ .../hydrogen-react-router-7/vite.config.ts | 51 ++++ .../hydrogen-react-router-7/wrangler.toml | 3 + packages/react-router/package.json | 11 + packages/react-router/rollup.npm.config.mjs | 2 +- packages/react-router/src/cloudflare/index.ts | 41 ++++ .../src/server/createSentryHandleRequest.tsx | 3 +- .../src/server/getMetaTagTransformer.ts | 26 ++ packages/react-router/src/server/index.ts | 3 +- .../src/server/wrapSentryHandleRequest.ts | 26 -- 42 files changed, 1494 insertions(+), 29 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.env create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.eslintignore create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.eslintrc.cjs create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/entry.client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/entry.server.tsx create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/fragments.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/session.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/variants.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/_index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/action-formdata.tsx create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/client-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/loader-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/navigate.tsx create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/user.$id.tsx create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/utils.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/instrument.server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/public/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/public/favicon.svg create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/react-router.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/server.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/client-errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/client-transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/server-transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/wrangler.toml create mode 100644 packages/react-router/src/cloudflare/index.ts create mode 100644 packages/react-router/src/server/getMetaTagTransformer.ts 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..87c3bec9192b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/entry.client.tsx @@ -0,0 +1,23 @@ +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', + debug: true, + 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..79eed7cf4690 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx @@ -0,0 +1,225 @@ +import * as Sentry from '@sentry/react-router/cloudflare'; +import {type LoaderFunctionArgs} from '@shopify/remix-oxygen'; +import { + Outlet, + useRouteError, + isRouteErrorResponse, + type ShouldRevalidateFunction, + Links, + Meta, + Scripts, + ScrollRestoration, + useLocation, + useMatches, + useRouteLoaderData, +} from 'react-router'; +import {FOOTER_QUERY, HEADER_QUERY} from '~/lib/fragments'; +import {useEffect} from 'react'; +import {useNonce} from '@shopify/hydrogen'; + +// export const meta = ({ +// data, +// }: { +// data: { +// ENV: {SENTRY_DSN: string}; +// sentryTrace: string; +// sentryBaggage: string; +// }; +// }) => { +// return [ +// { +// env: data.ENV, +// }, +// { +// name: 'sentry-trace', +// content: data.sentryTrace, +// }, +// { +// name: 'baggage', +// content: data.sentryBaggage, +// }, +// ]; +// }; + +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 {storefront, 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(); + const data = useRouteLoaderData('root'); + + 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 && ( +
+
{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..f9791ce4e02e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/instrument.server.mjs @@ -0,0 +1,10 @@ +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, + debug: true, + 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..593e0f1f2342 --- /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..0b6f3498be44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/server.ts @@ -0,0 +1,133 @@ +// import { wrapRequestHandler } from '@sentry/cloudflare/request'; +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 a55ee2172e55..2c5437202480 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..fcbb185ce9f4 --- /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 html = value instanceof Uint8Array ? new TextDecoder().decode(value) : String(value); + + if (html.includes(headClosingTag)) { + const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`); + + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode(modifiedHtml)); + return; + } + + controller.enqueue(value); + }, + }); + + 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 b42b769a78e6..97f1bdd70736 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -2,7 +2,8 @@ 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 { getMetaTagTransformer } from './getMetaTagTransformer'; diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index df7d65109338..6a4307e0ee27 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -4,13 +4,10 @@ import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import { 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, @@ -64,26 +61,3 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): /** @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; -} From dff27df5135ae0db07c0369825057e380e2fa67c Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 24 Jul 2025 10:55:28 +0100 Subject: [PATCH 2/6] Fix unit tests --- .../server/createSentryHandleRequest.test.ts | 14 ++- .../test/server/getMetaTagTransformer.ts | 94 +++++++++++++++++++ .../server/wrapSentryHandleRequest.test.ts | 77 +-------------- 3 files changed, 104 insertions(+), 81 deletions(-) create mode 100644 packages/react-router/test/server/getMetaTagTransformer.ts 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..a9c3f0b4c21a --- /dev/null +++ b/packages/react-router/test/server/getMetaTagTransformer.ts @@ -0,0 +1,94 @@ +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 40dce7c83702..8671b88fe75f 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -3,13 +3,11 @@ import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import { getActiveSpan, getRootSpan, - getTraceMetaTags, 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 { wrapSentryHandleRequest } from '../../src/server/wrapSentryHandleRequest'; vi.mock('@opentelemetry/core', () => ({ RPCType: { HTTP: 'http' }, @@ -124,76 +122,3 @@ describe('wrapSentryHandleRequest', () => { }); }); -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(); - }); -}); From 3eb314cc9ccb702eb99cde67653f8e9cc49386d4 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 24 Jul 2025 11:50:53 +0100 Subject: [PATCH 3/6] Lint --- packages/react-router/test/server/getMetaTagTransformer.ts | 5 +---- .../react-router/test/server/wrapSentryHandleRequest.test.ts | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/react-router/test/server/getMetaTagTransformer.ts b/packages/react-router/test/server/getMetaTagTransformer.ts index a9c3f0b4c21a..16334888627c 100644 --- a/packages/react-router/test/server/getMetaTagTransformer.ts +++ b/packages/react-router/test/server/getMetaTagTransformer.ts @@ -1,6 +1,4 @@ -import { - getTraceMetaTags, -} from '@sentry/core'; +import { getTraceMetaTags } from '@sentry/core'; import { PassThrough } from 'stream'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer'; @@ -18,7 +16,6 @@ vi.mock('@sentry/core', () => ({ getTraceMetaTags: vi.fn(), })); - describe('getMetaTagTransformer', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts index 8671b88fe75f..82701c3c1a07 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -121,4 +121,3 @@ describe('wrapSentryHandleRequest', () => { expect(getRPCMetadata).not.toHaveBeenCalled(); }); }); - From 748b0a79b02a284b073d4604103feb350893e014 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 24 Jul 2025 11:52:57 +0100 Subject: [PATCH 4/6] Clean up --- .../app/entry.client.tsx | 1 - .../hydrogen-react-router-7/app/root.tsx | 33 ++----------------- .../instrument.server.mjs | 1 - 3 files changed, 2 insertions(+), 33 deletions(-) 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 index 87c3bec9192b..9c48e56befe8 100644 --- 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 @@ -7,7 +7,6 @@ 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', - debug: true, integrations: [Sentry.reactRouterTracingIntegration()], tracesSampleRate: 1.0, tunnel: 'http://localhost:3031/', // proxy server 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 index 79eed7cf4690..e38f97bd3f06 100644 --- 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 @@ -2,44 +2,16 @@ import * as Sentry from '@sentry/react-router/cloudflare'; import {type LoaderFunctionArgs} from '@shopify/remix-oxygen'; import { Outlet, - useRouteError, isRouteErrorResponse, type ShouldRevalidateFunction, Links, Meta, Scripts, ScrollRestoration, - useLocation, - useMatches, - useRouteLoaderData, } from 'react-router'; import {FOOTER_QUERY, HEADER_QUERY} from '~/lib/fragments'; -import {useEffect} from 'react'; -import {useNonce} from '@shopify/hydrogen'; -// export const meta = ({ -// data, -// }: { -// data: { -// ENV: {SENTRY_DSN: string}; -// sentryTrace: string; -// sentryBaggage: string; -// }; -// }) => { -// return [ -// { -// env: data.ENV, -// }, -// { -// name: 'sentry-trace', -// content: data.sentryTrace, -// }, -// { -// name: 'baggage', -// content: data.sentryBaggage, -// }, -// ]; -// }; +import {useNonce} from '@shopify/hydrogen'; export type RootLoader = typeof loader; @@ -95,7 +67,7 @@ export async function loader(args: LoaderFunctionArgs) { // Await the critical data required to render initial state of the page const criticalData = await loadCriticalData(args); - const {storefront, env} = args.context; + const {env} = args.context; return { ...deferredData, @@ -166,7 +138,6 @@ function loadDeferredData({context}: LoaderFunctionArgs) { export function Layout({children}: {children?: React.ReactNode}) { const nonce = useNonce(); - const data = useRouteLoaderData('root'); return ( 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 index f9791ce4e02e..044e50e1b86c 100644 --- 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 @@ -5,6 +5,5 @@ Sentry.init({ // https://docs.sentry.io/platforms/javascript/guides/react-router/configuration/options/#sendDefaultPii sendDefaultPii: true, tracesSampleRate: 1.0, - debug: true, tunnel: `http://localhost:3031/`, // proxy server }); From a38286d9d9200657c4a691b44d52238252f05419 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 24 Jul 2025 12:08:25 +0100 Subject: [PATCH 5/6] Remove commented-out import --- .../test-applications/hydrogen-react-router-7/server.ts | 1 - 1 file changed, 1 deletion(-) 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 index 0b6f3498be44..07638d967cf7 100644 --- 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 @@ -1,4 +1,3 @@ -// import { wrapRequestHandler } from '@sentry/cloudflare/request'; import { cartGetIdDefault, cartSetIdDefault, From 452353d79e95a7a470c989c61119a76e1508dd5e Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 25 Jul 2025 15:28:34 +0100 Subject: [PATCH 6/6] Encode unmodified html as well --- packages/react-router/src/cloudflare/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index fcbb185ce9f4..e5978e7b2bea 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -23,17 +23,17 @@ export function injectTraceMetaTags(body: ReadableStream): ReadableStream { 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}`); - const encoder = new TextEncoder(); controller.enqueue(encoder.encode(modifiedHtml)); return; } - controller.enqueue(value); + controller.enqueue(encoder.encode(html)); }, });