Skip to content

Commit d669065

Browse files
committed
Reduce complexity, cleanup
1 parent 66164ac commit d669065

File tree

2 files changed

+818
-51
lines changed

2 files changed

+818
-51
lines changed

packages/react/src/reactrouter-compat-utils/instrumentation.tsx

Lines changed: 134 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet<Client>();
5757
// Prevents duplicate spans when router.subscribe fires multiple times
5858
const activeNavigationSpans = new WeakMap<
5959
Client,
60-
{ span: Span; routeName: string; pathname: string; isPlaceholder?: boolean }
60+
{ span: Span; routeName: string; pathname: string; locationKey: string; isPlaceholder?: boolean }
6161
>();
6262

6363
// Exported for testing only
@@ -66,6 +66,94 @@ export const allRoutes = new Set<RouteObject>();
6666
// Tracks lazy route loads to wait before finalizing span names
6767
const pendingLazyRouteLoads = new WeakMap<Span, Set<Promise<unknown>>>();
6868

69+
/**
70+
* Schedules a callback using requestAnimationFrame when available (browser),
71+
* or falls back to setTimeout for SSR environments (Node.js, createMemoryRouter tests).
72+
*/
73+
function scheduleCallback(callback: () => void): number {
74+
if (WINDOW?.requestAnimationFrame) {
75+
return WINDOW.requestAnimationFrame(callback);
76+
}
77+
return setTimeout(callback, 0) as unknown as number;
78+
}
79+
80+
/**
81+
* Cancels a scheduled callback, handling both RAF (browser) and timeout (SSR) IDs.
82+
*/
83+
function cancelScheduledCallback(id: number): void {
84+
if (WINDOW?.cancelAnimationFrame) {
85+
WINDOW.cancelAnimationFrame(id);
86+
} else {
87+
clearTimeout(id);
88+
}
89+
}
90+
91+
/**
92+
* Computes a location key that uniquely identifies a navigation including pathname, search, and hash.
93+
*
94+
* Normalizes undefined/null search and hash to empty strings to ensure consistency between
95+
* partial location objects (from <Routes location="/path">) and full location objects
96+
* (from history state). This prevents duplicate navigation spans when using the location
97+
* prop with string values (common in modal routes and SSR).
98+
*
99+
* Exported for testing.
100+
*
101+
* @example
102+
* // Partial location (from <Routes location="/users">)
103+
* computeLocationKey({ pathname: '/users', search: undefined, hash: undefined })
104+
* // Returns: '/users'
105+
*
106+
* // Full location (from history)
107+
* computeLocationKey({ pathname: '/users', search: '', hash: '' })
108+
* // Returns: '/users' (same key - duplicate detection works correctly)
109+
*/
110+
export function computeLocationKey(location: Location): string {
111+
return `${location.pathname}${location.search || ''}${location.hash || ''}`;
112+
}
113+
114+
/**
115+
* Determines if a navigation should be skipped as a duplicate, and if an existing span should be updated.
116+
* Exported for testing.
117+
*
118+
* @returns An object with:
119+
* - skip: boolean - Whether to skip creating a new span
120+
* - shouldUpdate: boolean - Whether to update the existing span name (wildcard upgrade)
121+
*/
122+
export function shouldSkipNavigation(
123+
trackedNav:
124+
| { span: Span; routeName: string; pathname: string; locationKey: string; isPlaceholder?: boolean }
125+
| undefined,
126+
locationKey: string,
127+
proposedName: string,
128+
spanHasEnded: boolean,
129+
): { skip: boolean; shouldUpdate: boolean } {
130+
if (!trackedNav) {
131+
return { skip: false, shouldUpdate: false };
132+
}
133+
134+
// If it's a placeholder for the same location, skip immediately (span creation in progress)
135+
if (trackedNav.isPlaceholder && trackedNav.locationKey === locationKey) {
136+
return { skip: true, shouldUpdate: false };
137+
}
138+
139+
// For real spans (not placeholders), check if duplicate by location and end status
140+
if (!trackedNav.isPlaceholder) {
141+
// If tracked span is for the same location and hasn't ended yet, this is a duplicate
142+
if (trackedNav.locationKey === locationKey && !spanHasEnded) {
143+
// Check if we should update from wildcard to parameterized
144+
const shouldUpdate =
145+
!!trackedNav.routeName &&
146+
transactionNameHasWildcard(trackedNav.routeName) &&
147+
!transactionNameHasWildcard(proposedName);
148+
149+
return { skip: true, shouldUpdate };
150+
}
151+
}
152+
153+
// Location is different or span has ended - allow creating new span
154+
return { skip: false, shouldUpdate: false };
155+
}
156+
69157
export interface ReactRouterOptions {
70158
useEffect: UseEffect;
71159
useLocation: UseLocation;
@@ -286,7 +374,8 @@ function setupRouterSubscription(
286374

287375
if (shouldHandleNavigation) {
288376
// Include search and hash to allow query/hash-only navigations
289-
const currentLocationKey = `${state.location.pathname}${state.location.search}${state.location.hash}`;
377+
// Use computeLocationKey() to ensure undefined/null values are normalized to empty strings
378+
const currentLocationKey = computeLocationKey(state.location);
290379
const navigationHandler = (): void => {
291380
// Prevent multiple calls for the same location within the same navigation cycle
292381
if (lastHandledPathname === currentLocationKey) {
@@ -311,13 +400,13 @@ function setupRouterSubscription(
311400
}
312401
// Cancel any previously scheduled handler to avoid duplicates
313402
if (scheduledNavigationHandler !== null) {
314-
cancelAnimationFrame(scheduledNavigationHandler);
403+
cancelScheduledCallback(scheduledNavigationHandler);
315404
}
316-
scheduledNavigationHandler = requestAnimationFrame(navigationHandler);
405+
scheduledNavigationHandler = scheduleCallback(navigationHandler);
317406
} else {
318407
// Navigation completed - cancel scheduled handler if any, then call immediately
319408
if (scheduledNavigationHandler !== null) {
320-
cancelAnimationFrame(scheduledNavigationHandler);
409+
cancelScheduledCallback(scheduledNavigationHandler);
321410
scheduledNavigationHandler = null;
322411
}
323412
navigationHandler();
@@ -671,7 +760,6 @@ function wrapPatchRoutesOnNavigation(
671760
};
672761
}
673762

674-
// eslint-disable-next-line complexity
675763
export function handleNavigation(opts: {
676764
location: Location;
677765
routes: RouteObject[];
@@ -705,43 +793,26 @@ export function handleNavigation(opts: {
705793
basename,
706794
);
707795

796+
const locationKey = computeLocationKey(location);
708797
const trackedNav = activeNavigationSpans.get(client);
709798

710-
// If we have a tracked navigation for this client, check if it's a duplicate
711-
if (trackedNav) {
712-
// If it's a placeholder for the same pathname, skip immediately (span creation in progress)
713-
if (trackedNav.isPlaceholder && trackedNav.pathname === location.pathname) {
714-
DEBUG_BUILD &&
715-
debug.log(`[Tracing] Skipping duplicate navigation - placeholder exists for pathname: ${location.pathname}`);
716-
return;
717-
}
718-
719-
// For real spans (not placeholders), check if duplicate by pathname and end status
720-
if (!trackedNav.isPlaceholder) {
721-
const trackedSpanJson = spanToJSON(trackedNav.span);
722-
const trackedSpanHasEnded = !!trackedSpanJson.timestamp;
723-
724-
// If tracked span is for the same pathname and hasn't ended yet, this is a duplicate
725-
if (trackedNav.pathname === location.pathname && !trackedSpanHasEnded) {
726-
// Check if we should update from wildcard to parameterized
727-
const shouldUpdate =
728-
trackedNav.routeName &&
729-
transactionNameHasWildcard(trackedNav.routeName) &&
730-
!transactionNameHasWildcard(name);
731-
732-
if (shouldUpdate) {
733-
const oldName = trackedNav.routeName;
734-
trackedNav.span.updateName(name);
735-
trackedNav.span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom');
736-
// Update the tracked route name so future checks use the new name
737-
trackedNav.routeName = name;
738-
DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${oldName}" to "${name}"`);
739-
}
740-
return; // Skip - duplicate (same pathname, span still active)
741-
}
742-
// If pathname is different or span has ended, allow creating new span
743-
// This handles legitimate re-navigations like /users/123 -> /users/456
799+
// Determine if this navigation should be skipped as a duplicate
800+
const trackedSpanHasEnded =
801+
trackedNav && !trackedNav.isPlaceholder ? !!spanToJSON(trackedNav.span).timestamp : false;
802+
const { skip, shouldUpdate } = shouldSkipNavigation(trackedNav, locationKey, name, trackedSpanHasEnded);
803+
804+
if (skip) {
805+
if (shouldUpdate && trackedNav) {
806+
// Update existing span from wildcard to parameterized route name
807+
const oldName = trackedNav.routeName;
808+
trackedNav.span.updateName(name);
809+
trackedNav.span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom');
810+
trackedNav.routeName = name;
811+
DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${oldName}" to "${name}"`);
812+
} else {
813+
DEBUG_BUILD && debug.log(`[Tracing] Skipping duplicate navigation for location: ${locationKey}`);
744814
}
815+
return;
745816
}
746817

747818
// Create new navigation span (first navigation or legitimate new navigation)
@@ -752,6 +823,7 @@ export function handleNavigation(opts: {
752823
span: placeholderSpan,
753824
routeName: name,
754825
pathname: location.pathname,
826+
locationKey,
755827
isPlaceholder: true,
756828
});
757829

@@ -766,7 +838,12 @@ export function handleNavigation(opts: {
766838

767839
if (navigationSpan) {
768840
// Update the map with the real span (isPlaceholder omitted, defaults to false)
769-
activeNavigationSpans.set(client, { span: navigationSpan, routeName: name, pathname: location.pathname });
841+
activeNavigationSpans.set(client, {
842+
span: navigationSpan,
843+
routeName: name,
844+
pathname: location.pathname,
845+
locationKey,
846+
});
770847
patchSpanEnd(navigationSpan, location, routes, basename, allRoutes, 'navigation');
771848
} else {
772849
// If no span was created, remove the placeholder
@@ -955,14 +1032,16 @@ function patchSpanEnd(
9551032
const currentName = spanJson.description;
9561033
const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
9571034

958-
// Clean up activeNavigationSpans entry for this span to prevent stale entries
959-
const client = getClient();
960-
if (client && spanType === 'navigation') {
961-
const trackedNav = activeNavigationSpans.get(client);
962-
if (trackedNav && trackedNav.span === span) {
963-
activeNavigationSpans.delete(client);
1035+
// Helper to clean up activeNavigationSpans after span ends
1036+
const cleanupNavigationSpan = (): void => {
1037+
const client = getClient();
1038+
if (client && spanType === 'navigation') {
1039+
const trackedNav = activeNavigationSpans.get(client);
1040+
if (trackedNav && trackedNav.span === span) {
1041+
activeNavigationSpans.delete(client);
1042+
}
9641043
}
965-
}
1044+
};
9661045

9671046
const pendingPromises = pendingLazyRouteLoads.get(span);
9681047
// Wait for lazy routes if:
@@ -978,7 +1057,9 @@ function patchSpanEnd(
9781057
if (shouldWaitForLazyRoutes) {
9791058
if (_lazyRouteTimeout === 0) {
9801059
tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet);
981-
return originalEnd(...args);
1060+
originalEnd(...args);
1061+
cleanupNavigationSpan();
1062+
return;
9821063
}
9831064

9841065
const allSettled = Promise.allSettled(pendingPromises).then(() => {});
@@ -1001,15 +1082,18 @@ function patchSpanEnd(
10011082
allRoutesSet,
10021083
);
10031084
originalEnd(...args);
1085+
cleanupNavigationSpan();
10041086
})
10051087
.catch(() => {
10061088
originalEnd(...args);
1089+
cleanupNavigationSpan();
10071090
});
10081091
return;
10091092
}
10101093

10111094
tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet);
1012-
return originalEnd(...args);
1095+
originalEnd(...args);
1096+
cleanupNavigationSpan();
10131097
};
10141098

10151099
addNonEnumerableProperty(span as unknown as Record<string, boolean>, patchedPropertyName, true);

0 commit comments

Comments
 (0)