Skip to content

Commit 7c622f8

Browse files
committed
fix(react): Patch spanEnd for potentially cancelled lazy-route pageloads
1 parent 8e3afe2 commit 7c622f8

File tree

3 files changed

+291
-14
lines changed

3 files changed

+291
-14
lines changed

dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,124 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes',
377377
expect(backNavigationEvent.transaction).toBe('/');
378378
expect(backNavigationEvent.contexts?.trace?.op).toBe('navigation');
379379
});
380+
381+
test('Updates pageload transaction name correctly when span is cancelled early (document.hidden simulation)', async ({
382+
page,
383+
}) => {
384+
const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
385+
return (
386+
!!transactionEvent?.transaction &&
387+
transactionEvent.contexts?.trace?.op === 'pageload' &&
388+
transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId'
389+
);
390+
});
391+
392+
// Set up the page to simulate document.hidden before navigation
393+
await page.addInitScript(() => {
394+
// Wait a bit for Sentry to initialize and start the pageload span
395+
setTimeout(() => {
396+
// Override document.hidden to simulate tab switching
397+
Object.defineProperty(document, 'hidden', {
398+
configurable: true,
399+
get: function () {
400+
return true;
401+
},
402+
});
403+
404+
// Dispatch visibilitychange event to trigger the idle span cancellation logic
405+
document.dispatchEvent(new Event('visibilitychange'));
406+
}, 100); // Small delay to ensure the span has started
407+
});
408+
409+
// Navigate to the lazy route URL
410+
await page.goto('/lazy/inner/1/2/3');
411+
412+
const event = await transactionPromise;
413+
414+
// Verify the lazy route content eventually loads (even though span was cancelled early)
415+
const lazyRouteContent = page.locator('id=innermost-lazy-route');
416+
await expect(lazyRouteContent).toBeVisible();
417+
418+
// Validate that the transaction event has the correct parameterized route name
419+
// even though the span was cancelled early due to document.hidden
420+
expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId');
421+
expect(event.type).toBe('transaction');
422+
expect(event.contexts?.trace?.op).toBe('pageload');
423+
424+
// Check if the span was indeed cancelled (should have idle_span_finish_reason attribute)
425+
const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason'];
426+
if (idleSpanFinishReason) {
427+
// If the span was cancelled due to visibility change, verify it still got the right name
428+
expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason);
429+
}
430+
});
431+
432+
test('Updates navigation transaction name correctly when span is cancelled early (document.hidden simulation)', async ({
433+
page,
434+
}) => {
435+
// First go to home page
436+
await page.goto('/');
437+
438+
const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
439+
return (
440+
!!transactionEvent?.transaction &&
441+
transactionEvent.contexts?.trace?.op === 'navigation' &&
442+
transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId'
443+
);
444+
});
445+
446+
// Set up a listener to simulate document.hidden after clicking the navigation link
447+
await page.evaluate(() => {
448+
// Override document.hidden to simulate tab switching
449+
let hiddenValue = false;
450+
Object.defineProperty(document, 'hidden', {
451+
configurable: true,
452+
get: function () {
453+
return hiddenValue;
454+
},
455+
});
456+
457+
// Listen for clicks on the navigation link and simulate document.hidden shortly after
458+
document.addEventListener(
459+
'click',
460+
() => {
461+
setTimeout(() => {
462+
hiddenValue = true;
463+
// Dispatch visibilitychange event to trigger the idle span cancellation logic
464+
document.dispatchEvent(new Event('visibilitychange'));
465+
}, 50); // Small delay to ensure the navigation span has started
466+
},
467+
{ once: true },
468+
);
469+
});
470+
471+
// Click the navigation link to navigate to the lazy route
472+
const navigationLink = page.locator('id=navigation');
473+
await expect(navigationLink).toBeVisible();
474+
await navigationLink.click();
475+
476+
const event = await navigationPromise;
477+
478+
// Verify the lazy route content eventually loads (even though span was cancelled early)
479+
const lazyRouteContent = page.locator('id=innermost-lazy-route');
480+
await expect(lazyRouteContent).toBeVisible();
481+
482+
// Validate that the transaction event has the correct parameterized route name
483+
// even though the span was cancelled early due to document.hidden
484+
expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId');
485+
expect(event.type).toBe('transaction');
486+
expect(event.contexts?.trace?.op).toBe('navigation');
487+
488+
// Check if the span was indeed cancelled (should have cancellation_reason attribute or idle_span_finish_reason)
489+
const cancellationReason = event.contexts?.trace?.data?.['sentry.cancellation_reason'];
490+
const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason'];
491+
492+
// Verify that the span was cancelled due to document.hidden
493+
if (cancellationReason) {
494+
expect(cancellationReason).toBe('document.hidden');
495+
}
496+
497+
if (idleSpanFinishReason) {
498+
expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason);
499+
}
500+
});

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

