diff --git a/packages/remix/src/client/routeProvider.ts b/packages/remix/src/client/routeProvider.ts new file mode 100644 index 000000000000..7adca26a4480 --- /dev/null +++ b/packages/remix/src/client/routeProvider.ts @@ -0,0 +1,13 @@ +import type { RouteProvider } from '@sentry/core'; +import { createUrlRouteProvider } from '@sentry/core'; +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..32a72eb9e580 100644 --- a/packages/remix/src/client/sdk.ts +++ b/packages/remix/src/client/sdk.ts @@ -1,7 +1,8 @@ import type { Client } from '@sentry/core'; -import { applySdkMetadata } from '@sentry/core'; +import { applySdkMetadata, setRouteProvider } from '@sentry/core'; import { init as reactInit } from '@sentry/react'; import type { RemixOptions } from '../utils/remixOptions'; +import { createRemixRouteProvider } from './routeProvider'; /** * Initializes the Remix SDK. @@ -16,5 +17,12 @@ export function init(options: RemixOptions): Client | undefined { applySdkMetadata(opts, 'remix', ['remix', 'react']); - return reactInit(opts); + const client = reactInit(opts); + + // Registered here rather than from the tracing integration so route parameterization does not + // depend on tracing: the manifest is injected at build time, so anything that needs a route name + // (bfcache metrics, web vitals) can resolve one even with tracing disabled. + setRouteProvider(createRemixRouteProvider(), client); + + return client; } 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(); + }); +});