Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ const lazyRouteManifest = [
'/deep/level2/level3/:id',
'/slow-fetch/:id',
'/wildcard-lazy/:id',
'/lazy-gql-a/fetch',
'/lazy-gql-b/fetch',
];

Sentry.init({
Expand Down Expand Up @@ -169,6 +171,18 @@ const router = sentryCreateBrowserRouter(
lazyChildren: () => import('./pages/WildcardLazyRoutes').then(module => module.wildcardRoutes),
},
},
{
path: '/lazy-gql-a',
handle: {
lazyChildren: () => import('./pages/LazyFetchRoutes').then(module => module.lazyGqlARoutes),
},
},
{
path: '/lazy-gql-b',
handle: {
lazyChildren: () => import('./pages/LazyFetchSubRoutes').then(module => module.lazyGqlBRoutes),
},
},
],
{
async patchRoutesOnNavigation({ matches, patch }: Parameters<PatchRoutesOnNavigationFunction>[0]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ const Index = () => {
<Link to="/wildcard-lazy/789" id="navigation-to-wildcard-lazy">
Navigate to Wildcard Lazy Route (500ms delay, no fetch)
</Link>
<br />
<Link to="/lazy-gql-a/fetch" id="navigation-to-gql-a">
Navigate to GQL Page A
</Link>
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';
import { Link } from 'react-router-dom';

const GqlPageA = () => {
const [data, setData] = React.useState<{ data?: unknown } | null>(null);

React.useEffect(() => {
fetch('/api/graphql?op=UserAQuery', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ userA { id name } }', operationName: 'UserAQuery' }),
})
.then(res => res.json())
.then(setData)
.catch(() => setData({ data: { error: 'failed' } }));
}, []);

return (
<div id="gql-page-a">
<h1>GQL Page A</h1>
<p id="gql-page-a-data">{data ? JSON.stringify(data) : 'loading...'}</p>
<Link to="/lazy-gql-b/fetch" id="navigate-to-gql-b">
Navigate to GQL Page B
</Link>
</div>
);
};

export const lazyGqlARoutes = [
{
path: 'fetch',
element: <GqlPageA />,
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';
import { Link } from 'react-router-dom';

const GqlPageB = () => {
const [data, setData] = React.useState<{ data?: unknown } | null>(null);

React.useEffect(() => {
fetch('/api/graphql?op=UserBQuery', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ userB { id email } }', operationName: 'UserBQuery' }),
})
.then(res => res.json())
.then(setData)
.catch(() => setData({ data: { error: 'failed' } }));
}, []);

return (
<div id="gql-page-b">
<h1>GQL Page B</h1>
<p id="gql-page-b-data">{data ? JSON.stringify(data) : 'loading...'}</p>
<Link to="/" id="gql-b-home-link">
Go Home
</Link>
</div>
);
};

export const lazyGqlBRoutes = [
{
path: 'fetch',
element: <GqlPageB />,
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -1484,3 +1484,140 @@ test('Route manifest provides correct name when pageload span ends before lazy r
expect(event.contexts?.trace?.op).toBe('pageload');
expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route');
});

test('GQL fetch span is attributed to the correct navigation transaction when navigating from index to lazy GQL page', async ({
page,
}) => {
const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
return (
!!transactionEvent?.transaction &&
transactionEvent.contexts?.trace?.op === 'pageload' &&
transactionEvent.transaction === '/'
);
});

const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
return (
!!transactionEvent?.transaction &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.transaction === '/lazy-gql-a/fetch'
);
});

await page.goto('/');
const pageloadEvent = await pageloadPromise;

// Pageload should NOT contain any /api/graphql spans (neither UserAQuery nor UserBQuery)
const pageloadSpans = pageloadEvent.spans || [];
const pageloadGqlSpans = pageloadSpans.filter(
(span: { op?: string; description?: string; data?: { url?: string } }) =>
span.op === 'http.client' &&
(span.description?.includes('/api/graphql') || span.data?.url?.includes('/api/graphql')),
);
expect(pageloadGqlSpans.length).toBe(0);