Lines changed: 133 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -667,14 +667,19 @@ export function handleNavigation(opts: {
667667

668668
// Cross usage can result in multiple navigation spans being created without this check
669669
if (!isAlreadyInNavigationSpan) {
670-
startBrowserTracingNavigationSpan(client, {
670+
const navigationSpan = startBrowserTracingNavigationSpan(client, {
671671
name,
672672
attributes: {
673673
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
674674
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
675675
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`,
676676
},
677677
});
678+
679+
// Patch navigation span to handle early cancellation (e.g., document.hidden)
680+
if (navigationSpan) {
681+
patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes);
682+
}
678683
}
679684
}
680685
}
@@ -727,27 +732,141 @@ function updatePageloadTransaction({
727732
: (_matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[]);
728733

729734
if (branches) {
730-
let name,
731-
source: TransactionSource = 'url';
732-
733-
const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes || routes);
734-
735-
if (isInDescendantRoute) {
736-
name = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes || routes, location));
737-
source = 'route';
738-
}
739-
740-
if (!isInDescendantRoute || !name) {
741-
[name, source] = getNormalizedName(routes, location, branches, basename);
742-
}
735+
const [name, source] = getTransactionNameAndSource(location, routes, branches, basename, allRoutes);
743736

744737
getCurrentScope().setTransactionName(name || '/');
745738

746739
if (activeRootSpan) {
747740
activeRootSpan.updateName(name);
748741
activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
742+
743+
// Patch span.end() to ensure we update the name one last time before the span is sent
744+
patchPageloadSpanEnd(activeRootSpan, location, routes, basename, allRoutes);
745+
}
746+
}
747+
}
748+
749+
/**
750+
* Extracts the transaction name and source from the route information.
751+
*/
752+
function getTransactionNameAndSource(
753+
location: Location,
754+
routes: RouteObject[],
755+
branches: RouteMatch[],
756+
basename: string | undefined,
757+
allRoutes: RouteObject[] | undefined,
758+
): [string, TransactionSource] {
759+
let name: string | undefined;
760+
let source: TransactionSource = 'url';
761+
762+
const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes || routes);
763+
764+
if (isInDescendantRoute) {
765+
name = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes || routes, location));
766+
source = 'route';
767+
}
768+
769+
if (!isInDescendantRoute || !name) {
770+
[name, source] = getNormalizedName(routes, location, branches, basename);
771+
}
772+
773+
return [name || '/', source];
774+
}
775+
776+
/**
777+
* Patches the span.end() method to update the transaction name one last time before the span is sent.
778+
* This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading.
779+
*/
780+
function patchPageloadSpanEnd(
781+
span: Span,
782+
location: Location,
783+
routes: RouteObject[],
784+
basename: string | undefined,
785+
allRoutes: RouteObject[] | undefined,
786+
): void {
787+
const hasEndBeenPatched = (span as { __sentry_pageload_end_patched__?: boolean })?.__sentry_pageload_end_patched__;
788+
789+
if (hasEndBeenPatched || !span.end) {
790+
return;
791+
}
792+
793+
const originalEnd = span.end.bind(span);
794+
795+
span.end = function patchedEnd(...args) {
796+
// Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet)
797+
const spanJson = spanToJSON(span);
798+
const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
799+
if (currentSource !== 'route') {
800+
// Last chance to update the transaction name with the latest route info
801+
const branches = _matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[];
802+
803+
if (branches) {
804+
const [latestName, latestSource] = getTransactionNameAndSource(location, routes, branches, basename, allRoutes);
805+
806+
span.updateName(latestName);
807+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, latestSource);
808+
}
749809
}
810+
811+
return originalEnd(...args);
812+
};
813+
814+
// Mark this span as having its end() method patched to prevent duplicate patching
815+
addNonEnumerableProperty(
816+
span as { __sentry_pageload_end_patched__?: boolean },
817+
'__sentry_pageload_end_patched__',
818+
true,
819+
);
820+
}
821+
822+
/**
823+
* Patches the navigation span.end() method to update the transaction name one last time before the span is sent.
824+
* This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading.
825+
*/
826+
function patchNavigationSpanEnd(
827+
span: Span,
828+
location: Location,
829+
routes: RouteObject[],
830+
basename: string | undefined,
831+
allRoutes: RouteObject[] | undefined,
832+
): void {
833+
const hasEndBeenPatched = (span as { __sentry_navigation_end_patched__?: boolean })
834+
?.__sentry_navigation_end_patched__;
835+
836+
if (hasEndBeenPatched || !span.end) {
837+
return;
750838
}
839+
840+
const originalEnd = span.end.bind(span);
841+
842+
span.end = function patchedEnd(...args) {
843+
// Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet)
844+
const spanJson = spanToJSON(span);
845+
const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
846+
if (currentSource !== 'route') {
847+
// Last chance to update the transaction name with the latest route info
848+
const branches = _matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[];
849+
850+
if (branches) {
851+
const [name, source] = resolveRouteNameAndSource(location, routes, allRoutes || routes, branches, basename);
852+
853+
// Only update if we have a valid name and the span hasn't finished
854+
if (name && !spanJson.timestamp) {
855+
span.updateName(name);
856+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
857+
}
858+
}
859+
}
860+
861+
return originalEnd(...args);
862+
};
863+
864+
// Mark this span as having its end() method patched to prevent duplicate patching
865+
addNonEnumerableProperty(
866+
span as { __sentry_navigation_end_patched__?: boolean },
867+
'__sentry_navigation_end_patched__',
868+
true,
869+
);
751870
}
752871

753872
// eslint-disable-next-line @typescript-eslint/no-explicit-any

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,41 @@ describe('reactrouter-compat-utils/instrumentation', () => {
140140
expect(typeof integration.afterAllSetup).toBe('function');
141141
});
142142
});
143+
144+
describe('span.end() patching for early cancellation', () => {
145+
it('should update transaction name when span.end() is called during cancellation', () => {
146+
const mockEnd = vi.fn();
147+
let patchedEnd: ((...args: any[]) => any) | null = null;
148+
149+
const updateNameMock = vi.fn();
150+
const setAttributeMock = vi.fn();
151+
152+
const testSpan = {
153+
updateName: updateNameMock,
154+
setAttribute: setAttributeMock,
155+
get end() {
156+
return patchedEnd || mockEnd;
157+
},
158+
set end(fn: (...args: any[]) => any) {
159+
patchedEnd = fn;
160+
},
161+
} as unknown as Span;
162+
163+
// Simulate the patching behavior
164+
const originalEnd = testSpan.end.bind(testSpan);
165+
(testSpan as any).end = function patchedEndFn(...args: any[]) {
166+
// This simulates what happens in the actual implementation
167+
updateNameMock('Updated Route');
168+
setAttributeMock('sentry.source', 'route');
169+
return originalEnd(...args);
170+
};
171+
172+
// Call the patched end
173+
testSpan.end(12345);
174+
175+
expect(updateNameMock).toHaveBeenCalledWith('Updated Route');
176+
expect(setAttributeMock).toHaveBeenCalledWith('sentry.source', 'route');
177+
expect(mockEnd).toHaveBeenCalledWith(12345);
178+
});
179+
});
143180
});

0 commit comments

Comments
 (0)