diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/route-provider.$id.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/route-provider.$id.tsx new file mode 100644 index 000000000000..6134dbb17ab0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/route-provider.$id.tsx @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/remix'; +import { useEffect, useState } from 'react'; + +export default function RouteProvider() { + const [route, setRoute] = useState(); + + useEffect(() => { + setRoute(Sentry.resolveCurrentRoute() ?? 'unresolved'); + }, []); + + return
{route}
; +} diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/route-provider.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/route-provider.test.ts new file mode 100644 index 000000000000..a99e7ec07314 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/route-provider.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@playwright/test'; + +// The route provider is backed by the manifest the Vite plugin injects into the client bundle, so this +// fails if that manifest never reaches the browser. +test('resolves the parameterized route through the route provider', async ({ page }) => { + await page.goto('/route-provider/123'); + + await expect(page.locator('#resolved-route')).toHaveText('/route-provider/:id'); +}); diff --git a/packages/remix/src/client/routeProvider.ts b/packages/remix/src/client/routeProvider.ts new file mode 100644 index 000000000000..5cc470a9c5a0 --- /dev/null +++ b/packages/remix/src/client/routeProvider.ts @@ -0,0 +1,13 @@ +import type { RouteProvider } from '@sentry/react'; +import { createUrlRouteProvider } from '@sentry/react'; +import { maybeParameterizeRemixRoute } from './remixRouteParameterization'; + +/** + * A route provider backed by the route manifest the Vite plugin injects at build time. + * + * The manifest is on the global object before `Sentry.init` runs, so this needs no router and no + * tracing integration: registering it is what lets anything else in the SDK name a route. + */ +export function createRemixRouteProvider(): RouteProvider { + return createUrlRouteProvider(url => maybeParameterizeRemixRoute(url.pathname)); +} diff --git a/packages/remix/src/client/sdk.ts b/packages/remix/src/client/sdk.ts index d09ec6786ce4..46bf5a92c501 100644 --- a/packages/remix/src/client/sdk.ts +++ b/packages/remix/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { Client } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import { init as reactInit } from '@sentry/react'; import type { RemixOptions } from '../utils/remixOptions'; +import { createRemixRouteProvider } from './routeProvider'; /** * Initializes the Remix SDK. @@ -10,6 +11,9 @@ import type { RemixOptions } from '../utils/remixOptions'; */ export function init(options: RemixOptions): Client | undefined { const opts = { + // The manifest is injected at build time, so route parameterization works from `init` on, even with + // tracing disabled. + routeProvider: createRemixRouteProvider(), ...options, environment: options.environment || process.env.NODE_ENV, }; diff --git a/packages/remix/test/client/routeProvider.test.ts b/packages/remix/test/client/routeProvider.test.ts new file mode 100644 index 000000000000..d92edc120cba --- /dev/null +++ b/packages/remix/test/client/routeProvider.test.ts @@ -0,0 +1,50 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createRemixRouteProvider } from '../../src/client/routeProvider'; + +const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRemixRouteManifest: string | undefined; +}; + +const MANIFEST = JSON.stringify({ + staticRoutes: [{ path: '/about' }], + dynamicRoutes: [{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] }], +}); + +let originalDocument: unknown; + +describe('createRemixRouteProvider', () => { + beforeEach(() => { + globalWithInjectedManifest._sentryRemixRouteManifest = MANIFEST; + originalDocument = (GLOBAL_OBJ as { document?: unknown }).document; + // `resolveCurrentRoute` reads `document.location.href`. + (GLOBAL_OBJ as { document?: unknown }).document = { location: { href: 'https://example.com/users/42' } }; + }); + + afterEach(() => { + globalWithInjectedManifest._sentryRemixRouteManifest = undefined; + (GLOBAL_OBJ as { document?: unknown }).document = originalDocument; + }); + + it('parameterizes a URL from the build-time manifest', () => { + expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('resolves a static route', () => { + expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/about'))).toBe('/about'); + }); + + it('resolves the current route from the document location', () => { + expect(createRemixRouteProvider().resolveCurrentRoute()).toBe('/users/:id'); + }); + + it('returns undefined for a URL the manifest does not know', () => { + expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/nope/deep'))).toBeUndefined(); + }); + + it('returns undefined when the manifest was never injected', () => { + globalWithInjectedManifest._sentryRemixRouteManifest = undefined; + + expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/users/42'))).toBeUndefined(); + }); +}); diff --git a/packages/remix/test/index.client.test.ts b/packages/remix/test/index.client.test.ts index 5b39e7736e29..1e0e26343494 100644 --- a/packages/remix/test/index.client.test.ts +++ b/packages/remix/test/index.client.test.ts @@ -43,6 +43,17 @@ describe('Client init()', () => { ); }); + it('passes the Remix route provider unless the user passed one', () => { + init({}); + expect(reactInit).toHaveBeenLastCalledWith( + expect.objectContaining({ routeProvider: expect.objectContaining({ resolveRoute: expect.any(Function) }) }), + ); + + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + init({ routeProvider }); + expect(reactInit).toHaveBeenLastCalledWith(expect.objectContaining({ routeProvider })); + }); + it('returns client from init', () => { expect(init({})).not.toBeUndefined(); });