Skip to content

Commit 853d062

Browse files
committed
Use token-based LIFO for navigation context cleanup
1 parent c7f9647 commit 853d062

File tree

4 files changed

+165
-14
lines changed

4 files changed

+165
-14
lines changed

packages/react/src/reactrouter-compat-utils/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export {
2626
pathIsWildcardAndHasChildren,
2727
getNumberOfUrlSegments,
2828
transactionNameHasWildcard,
29+
getActiveRootSpan,
30+
// Navigation context functions (internal use and testing)
31+
setNavigationContext,
32+
clearNavigationContext,
33+
getNavigationContext,
2934
} from './utils';
3035

3136
// Lazy route exports

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -747,12 +747,12 @@ function wrapPatchRoutesOnNavigation(
747747

748748
const lazyLoadPromise = (async () => {
749749
// Set context so async handlers can access correct targetPath and span
750-
setNavigationContext(targetPath, activeRootSpan);
750+
const contextToken = setNavigationContext(targetPath, activeRootSpan);
751751
let result;
752752
try {
753753
result = await originalPatchRoutes(args);
754754
} finally {
755-
clearNavigationContext(activeRootSpan);
755+
clearNavigationContext(contextToken);
756756
}
757757

758758
const currentActiveRootSpan = getActiveRootSpan();

packages/react/src/reactrouter-compat-utils/utils.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Span, TransactionSource } from '@sentry/core';
2-
import { getActiveSpan, getRootSpan, spanToJSON } from '@sentry/core';
2+
import { debug, getActiveSpan, getRootSpan, spanToJSON } from '@sentry/core';
3+
import { DEBUG_BUILD } from '../debug-build';
34
import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../types';
45

56
// Global variables that these utilities depend on
@@ -8,29 +9,38 @@ let _stripBasename: boolean = false;
89

910
// Navigation context stack for nested/concurrent patchRoutesOnNavigation calls.
1011
// Required because window.location hasn't updated yet when handlers are invoked.
11-
// Uses a stack to handle overlapping navigations correctly (LIFO semantics).
1212
interface NavigationContext {
13+
token: object;
1314
targetPath: string | undefined;
1415
span: Span | undefined;
1516
}
1617

1718
const _navigationContextStack: NavigationContext[] = [];
1819
const MAX_CONTEXT_STACK_SIZE = 10;
1920

20-
/** Pushes a navigation context before invoking patchRoutesOnNavigation. */
21-
export function setNavigationContext(targetPath: string | undefined, span: Span | undefined): void {
22-
// Prevent unbounded stack growth from cleanup failures or rapid navigations
21+
/**
22+
* Pushes a navigation context and returns a unique token for cleanup.
23+
* The token uses object identity for uniqueness (no counter needed).
24+
*/
25+
export function setNavigationContext(targetPath: string | undefined, span: Span | undefined): object {
26+
const token = {};
27+
// Prevent unbounded stack growth - oldest (likely stale) contexts are evicted first
2328
if (_navigationContextStack.length >= MAX_CONTEXT_STACK_SIZE) {
24-
_navigationContextStack.shift(); // Remove oldest
29+
DEBUG_BUILD && debug.warn('[React Router] Navigation context stack overflow - removing oldest context');
30+
_navigationContextStack.shift();
2531
}
26-
_navigationContextStack.push({ targetPath, span });
32+
_navigationContextStack.push({ token, targetPath, span });
33+
return token;
2734
}
2835

29-
/** Pops the navigation context for the given span after patchRoutesOnNavigation completes. */
30-
export function clearNavigationContext(span: Span | undefined): void {
31-
// Only pop if top of stack matches this span to prevent corruption from mismatched calls
36+
/**
37+
* Clears the navigation context if it's on top of the stack (LIFO).
38+
* If our context is not on top (out-of-order completion), we leave it -
39+
* it will be cleaned up by overflow protection when the stack fills up.
40+
*/
41+
export function clearNavigationContext(token: object): void {
3242
const top = _navigationContextStack[_navigationContextStack.length - 1];
33-
if (top?.span === span) {
43+
if (top?.token === token) {
3444
_navigationContextStack.pop();
3545
}
3646
}

packages/react/test/reactrouter-compat-utils/utils.test.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { beforeEach, describe, expect, it, vi } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22
import {
3+
clearNavigationContext,
4+
getNavigationContext,
35
getNormalizedName,
46
getNumberOfUrlSegments,
57
initializeRouterUtils,
@@ -9,6 +11,7 @@ import {
911
prefixWithSlash,
1012
rebuildRoutePathFromAllRoutes,
1113
resolveRouteNameAndSource,
14+
setNavigationContext,
1215
transactionNameHasWildcard,
1316
} from '../../src/reactrouter-compat-utils';
1417
import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../../src/types';
@@ -664,4 +667,137 @@ describe('reactrouter-compat-utils/utils', () => {
664667
expect(transactionNameHasWildcard('/path/to/asterisk')).toBe(false); // 'asterisk' contains 'isk' but not '*'
665668
});
666669
});
670+
671+
describe('navigation context management', () => {
672+
// Clean up navigation context after each test by popping until empty
673+
afterEach(() => {
674+
// Pop all remaining contexts
675+
while (getNavigationContext() !== null) {
676+
const ctx = getNavigationContext();
677+
if (ctx) {
678+
clearNavigationContext((ctx as any).token);
679+
}
680+
}
681+
});
682+
683+
describe('setNavigationContext', () => {
684+
it('should return unique tokens (object identity)', () => {
685+
const token1 = setNavigationContext('/path1', undefined);
686+
const token2 = setNavigationContext('/path2', undefined);
687+
const token3 = setNavigationContext('/path3', undefined);
688+
689+
// Each token should be a unique object
690+
expect(token1).not.toBe(token2);
691+
expect(token2).not.toBe(token3);
692+
expect(token1).not.toBe(token3);
693+
});
694+
695+
it('should store targetPath and span in context', () => {
696+
const mockSpan = { name: 'test-span' } as any;
697+
setNavigationContext('/test-path', mockSpan);
698+
699+
const context = getNavigationContext();
700+
expect(context).not.toBeNull();
701+
expect(context?.targetPath).toBe('/test-path');
702+
expect(context?.span).toBe(mockSpan);
703+
});
704+
705+
it('should handle undefined targetPath', () => {
706+
setNavigationContext(undefined, undefined);
707+
708+
const context = getNavigationContext();
709+
expect(context).not.toBeNull();
710+
expect(context?.targetPath).toBeUndefined();
711+
});
712+
});
713+
714+
describe('clearNavigationContext', () => {
715+
it('should remove context when token matches top of stack (LIFO)', () => {
716+
const token = setNavigationContext('/test', undefined);
717+
718+
expect(getNavigationContext()).not.toBeNull();
719+
720+
clearNavigationContext(token);
721+
722+
expect(getNavigationContext()).toBeNull();
723+
});
724+
725+
it('should NOT remove context when token is not on top (out-of-order completion)', () => {
726+
// Simulate: Nav1 starts, Nav2 starts, Nav1 tries to complete first
727+
const token1 = setNavigationContext('/nav1', undefined);
728+
const token2 = setNavigationContext('/nav2', undefined);
729+
730+
// Most recent should be nav2
731+
expect(getNavigationContext()?.targetPath).toBe('/nav2');
732+
733+
// Nav1 tries to complete first (out of order) - should NOT pop because nav1 is not on top
734+
clearNavigationContext(token1);
735+
736+
// Nav2 should still be the current context (nav1's context is still buried)
737+
expect(getNavigationContext()?.targetPath).toBe('/nav2');
738+
739+
// Nav2 completes - should pop because nav2 IS on top
740+
clearNavigationContext(token2);
741+
742+
// Now nav1's stale context is on top (will be cleaned by overflow protection)
743+
expect(getNavigationContext()?.targetPath).toBe('/nav1');
744+
});
745+
746+
it('should not throw when clearing with unknown token', () => {
747+
const unknownToken = {};
748+
expect(() => clearNavigationContext(unknownToken)).not.toThrow();
749+
});
750+
751+
it('should correctly handle LIFO cleanup order', () => {
752+
const token1 = setNavigationContext('/path1', undefined);
753+
const token2 = setNavigationContext('/path2', undefined);
754+
const token3 = setNavigationContext('/path3', undefined);
755+
756+
// Clear in LIFO order
757+
clearNavigationContext(token3);
758+
expect(getNavigationContext()?.targetPath).toBe('/path2');
759+
760+
clearNavigationContext(token2);
761+
expect(getNavigationContext()?.targetPath).toBe('/path1');
762+
763+
clearNavigationContext(token1);
764+
expect(getNavigationContext()).toBeNull();
765+
});
766+
});
767+
768+
describe('getNavigationContext', () => {
769+
it('should return null when stack is empty', () => {
770+
expect(getNavigationContext()).toBeNull();
771+
});
772+
773+
it('should return the most recent context', () => {
774+
setNavigationContext('/first', undefined);
775+
setNavigationContext('/second', undefined);
776+
setNavigationContext('/third', undefined);
777+
778+
expect(getNavigationContext()?.targetPath).toBe('/third');
779+
});
780+
});
781+
782+
describe('stack overflow protection', () => {
783+
it('should remove oldest context when stack exceeds limit', () => {
784+
// Push 12 contexts (limit is 10)
785+
const tokens: object[] = [];
786+
for (let i = 0; i < 12; i++) {
787+
tokens.push(setNavigationContext(`/path${i}`, undefined));
788+
}
789+
790+
// Most recent should be /path11
791+
expect(getNavigationContext()?.targetPath).toBe('/path11');
792+
793+
// The oldest contexts (path0, path1) were evicted due to overflow
794+
// Trying to clear them does nothing (their tokens no longer match anything)
795+
clearNavigationContext(tokens[0]!);
796+
clearNavigationContext(tokens[1]!);
797+
798+
// /path11 should still be current
799+
expect(getNavigationContext()?.targetPath).toBe('/path11');
800+
});
801+
});
802+
});
667803
});

0 commit comments

Comments
 (0)