Skip to content

Commit 0e2e578

Browse files
chargomeclaude
andcommitted
fix(remix): Record the route under the URL that served it, and forget failed navigations
A redirected frame fetch answers from another URL; recording its route under the requested path would misname every later visit there. A fetch that failed never reported back, so its entry stayed pending for good. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent c910ecf commit 0e2e578

2 files changed

Lines changed: 79 additions & 6 deletions

File tree

‎packages/remix/src/v3/client/browserTracingIntegration.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,9 @@ function instrumentNavigationApi(client: Client): void {
8383

8484
// Navigation spans still waiting for their route, which arrives with the response to the runtime's
8585
// fetch of the destination. Keyed by pathname: a second navigation before the first response must
86-
// not lose either span.
86+
// not lose either span. Bounded, because a fetch that fails never reports back for its entry.
8787
const pending = new Map<string, { span: Span; url: string }>();
88+
const MAX_PENDING = 20;
8889

8990
navigation.addEventListener('navigate', event => {
9091
const url = event.destination?.url;
@@ -111,12 +112,19 @@ function instrumentNavigationApi(client: Client): void {
111112
);
112113

113114
if (span && !route) {
115+
// A repeat of the same path replaces its entry; the Navigation API aborted the earlier intercept.
116+
pending.delete(pathname);
114117
pending.set(pathname, { span, url });
118+
while (pending.size > MAX_PENDING) {
119+
pending.delete(pending.keys().next().value as string);
120+
}
115121
}
116122
});
117123

118124
addFetchInstrumentationHandler(({ fetchData, response }) => {
119125
if (pending.size === 0 || !response) {
126+
// A failed fetch does not settle the entry: the Navigation API aborts the previous intercept when
127+
// a new navigation starts, and that rejection lands after the new one re-queued the same path.
120128
return;
121129
}
122130
// The runtime fetches the destination itself. Any other fetch during the navigation is the app's.
@@ -127,8 +135,11 @@ function instrumentNavigationApi(client: Client): void {
127135
}
128136
pending.delete(pathname);
129137

130-
recordRoute(client, pathname, parseRouteTiming(response.headers.get('server-timing')));
131-
const route = resolveRoute(waiting.url, client);
138+
// The route belongs to the URL that answered. After a redirect that is not the one requested, and
139+
// recording it under the requested path would misname every later visit there.
140+
const servedPathname = pathnameOf(response.url, WINDOW.location?.href) || pathname;
141+
recordRoute(client, servedPathname, parseRouteTiming(response.headers.get('server-timing')));
142+
const route = resolveRoute(response.url || waiting.url, client);
132143
if (route) {
133144
applyRoute(waiting.span, route);
134145
}

‎packages/remix/test/v3/browserTracingIntegration.test.ts‎

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ type FakeSpan = ReturnType<typeof fakeSpan>;
1111

1212
let navigationSpan: FakeSpan | undefined;
1313
let activeSpan: FakeSpan | undefined;
14-
let fetchHandler: ((data: { fetchData: { url: string }; response?: { headers: Headers } }) => void) | undefined;
14+
let fetchHandler:
15+
| ((data: {
16+
fetchData: { url: string };
17+
endTimestamp?: number;
18+
response?: { url: string; headers: Headers };
19+
}) => void)
20+
| undefined;
1521

1622
const startBrowserTracingNavigationSpan = vi.fn(() => navigationSpan);
1723
const upstreamIntegration = { name: 'BrowserTracing', afterAllSetup: vi.fn() };
@@ -57,13 +63,22 @@ vi.mock('@sentry/core', async importOriginal => ({
5763
const { browserTracingIntegration } = await import('../../src/v3/client/browserTracingIntegration');
5864

5965
/** A response to the runtime's frame fetch, as the fetch instrumentation reports it. */
60-
function frameResponse(url: string, serverTiming?: string) {
66+
function frameResponse(url: string, serverTiming?: string, servedUrl = url) {
6167
return {
6268
fetchData: { url },
63-
response: { headers: new Headers(serverTiming ? { 'server-timing': serverTiming } : {}) },
69+
endTimestamp: 1,
70+
response: {
71+
url: new URL(servedUrl, 'https://app.test').href,
72+
headers: new Headers(serverTiming ? { 'server-timing': serverTiming } : {}),
73+
},
6474
};
6575
}
6676

77+
/** A frame fetch that failed: settled, but with no response. */
78+
function failedFrameFetch(url: string) {
79+
return { fetchData: { url }, endTimestamp: 1 };
80+
}
81+
6782
interface FakeNavigateEvent {
6883
canIntercept: boolean;
6984
info?: unknown;
@@ -224,6 +239,53 @@ describe('browserTracingIntegration', () => {
224239
expect(navigationSpan?.updateName).toHaveBeenCalledWith('/files/:name("a\\b")');
225240
});
226241

242+
it('records a redirected response under the path that served it, not the one requested', () => {
243+
const navigate = setup();
244+
navigate({ canIntercept: true, destination: { url: 'https://app.test/old/5' } });
245+
activeSpan = navigationSpan;
246+
247+
fetchHandler?.(frameResponse('/old/5', 'sentry-route;desc="/users/:id"', '/users/5'));
248+
249+
expect(recorded.get('/users/5')).toBe('/users/:id');
250+
expect(recorded.has('/old/5')).toBe(false);
251+
expect(navigationSpan?.updateName).toHaveBeenCalledWith('/users/:id');
252+
});
253+
254+
it('still names a repeated navigation after the aborted first attempt reports its failure', () => {
255+
const first = fakeSpan('navigation');
256+
const second = fakeSpan('navigation');
257+
const navigate = setup();
258+
navigationSpan = first;
259+
navigate({ canIntercept: true, destination: { url: 'https://app.test/users/1' } });
260+
navigationSpan = second;
261+
navigate({ canIntercept: true, destination: { url: 'https://app.test/users/1' } });
262+
activeSpan = second;
263+
264+
// The Navigation API aborted the first intercept; its rejection arrives after the second queued.
265+
fetchHandler?.(failedFrameFetch('/users/1'));
266+
fetchHandler?.(frameResponse('/users/1', 'sentry-route;desc="/users/:id"'));
267+
268+
expect(second.updateName).toHaveBeenCalledWith('/users/:id');
269+
expect(first.updateName).not.toHaveBeenCalled();
270+
});
271+
272+
it('keeps the pending set bounded when navigations never get a response', () => {
273+
const navigate = setup();
274+
for (let i = 0; i < 30; i++) {
275+
navigationSpan = fakeSpan('navigation');
276+
navigate({ canIntercept: true, destination: { url: `https://app.test/never/${i}` } });
277+
}
278+
const last = navigationSpan;
279+
activeSpan = last;
280+
281+
// The oldest entries were dropped; the newest still resolves.
282+
fetchHandler?.(frameResponse('/never/0', 'sentry-route;desc="/never/:n"'));
283+
fetchHandler?.(frameResponse('/never/29', 'sentry-route;desc="/never/:n"'));
284+
285+
expect(last?.updateName).toHaveBeenCalledWith('/never/:n');
286+
expect(recorded.has('/never/0')).toBe(false);
287+
});
288+
227289
it("ignores the app's own fetches during a navigation", () => {
228290
const navigate = setup();
229291
navigate({ canIntercept: true, destination: { url: 'https://app.test/users/12345' } });

0 commit comments

Comments
 (0)