Skip to content

Commit 97f19ed

Browse files
authored
feat(remix): Register remix route provider (#23791)
Registers a route provider for Remix from the route manifest the Vite plugin already injects at build time. `maybeParameterizeRemixRoute` was already a pure matcher over that manifest, it was just only reachable from the pageload and navigation instrumentation. Registered from `init()` rather than a tracing integration, so route parameterization no longer depends on tracing being enabled. Same shape as #23552, and simpler: Remix has one manifest and no `basePath` asymmetry. Part of #23556
1 parent f471f80 commit 97f19ed

6 files changed

Lines changed: 99 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Sentry from '@sentry/remix';
2+
import { useEffect, useState } from 'react';
3+
4+
export default function RouteProvider() {
5+
const [route, setRoute] = useState<string>();
6+
7+
useEffect(() => {
8+
setRoute(Sentry.resolveCurrentRoute() ?? 'unresolved');
9+
}, []);
10+
11+
return <div id="resolved-route">{route}</div>;
12+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
// The route provider is backed by the manifest the Vite plugin injects into the client bundle, so this
4+
// fails if that manifest never reaches the browser.
5+
test('resolves the parameterized route through the route provider', async ({ page }) => {
6+
await page.goto('/route-provider/123');
7+
8+
await expect(page.locator('#resolved-route')).toHaveText('/route-provider/:id');
9+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { RouteProvider } from '@sentry/react';
2+
import { createUrlRouteProvider } from '@sentry/react';
3+
import { maybeParameterizeRemixRoute } from './remixRouteParameterization';
4+
5+
/**
6+
* A route provider backed by the route manifest the Vite plugin injects at build time.
7+
*
8+
* The manifest is on the global object before `Sentry.init` runs, so this needs no router and no
9+
* tracing integration: registering it is what lets anything else in the SDK name a route.
10+
*/
11+
export function createRemixRouteProvider(): RouteProvider {
12+
return createUrlRouteProvider(url => maybeParameterizeRemixRoute(url.pathname));
13+
}

‎packages/remix/src/client/sdk.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Client } from '@sentry/core';
22
import { applySdkMetadata } from '@sentry/core';
33
import { init as reactInit } from '@sentry/react';
44
import type { RemixOptions } from '../utils/remixOptions';
5+
import { createRemixRouteProvider } from './routeProvider';
56

67
/**
78
* Initializes the Remix SDK.
@@ -10,6 +11,9 @@ import type { RemixOptions } from '../utils/remixOptions';
1011
*/
1112
export function init(options: RemixOptions): Client | undefined {
1213
const opts = {
14+
// The manifest is injected at build time, so route parameterization works from `init` on, even with
15+
// tracing disabled.
16+
routeProvider: createRemixRouteProvider(),
1317
...options,
1418
environment: options.environment || process.env.NODE_ENV,
1519
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { GLOBAL_OBJ } from '@sentry/core';
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3+
import { createRemixRouteProvider } from '../../src/client/routeProvider';
4+
5+
const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
6+
_sentryRemixRouteManifest: string | undefined;
7+
};
8+
9+
const MANIFEST = JSON.stringify({
10+
staticRoutes: [{ path: '/about' }],
11+
dynamicRoutes: [{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] }],
12+
});
13+
14+
let originalDocument: unknown;
15+
16+
describe('createRemixRouteProvider', () => {
17+
beforeEach(() => {
18+
globalWithInjectedManifest._sentryRemixRouteManifest = MANIFEST;
19+
originalDocument = (GLOBAL_OBJ as { document?: unknown }).document;
20+
// `resolveCurrentRoute` reads `document.location.href`.
21+
(GLOBAL_OBJ as { document?: unknown }).document = { location: { href: 'https://example.com/users/42' } };
22+
});
23+
24+
afterEach(() => {
25+
globalWithInjectedManifest._sentryRemixRouteManifest = undefined;
26+
(GLOBAL_OBJ as { document?: unknown }).document = originalDocument;
27+
});
28+
29+
it('parameterizes a URL from the build-time manifest', () => {
30+
expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id');
31+
});
32+
33+
it('resolves a static route', () => {
34+
expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/about'))).toBe('/about');
35+
});
36+
37+
it('resolves the current route from the document location', () => {
38+
expect(createRemixRouteProvider().resolveCurrentRoute()).toBe('/users/:id');
39+
});
40+
41+
it('returns undefined for a URL the manifest does not know', () => {
42+
expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/nope/deep'))).toBeUndefined();
43+
});
44+
45+
it('returns undefined when the manifest was never injected', () => {
46+
globalWithInjectedManifest._sentryRemixRouteManifest = undefined;
47+
48+
expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/users/42'))).toBeUndefined();
49+
});
50+
});

‎packages/remix/test/index.client.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ describe('Client init()', () => {
4343
);
4444
});
4545

46+
it('passes the Remix route provider unless the user passed one', () => {
47+
init({});
48+
expect(reactInit).toHaveBeenLastCalledWith(
49+
expect.objectContaining({ routeProvider: expect.objectContaining({ resolveRoute: expect.any(Function) }) }),
50+
);
51+
52+
const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' };
53+
init({ routeProvider });
54+
expect(reactInit).toHaveBeenLastCalledWith(expect.objectContaining({ routeProvider }));
55+
});
56+
4657
it('returns client from init', () => {
4758
expect(init({})).not.toBeUndefined();
4859
});

0 commit comments

Comments
 (0)