// Navigate to lazy GQL page A
const gqlLink = page.locator('id=navigation-to-gql-a');
await expect(gqlLink).toBeVisible();
await gqlLink.click();

const navigationEvent = await navigationPromise;

// Verify the lazy GQL page rendered
await expect(page.locator('id=gql-page-a')).toBeVisible();

// Verify the navigation transaction has the correct name
expect(navigationEvent.transaction).toBe('/lazy-gql-a/fetch');
expect(navigationEvent.contexts?.trace?.op).toBe('navigation');

// Verify the UserAQuery GQL fetch span is inside this navigation transaction
const navSpans = navigationEvent.spans || [];
const userASpans = navSpans.filter(
(span: { op?: string; description?: string; data?: { url?: string } }) =>
span.op === 'http.client' && (span.description?.includes('UserAQuery') || span.data?.url?.includes('UserAQuery')),
);
expect(userASpans.length).toBe(1);

// Verify NO UserBQuery spans leaked into this transaction
const userBSpans = navSpans.filter(
(span: { op?: string; description?: string; data?: { url?: string } }) =>
span.op === 'http.client' && (span.description?.includes('UserBQuery') || span.data?.url?.includes('UserBQuery')),
);
expect(userBSpans.length).toBe(0);
});

test('GQL fetch spans are attributed to correct navigation transactions when navigating between two lazy GQL pages', async ({
page,
}) => {
await page.goto('/');
await page.waitForTimeout(500);

// Navigate to GQL page A
const firstNavPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
return (
!!transactionEvent?.transaction &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.transaction === '/lazy-gql-a/fetch'
);
});

const gqlALink = page.locator('id=navigation-to-gql-a');
await expect(gqlALink).toBeVisible();
await gqlALink.click();

const firstNavEvent = await firstNavPromise;
await expect(page.locator('id=gql-page-a')).toBeVisible();

// First navigation should have exactly the UserAQuery span
const firstNavSpans = firstNavEvent.spans || [];
const firstUserASpans = firstNavSpans.filter(
(span: { op?: string; description?: string; data?: { url?: string } }) =>
span.op === 'http.client' && (span.description?.includes('UserAQuery') || span.data?.url?.includes('UserAQuery')),
);
expect(firstUserASpans.length).toBe(1);

// First navigation must NOT contain UserBQuery spans
const firstUserBSpans = firstNavSpans.filter(
(span: { op?: string; description?: string; data?: { url?: string } }) =>
span.op === 'http.client' && (span.description?.includes('UserBQuery') || span.data?.url?.includes('UserBQuery')),
);
expect(firstUserBSpans.length).toBe(0);

// Now navigate from GQL page A to GQL page B
const secondNavPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
return (
!!transactionEvent?.transaction &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.transaction === '/lazy-gql-b/fetch'
);
});

const gqlBLink = page.locator('id=navigate-to-gql-b');
await expect(gqlBLink).toBeVisible();
await gqlBLink.click();

const secondNavEvent = await secondNavPromise;
await expect(page.locator('id=gql-page-b')).toBeVisible();

// Second navigation should have exactly the UserBQuery span
const secondNavSpans = secondNavEvent.spans || [];
const secondUserBSpans = secondNavSpans.filter(
(span: { op?: string; description?: string; data?: { url?: string } }) =>
span.op === 'http.client' && (span.description?.includes('UserBQuery') || span.data?.url?.includes('UserBQuery')),
);
expect(secondUserBSpans.length).toBe(1);

// Second navigation must NOT contain UserAQuery spans (no leaking from first nav)
const secondUserASpans = secondNavSpans.filter(
(span: { op?: string; description?: string; data?: { url?: string } }) =>
span.op === 'http.client' && (span.description?.includes('UserAQuery') || span.data?.url?.includes('UserAQuery')),
);
expect(secondUserASpans.length).toBe(0);

// Verify the two transactions have different trace IDs
const firstTraceId = firstNavEvent.contexts?.trace?.trace_id;
const secondTraceId = secondNavEvent.contexts?.trace?.trace_id;
expect(firstTraceId).toBeDefined();
expect(secondTraceId).toBeDefined();
expect(firstTraceId).not.toBe(secondTraceId);
});
Loading