-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
fix(react): Prevent navigation span leaks for consecutive navigations #18098
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
a849910
7ae1711
3c859bc
d9e3c53
ade1b00
121bb5e
11d1a6a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,9 +53,11 @@ let _enableAsyncRouteHandlers: boolean = false; | |
| const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet<Client>(); | ||
|
|
||
| /** | ||
| * Adds resolved routes as children to the parent route. | ||
| * Prevents duplicate routes by checking if they already exist. | ||
| * Tracks last navigation per client to prevent duplicate spans in cross-usage scenarios. | ||
| * Entry persists until next different navigation, handling delayed wrapper execution. | ||
| */ | ||
| const LAST_NAVIGATION_PER_CLIENT = new WeakMap<Client, string>(); | ||
|
|
||
| export function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentRoute: RouteObject): void { | ||
| const existingChildren = parentRoute.children || []; | ||
|
|
||
|
|
@@ -275,27 +277,22 @@ export function createV6CompatibleWrapCreateBrowserRouter< | |
| // If we haven't seen a pageload span yet, keep waiting (don't mark as complete) | ||
| } | ||
|
|
||
| // Only handle navigation when it's complete (state is idle). | ||
| // During 'loading' or 'submitting', state.location may still have the old pathname, | ||
| // which would cause us to create a span for the wrong route. | ||
| const shouldHandleNavigation = | ||
| state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete); | ||
| (state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete)) && | ||
| state.navigation.state === 'idle'; | ||
|
|
||
| if (shouldHandleNavigation) { | ||
| const navigationHandler = (): void => { | ||
| handleNavigation({ | ||
| location: state.location, | ||
| routes, | ||
| navigationType: state.historyAction, | ||
| version, | ||
| basename, | ||
| allRoutes: Array.from(allRoutes), | ||
| }); | ||
| }; | ||
|
|
||
| // Wait for the next render if loading an unsettled route | ||
| if (state.navigation.state !== 'idle') { | ||
| requestAnimationFrame(navigationHandler); | ||
| } else { | ||
| navigationHandler(); | ||
| } | ||
| handleNavigation({ | ||
| location: state.location, | ||
| routes, | ||
| navigationType: state.historyAction, | ||
| version, | ||
| basename, | ||
| allRoutes: Array.from(allRoutes), | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
|
|
@@ -404,29 +401,22 @@ export function createV6CompatibleWrapCreateMemoryRouter< | |
| // If we haven't seen a pageload span yet, keep waiting (don't mark as complete) | ||
| } | ||
|
|
||
| const location = state.location; | ||
|
|
||
| // Only handle navigation when it's complete (state is idle). | ||
| // During 'loading' or 'submitting', state.location may still have the old pathname, | ||
| // which would cause us to create a span for the wrong route. | ||
| const shouldHandleNavigation = | ||
| state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete); | ||
| (state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete)) && | ||
| state.navigation.state === 'idle'; | ||
|
|
||
| if (shouldHandleNavigation) { | ||
| const navigationHandler = (): void => { | ||
| handleNavigation({ | ||
| location, | ||
| routes, | ||
| navigationType: state.historyAction, | ||
| version, | ||
| basename, | ||
| allRoutes: Array.from(allRoutes), | ||
| }); | ||
| }; | ||
|
|
||
| // Wait for the next render if loading an unsettled route | ||
| if (state.navigation.state !== 'idle') { | ||
| requestAnimationFrame(navigationHandler); | ||
| } else { | ||
| navigationHandler(); | ||
| } | ||
| handleNavigation({ | ||
| location: state.location, | ||
| routes, | ||
| navigationType: state.historyAction, | ||
| version, | ||
| basename, | ||
| allRoutes: Array.from(allRoutes), | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
|
|
@@ -622,6 +612,69 @@ function wrapPatchRoutesOnNavigation( | |
| }; | ||
| } | ||
|
|
||
| function getNavigationKey(location: Location): string { | ||
| return `${location.pathname}${location.search}${location.hash}`; | ||
| } | ||
|
|
||
| function tryUpdateSpanName( | ||
| activeSpan: Span, | ||
| currentSpanName: string | undefined, | ||
| newName: string, | ||
| newSource: string, | ||
| ): void { | ||
| const isNewNameBetter = newName !== currentSpanName && newName.includes(':'); | ||
| if (isNewNameBetter) { | ||
| activeSpan.updateName(newName); | ||
| activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, newSource as 'route' | 'url' | 'custom'); | ||
| } | ||
| } | ||
|
|
||
| function isDuplicateNavigation(client: Client, navigationKey: string): boolean { | ||
| const lastKey = LAST_NAVIGATION_PER_CLIENT.get(client); | ||
| return lastKey === navigationKey; | ||
| } | ||
|
|
||
| function createNavigationSpan(opts: { | ||
| client: Client; | ||
| name: string; | ||
| source: string; | ||
| version: string; | ||
| location: Location; | ||
| routes: RouteObject[]; | ||
| basename?: string; | ||
| allRoutes?: RouteObject[]; | ||
| navigationKey: string; | ||
| }): Span | undefined { | ||
| const { client, name, source, version, location, routes, basename, allRoutes, navigationKey } = opts; | ||
|
|
||
| const navigationSpan = startBrowserTracingNavigationSpan(client, { | ||
| name, | ||
| attributes: { | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source as 'route' | 'url' | 'custom', | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, | ||
| }, | ||
| }); | ||
|
|
||
| if (navigationSpan) { | ||
| LAST_NAVIGATION_PER_CLIENT.set(client, navigationKey); | ||
| patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes); | ||
|
|
||
| const unsubscribe = client.on('spanEnd', endedSpan => { | ||
| if (endedSpan === navigationSpan) { | ||
| // Clear key only if it's still our key (handles overlapping navigations) | ||
| const lastKey = LAST_NAVIGATION_PER_CLIENT.get(client); | ||
| if (lastKey === navigationKey) { | ||
| LAST_NAVIGATION_PER_CLIENT.delete(client); | ||
| } | ||
| unsubscribe(); // Prevent memory leak | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| return navigationSpan; | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| export function handleNavigation(opts: { | ||
| location: Location; | ||
| routes: RouteObject[]; | ||
|
|
@@ -632,15 +685,13 @@ export function handleNavigation(opts: { | |
| allRoutes?: RouteObject[]; | ||
| }): void { | ||
| const { location, routes, navigationType, version, matches, basename, allRoutes } = opts; | ||
| const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename); | ||
| const branches = Array.isArray(matches) ? matches : _matchRoutes(allRoutes || routes, location, basename); | ||
|
|
||
| const client = getClient(); | ||
| if (!client || !CLIENTS_WITH_INSTRUMENT_NAVIGATION.has(client)) { | ||
| return; | ||
| } | ||
|
|
||
| // Avoid starting a navigation span on initial load when a pageload root span is active. | ||
| // This commonly happens when lazy routes resolve during the first render and React Router emits a POP. | ||
| const activeRootSpan = getActiveRootSpan(); | ||
| if (activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload' && navigationType === 'POP') { | ||
| return; | ||
|
|
@@ -649,7 +700,7 @@ export function handleNavigation(opts: { | |
| if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) { | ||
| const [name, source] = resolveRouteNameAndSource( | ||
| location, | ||
| routes, | ||
| allRoutes || routes, | ||
| allRoutes || routes, | ||
| branches as RouteMatch[], | ||
| basename, | ||
|
|
@@ -658,22 +709,25 @@ export function handleNavigation(opts: { | |
| const activeSpan = getActiveSpan(); | ||
| const spanJson = activeSpan && spanToJSON(activeSpan); | ||
| const isAlreadyInNavigationSpan = spanJson?.op === 'navigation'; | ||
| const isSpanForSameRoute = isAlreadyInNavigationSpan && spanJson?.description === name; | ||
|
|
||
| // Cross usage can result in multiple navigation spans being created without this check | ||
| if (!isAlreadyInNavigationSpan) { | ||
| const navigationSpan = startBrowserTracingNavigationSpan(client, { | ||
| const currentNavigationKey = getNavigationKey(location); | ||
| const isNavDuplicate = isDuplicateNavigation(client, currentNavigationKey); | ||
|
|
||
| if (!isSpanForSameRoute && !isNavDuplicate) { | ||
| createNavigationSpan({ | ||
| client, | ||
| name, | ||
| attributes: { | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, | ||
| }, | ||
| source, | ||
| version, | ||
| location, | ||
| routes, | ||
| basename, | ||
| allRoutes, | ||
| navigationKey: currentNavigationKey, | ||
| }); | ||
|
|
||
| // Patch navigation span to handle early cancellation (e.g., document.hidden) | ||
| if (navigationSpan) { | ||
| patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes); | ||
| } | ||
| } else if (isNavDuplicate && isAlreadyInNavigationSpan && activeSpan) { | ||
| tryUpdateSpanName(activeSpan, spanJson?.description, name, source); | ||
| } | ||
|
Comment on lines
+721
to
731
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Stale 🔍 Detailed AnalysisIf 💡 Suggested FixEnsure 🤖 Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
| } | ||
| } | ||
|
|
@@ -727,7 +781,13 @@ function updatePageloadTransaction({ | |
| : (_matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[]); | ||
|
|
||
| if (branches) { | ||
| const [name, source] = resolveRouteNameAndSource(location, routes, allRoutes || routes, branches, basename); | ||
| const [name, source] = resolveRouteNameAndSource( | ||
| location, | ||
| allRoutes || routes, | ||
| allRoutes || routes, | ||
| branches, | ||
| basename, | ||
| ); | ||
|
|
||
| getCurrentScope().setTransactionName(name || '/'); | ||
|
|
||
|
|
@@ -780,7 +840,7 @@ function patchSpanEnd( | |
| if (branches) { | ||
| const [name, source] = resolveRouteNameAndSource( | ||
| location, | ||
| routes, | ||
| currentAllRoutes.length > 0 ? currentAllRoutes : routes, | ||
| currentAllRoutes.length > 0 ? currentAllRoutes : routes, | ||
| branches, | ||
| basename, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.