): void {
/**
* Processes resolved routes by adding them to allRoutes and checking for nested async handlers.
+ * When capturedSpan is provided, updates that specific span instead of the current active span.
+ * This prevents race conditions where a lazy handler resolves after the user has navigated away.
*/
export function processResolvedRoutes(
resolvedRoutes: RouteObject[],
parentRoute?: RouteObject,
currentLocation: Location | null = null,
+ capturedSpan?: Span,
): void {
resolvedRoutes.forEach(child => {
allRoutes.add(child);
@@ -249,17 +257,27 @@ export function processResolvedRoutes(
addResolvedRoutesToParent(resolvedRoutes, parentRoute);
}
- // After processing lazy routes, check if we need to update an active transaction
- const activeRootSpan = getActiveRootSpan();
- if (activeRootSpan) {
- const spanOp = spanToJSON(activeRootSpan).op;
+ // Use captured span if provided, otherwise fall back to current active span
+ const targetSpan = capturedSpan ?? getActiveRootSpan();
+ if (targetSpan) {
+ const spanJson = spanToJSON(targetSpan);
- // Try to use the provided location first, then fall back to global window location if needed
+ // Skip update if span has already ended (timestamp is set when span.end() is called)
+ if (spanJson.timestamp) {
+ DEBUG_BUILD && debug.warn('[React Router] Lazy handler resolved after span ended - skipping update');
+ return;
+ }
+
+ const spanOp = spanJson.op;
+
+ // Use captured location for route matching (ensures we match against the correct route)
+ // Fall back to window.location only if no captured location and no captured span
+ // (i.e., this is not from an async handler)
let location = currentLocation;
- if (!location) {
+ if (!location && !capturedSpan) {
if (typeof WINDOW !== 'undefined') {
const globalLocation = WINDOW.location;
- if (globalLocation) {
+ if (globalLocation?.pathname) {
location = { pathname: globalLocation.pathname };
}
}
@@ -269,14 +287,14 @@ export function processResolvedRoutes(
if (spanOp === 'pageload') {
// Re-run the pageload transaction update with the newly loaded routes
updatePageloadTransaction({
- activeRootSpan,
+ activeRootSpan: targetSpan,
location: { pathname: location.pathname },
routes: Array.from(allRoutes),
allRoutes: Array.from(allRoutes),
});
} else if (spanOp === 'navigation') {
// For navigation spans, update the name with the newly loaded routes
- updateNavigationSpan(activeRootSpan, location, Array.from(allRoutes), false, _matchRoutes);
+ updateNavigationSpan(targetSpan, location, Array.from(allRoutes), false, _matchRoutes);
}
}
}
@@ -713,7 +731,12 @@ function wrapPatchRoutesOnNavigation(
(args as any).patch = (routeId: string, children: RouteObject[]) => {
addRoutesToAllRoutes(children);
const currentActiveRootSpan = getActiveRootSpan();
- if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') {
+ // Only update if we have a valid targetPath (patchRoutesOnNavigation can be called without path)
+ if (
+ targetPath &&
+ currentActiveRootSpan &&
+ (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation'
+ ) {
updateNavigationSpan(
currentActiveRootSpan,
{ pathname: targetPath, search: '', hash: '', state: null, key: 'default' },
@@ -728,7 +751,14 @@ function wrapPatchRoutesOnNavigation(
}
const lazyLoadPromise = (async () => {
- const result = await originalPatchRoutes(args);
+ // Set context so async handlers can access correct targetPath and span
+ const contextToken = setNavigationContext(targetPath, activeRootSpan);
+ let result;
+ try {
+ result = await originalPatchRoutes(args);
+ } finally {
+ clearNavigationContext(contextToken);
+ }
const currentActiveRootSpan = getActiveRootSpan();
if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') {
@@ -1184,17 +1214,3 @@ export function createV6CompatibleWithSentryReactRouterRouting unknown,
route: RouteObject,
handlerKey: string,
- processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
+ processResolvedRoutes: (
+ resolvedRoutes: RouteObject[],
+ parentRoute?: RouteObject,
+ currentLocation?: Location,
+ capturedSpan?: Span,
+ ) => void,
): (...args: unknown[]) => unknown {
const proxy = new Proxy(originalFunction, {
apply(target: (...args: unknown[]) => unknown, thisArg, argArray) {
+ const locationAtInvocation = captureCurrentLocation();
+ const spanAtInvocation = captureActiveSpan();
const result = target.apply(thisArg, argArray);
- handleAsyncHandlerResult(result, route, handlerKey, processResolvedRoutes);
+ handleAsyncHandlerResult(
+ result,
+ route,
+ handlerKey,
+ processResolvedRoutes,
+ locationAtInvocation,
+ spanAtInvocation,
+ );
return result;
},
});
@@ -26,25 +94,33 @@ export function createAsyncHandlerProxy(
/**
* Handles the result of an async handler function call.
+ * Passes the captured span through to ensure the correct span is updated.
*/
export function handleAsyncHandlerResult(
result: unknown,
route: RouteObject,
handlerKey: string,
- processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
+ processResolvedRoutes: (
+ resolvedRoutes: RouteObject[],
+ parentRoute?: RouteObject,
+ currentLocation?: Location,
+ capturedSpan?: Span,
+ ) => void,
+ currentLocation: Location | null,
+ capturedSpan: Span | undefined,
): void {
if (isThenable(result)) {
(result as Promise)
.then((resolvedRoutes: unknown) => {
if (Array.isArray(resolvedRoutes)) {
- processResolvedRoutes(resolvedRoutes, route);
+ processResolvedRoutes(resolvedRoutes, route, currentLocation ?? undefined, capturedSpan);
}
})
.catch((e: unknown) => {
DEBUG_BUILD && debug.warn(`Error resolving async handler '${handlerKey}' for route`, route, e);
});
} else if (Array.isArray(result)) {
- processResolvedRoutes(result, route);
+ processResolvedRoutes(result, route, currentLocation ?? undefined, capturedSpan);
}
}
@@ -53,7 +129,12 @@ export function handleAsyncHandlerResult(
*/
export function checkRouteForAsyncHandler(
route: RouteObject,
- processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
+ processResolvedRoutes: (
+ resolvedRoutes: RouteObject[],
+ parentRoute?: RouteObject,
+ currentLocation?: Location,
+ capturedSpan?: Span,
+ ) => void,
): void {
// Set up proxies for any functions in the route's handle
if (route.handle && typeof route.handle === 'object') {
diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts
index 8431e283108b..96c178b64c14 100644
--- a/packages/react/src/reactrouter-compat-utils/utils.ts
+++ b/packages/react/src/reactrouter-compat-utils/utils.ts
@@ -1,10 +1,57 @@
-import type { TransactionSource } from '@sentry/core';
+import type { Span, TransactionSource } from '@sentry/core';
+import { debug, getActiveSpan, getRootSpan, spanToJSON } from '@sentry/core';
+import { DEBUG_BUILD } from '../debug-build';
import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../types';
// Global variables that these utilities depend on
let _matchRoutes: MatchRoutes;
let _stripBasename: boolean = false;
+// Navigation context stack for nested/concurrent patchRoutesOnNavigation calls.
+// Required because window.location hasn't updated yet when handlers are invoked.
+interface NavigationContext {
+ token: object;
+ targetPath: string | undefined;
+ span: Span | undefined;
+}
+
+const _navigationContextStack: NavigationContext[] = [];
+const MAX_CONTEXT_STACK_SIZE = 10;
+
+/**
+ * Pushes a navigation context and returns a unique token for cleanup.
+ * The token uses object identity for uniqueness (no counter needed).
+ */
+export function setNavigationContext(targetPath: string | undefined, span: Span | undefined): object {
+ const token = {};
+ // Prevent unbounded stack growth - oldest (likely stale) contexts are evicted first
+ if (_navigationContextStack.length >= MAX_CONTEXT_STACK_SIZE) {
+ DEBUG_BUILD && debug.warn('[React Router] Navigation context stack overflow - removing oldest context');
+ _navigationContextStack.shift();
+ }
+ _navigationContextStack.push({ token, targetPath, span });
+ return token;
+}
+
+/**
+ * Clears the navigation context if it's on top of the stack (LIFO).
+ * If our context is not on top (out-of-order completion), we leave it -
+ * it will be cleaned up by overflow protection when the stack fills up.
+ */
+export function clearNavigationContext(token: object): void {
+ const top = _navigationContextStack[_navigationContextStack.length - 1];
+ if (top?.token === token) {
+ _navigationContextStack.pop();
+ }
+}
+
+/** Gets the current (most recent) navigation context if inside a patchRoutesOnNavigation call. */
+export function getNavigationContext(): NavigationContext | null {
+ const length = _navigationContextStack.length;
+ // The `?? null` converts undefined (from array access) to null to match return type
+ return length > 0 ? (_navigationContextStack[length - 1] ?? null) : null;
+}
+
/**
* Initialize function to set dependencies that the router utilities need.
* Must be called before using any of the exported utility functions.
@@ -273,3 +320,20 @@ export function resolveRouteNameAndSource(
return [name || location.pathname, source];
}
+
+/**
+ * Gets the active root span if it's a pageload or navigation span.
+ */
+export function getActiveRootSpan(): Span | undefined {
+ const span = getActiveSpan();
+ const rootSpan = span ? getRootSpan(span) : undefined;
+
+ if (!rootSpan) {
+ return undefined;
+ }
+
+ const op = spanToJSON(rootSpan).op;
+
+ // Only use this root span if it is a pageload or navigation span
+ return op === 'navigation' || op === 'pageload' ? rootSpan : undefined;
+}
diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx
index 276a5b9950fc..3d2b4f198cf5 100644
--- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx
+++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx
@@ -59,6 +59,7 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({
transactionNameHasWildcard: vi.fn((name: string) => {
return name.includes('/*') || name === '*' || name.endsWith('*');
}),
+ getActiveRootSpan: vi.fn(() => undefined),
}));
vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({
diff --git a/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts b/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts
index 732b893ea8f8..0d1a493e08f2 100644
--- a/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts
+++ b/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts
@@ -106,7 +106,9 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
proxy();
// Since handleAsyncHandlerResult is called internally, we verify through its side effects
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(['route1', 'route2'], route);
+ // The third parameter is the captured location (undefined in jsdom test environment)
+ // The fourth parameter is the captured span (undefined since no active span in test)
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(['route1', 'route2'], route, undefined, undefined);
});
it('should handle functions that throw exceptions', () => {
@@ -137,35 +139,38 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
proxy();
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith([], route);
+ // The third parameter is the captured location (undefined in jsdom test environment)
+ // The fourth parameter is the captured span (undefined since no active span in test)
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith([], route, undefined, undefined);
});
});
describe('handleAsyncHandlerResult', () => {
const route: RouteObject = { path: '/test' };
const handlerKey = 'testHandler';
+ const mockLocation = { pathname: '/test', search: '', hash: '', state: null, key: 'default' };
it('should handle array results directly', () => {
const routes: RouteObject[] = [{ path: '/route1' }, { path: '/route2' }];
- handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined);
});
it('should handle empty array results', () => {
const routes: RouteObject[] = [];
- handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined);
});
it('should handle Promise results that resolve to arrays', async () => {
const routes: RouteObject[] = [{ path: '/route1' }, { path: '/route2' }];
const promiseResult = Promise.resolve(routes);
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
// Wait for the promise to resolve
await promiseResult;
@@ -173,25 +178,25 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
// Use setTimeout to wait for the async handling
await new Promise(resolve => setTimeout(resolve, 0));
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined);
});
it('should handle Promise results that resolve to empty arrays', async () => {
const routes: RouteObject[] = [];
const promiseResult = Promise.resolve(routes);
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
await promiseResult;
await new Promise(resolve => setTimeout(resolve, 0));
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined);
});
it('should handle Promise results that resolve to non-arrays', async () => {
const promiseResult = Promise.resolve('not an array');
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
await promiseResult;
await new Promise(resolve => setTimeout(resolve, 0));
@@ -202,7 +207,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
it('should handle Promise results that resolve to null', async () => {
const promiseResult = Promise.resolve(null);
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
await promiseResult;
await new Promise(resolve => setTimeout(resolve, 0));
@@ -213,7 +218,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
it('should handle Promise results that resolve to undefined', async () => {
const promiseResult = Promise.resolve(undefined);
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
await promiseResult;
await new Promise(resolve => setTimeout(resolve, 0));
@@ -224,7 +229,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
it('should handle Promise rejections gracefully', async () => {
const promiseResult = Promise.reject(new Error('Test error'));
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
// Wait for the promise to be handled
await new Promise(resolve => setTimeout(resolve, 0));
@@ -240,7 +245,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
it('should handle Promise rejections with non-Error values', async () => {
const promiseResult = Promise.reject('string error');
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
await new Promise(resolve => setTimeout(resolve, 0));
@@ -253,25 +258,25 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
});
it('should ignore non-promise, non-array results', () => {
- handleAsyncHandlerResult('string result', route, handlerKey, mockProcessResolvedRoutes);
- handleAsyncHandlerResult(123, route, handlerKey, mockProcessResolvedRoutes);
- handleAsyncHandlerResult({ not: 'array' }, route, handlerKey, mockProcessResolvedRoutes);
- handleAsyncHandlerResult(null, route, handlerKey, mockProcessResolvedRoutes);
- handleAsyncHandlerResult(undefined, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult('string result', route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
+ handleAsyncHandlerResult(123, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
+ handleAsyncHandlerResult({ not: 'array' }, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
+ handleAsyncHandlerResult(null, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
+ handleAsyncHandlerResult(undefined, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
});
it('should ignore boolean values', () => {
- handleAsyncHandlerResult(true, route, handlerKey, mockProcessResolvedRoutes);
- handleAsyncHandlerResult(false, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(true, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
+ handleAsyncHandlerResult(false, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
});
it('should ignore functions as results', () => {
const functionResult = () => 'test';
- handleAsyncHandlerResult(functionResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(functionResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
});
@@ -281,7 +286,14 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
then: 'not a function',
};
- handleAsyncHandlerResult(fakeThenableButNotPromise, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(
+ fakeThenableButNotPromise,
+ route,
+ handlerKey,
+ mockProcessResolvedRoutes,
+ mockLocation,
+ undefined,
+ );
expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
});
@@ -291,7 +303,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
then: null,
};
- handleAsyncHandlerResult(almostPromise, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(almostPromise, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
});
@@ -306,12 +318,19 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
const routes: RouteObject[] = [{ path: '/dynamic1' }, { path: '/dynamic2' }];
const promiseResult = Promise.resolve(routes);
- handleAsyncHandlerResult(promiseResult, complexRoute, 'complexHandler', mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(
+ promiseResult,
+ complexRoute,
+ 'complexHandler',
+ mockProcessResolvedRoutes,
+ mockLocation,
+ undefined,
+ );
await promiseResult;
await new Promise(resolve => setTimeout(resolve, 0));
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, complexRoute);
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, complexRoute, mockLocation, undefined);
});
it('should handle nested route objects in arrays', () => {
@@ -322,9 +341,18 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
},
];
- handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined);
+ });
+
+ it('should convert null location to undefined for processResolvedRoutes', () => {
+ const routes: RouteObject[] = [{ path: '/route1' }];
+
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes, null, undefined);
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ // When null is passed, it should convert to undefined for processResolvedRoutes
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, undefined, undefined);
});
});
diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts
index 438b026104bd..401ea648b0fc 100644
--- a/packages/react/test/reactrouter-compat-utils/utils.test.ts
+++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts
@@ -1,5 +1,7 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
+ clearNavigationContext,
+ getNavigationContext,
getNormalizedName,
getNumberOfUrlSegments,
initializeRouterUtils,
@@ -9,6 +11,7 @@ import {
prefixWithSlash,
rebuildRoutePathFromAllRoutes,
resolveRouteNameAndSource,
+ setNavigationContext,
transactionNameHasWildcard,
} from '../../src/reactrouter-compat-utils';
import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../../src/types';
@@ -664,4 +667,137 @@ describe('reactrouter-compat-utils/utils', () => {
expect(transactionNameHasWildcard('/path/to/asterisk')).toBe(false); // 'asterisk' contains 'isk' but not '*'
});
});
+
+ describe('navigation context management', () => {
+ // Clean up navigation context after each test by popping until empty
+ afterEach(() => {
+ // Pop all remaining contexts
+ while (getNavigationContext() !== null) {
+ const ctx = getNavigationContext();
+ if (ctx) {
+ clearNavigationContext((ctx as any).token);
+ }
+ }
+ });
+
+ describe('setNavigationContext', () => {
+ it('should return unique tokens (object identity)', () => {
+ const token1 = setNavigationContext('/path1', undefined);
+ const token2 = setNavigationContext('/path2', undefined);
+ const token3 = setNavigationContext('/path3', undefined);
+
+ // Each token should be a unique object
+ expect(token1).not.toBe(token2);
+ expect(token2).not.toBe(token3);
+ expect(token1).not.toBe(token3);
+ });
+
+ it('should store targetPath and span in context', () => {
+ const mockSpan = { name: 'test-span' } as any;
+ setNavigationContext('/test-path', mockSpan);
+
+ const context = getNavigationContext();
+ expect(context).not.toBeNull();
+ expect(context?.targetPath).toBe('/test-path');
+ expect(context?.span).toBe(mockSpan);
+ });
+
+ it('should handle undefined targetPath', () => {
+ setNavigationContext(undefined, undefined);
+
+ const context = getNavigationContext();
+ expect(context).not.toBeNull();
+ expect(context?.targetPath).toBeUndefined();
+ });
+ });
+
+ describe('clearNavigationContext', () => {
+ it('should remove context when token matches top of stack (LIFO)', () => {
+ const token = setNavigationContext('/test', undefined);
+
+ expect(getNavigationContext()).not.toBeNull();
+
+ clearNavigationContext(token);
+
+ expect(getNavigationContext()).toBeNull();
+ });
+
+ it('should NOT remove context when token is not on top (out-of-order completion)', () => {
+ // Simulate: Nav1 starts, Nav2 starts, Nav1 tries to complete first
+ const token1 = setNavigationContext('/nav1', undefined);
+ const token2 = setNavigationContext('/nav2', undefined);
+
+ // Most recent should be nav2
+ expect(getNavigationContext()?.targetPath).toBe('/nav2');
+
+ // Nav1 tries to complete first (out of order) - should NOT pop because nav1 is not on top
+ clearNavigationContext(token1);
+
+ // Nav2 should still be the current context (nav1's context is still buried)
+ expect(getNavigationContext()?.targetPath).toBe('/nav2');
+
+ // Nav2 completes - should pop because nav2 IS on top
+ clearNavigationContext(token2);
+
+ // Now nav1's stale context is on top (will be cleaned by overflow protection)
+ expect(getNavigationContext()?.targetPath).toBe('/nav1');
+ });
+
+ it('should not throw when clearing with unknown token', () => {
+ const unknownToken = {};
+ expect(() => clearNavigationContext(unknownToken)).not.toThrow();
+ });
+
+ it('should correctly handle LIFO cleanup order', () => {
+ const token1 = setNavigationContext('/path1', undefined);
+ const token2 = setNavigationContext('/path2', undefined);
+ const token3 = setNavigationContext('/path3', undefined);
+
+ // Clear in LIFO order
+ clearNavigationContext(token3);
+ expect(getNavigationContext()?.targetPath).toBe('/path2');
+
+ clearNavigationContext(token2);
+ expect(getNavigationContext()?.targetPath).toBe('/path1');
+
+ clearNavigationContext(token1);
+ expect(getNavigationContext()).toBeNull();
+ });
+ });
+
+ describe('getNavigationContext', () => {
+ it('should return null when stack is empty', () => {
+ expect(getNavigationContext()).toBeNull();
+ });
+
+ it('should return the most recent context', () => {
+ setNavigationContext('/first', undefined);
+ setNavigationContext('/second', undefined);
+ setNavigationContext('/third', undefined);
+
+ expect(getNavigationContext()?.targetPath).toBe('/third');
+ });
+ });
+
+ describe('stack overflow protection', () => {
+ it('should remove oldest context when stack exceeds limit', () => {
+ // Push 12 contexts (limit is 10)
+ const tokens: object[] = [];
+ for (let i = 0; i < 12; i++) {
+ tokens.push(setNavigationContext(`/path${i}`, undefined));
+ }
+
+ // Most recent should be /path11
+ expect(getNavigationContext()?.targetPath).toBe('/path11');
+
+ // The oldest contexts (path0, path1) were evicted due to overflow
+ // Trying to clear them does nothing (their tokens no longer match anything)
+ clearNavigationContext(tokens[0]!);
+ clearNavigationContext(tokens[1]!);
+
+ // /path11 should still be current
+ expect(getNavigationContext()?.targetPath).toBe('/path11');
+ });
+ });
+ });
});
diff --git a/yarn.lock b/yarn.lock
index 210e9d186cfb..a0d0b72f6718 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6993,10 +6993,10 @@
detect-libc "^2.0.3"
node-abi "^3.73.0"
-"@sentry-internal/node-native-stacktrace@^0.2.2":
- version "0.2.2"
- resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.2.2.tgz#b32dde884642f100dd691b12b643361040825eeb"
- integrity sha512-ZRS+a1Ik+w6awjp9na5vHBqLNkIxysfGDswLVAkjtVdBUxtfsEVI8OA6r8PijJC5Gm1oAJJap2e9H7TSiCUQIQ==
+"@sentry-internal/node-native-stacktrace@^0.3.0":
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.3.0.tgz#68c80dcf11ee070a3a54406b35d4571952caa793"
+ integrity sha512-ef0M2y2JDrC/H0AxMJJQInGTdZTlnwa6AAVWR4fMOpJRubkfdH2IZXE/nWU0Nj74oeJLQgdPtS6DeijLJtqq8Q==
dependencies:
detect-libc "^2.0.4"
node-abi "^3.73.0"