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 @@ -9,7 +9,9 @@
"test": "playwright test",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test"
"test:assert": "pnpm test",
"test:build:basepath": "E2E_TEST_BASEPATH=/app pnpm test:build",
"test:assert:basepath": "E2E_TEST_BASEPATH=/app pnpm test:assert"
},
"dependencies": {
"@sentry/react": "file:../../packed/sentry-react-packed.tgz",
Expand All @@ -30,5 +32,14 @@
},
"volta": {
"extends": "../../package.json"
},
"sentryTest": {
"variants": [
{
"build-command": "pnpm test:build:basepath",
"assert-command": "pnpm test:assert:basepath",
"label": "tanstack-router (basepath)"
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,25 @@ const redirectRoute = createRoute({
},
});

const routeTree = rootRoute.addChildren([indexRoute, redirectRoute, postsRoute.addChildren([postIdRoute])]);
// Dynamic enough to absorb basepath segments if they ever leak into route matching (see #23253).
const catchAllRoute = createRoute({
getParentRoute: () => rootRoute,
path: '$a/$b/$c',
component: function CatchAll() {
return <div>Catch all</div>;
},
});

const routeTree = rootRoute.addChildren([
indexRoute,
redirectRoute,
catchAllRoute,
postsRoute.addChildren([postIdRoute]),
]);

declare const __APP_BASEPATH__: string;

const router = createRouter({ routeTree });
const router = createRouter({ routeTree, ...(__APP_BASEPATH__ ? { basepath: __APP_BASEPATH__ } : {}) });

declare const __APP_DSN__: string;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';

// Only meaningful in the `tanstack-router (basepath)` variant, where the router is created with
// `basepath: '/app'`. The rest of the suite runs in both variants.
const BASE = process.env.E2E_TEST_BASEPATH || '';

test.describe('router basepath', () => {
test.skip(!BASE, 'Only runs in the basepath variant');

// `window.location.pathname` carries the basepath, but the router never sees it. Matching the
// pageload against the raw browser path let the catch-all `/$a/$b/$c` route absorb `app` as a
// param instead of matching `/posts/$postId`.
test('does not leak the basepath into the matched route params', async ({ page }) => {
const transactionPromise = waitForTransaction('tanstack-router', async transactionEvent => {
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
});

await page.goto(`${BASE}/posts/456`);

const rootSpan = await transactionPromise;

// `onResolved` later merges the correct params in, but never clears the ones the bad initial
// match already set, so the stale `a`/`b`/`c` params survive on the span. Keys are passed as
// arrays because `toHaveProperty` would otherwise read the dots as a nested lookup.
const traceData = rootSpan.contexts?.trace?.data;
expect(traceData).not.toHaveProperty(['url.path.params.a']);
expect(traceData).not.toHaveProperty(['url.path.params.b']);
expect(traceData).not.toHaveProperty(['url.path.params.c']);
expect(traceData).toHaveProperty(['url.path.params.postId'], '456');
expect(traceData).toHaveProperty(['url.template'], '/posts/$postId');
});

// The first test only checks the span. The scope transaction is a separate value: it is set once
// when the pageload span starts, and the later `updateName` in `onResolved` does not rewrite it.
// So even when the sent transaction name is correct, errors captured after the pageload still
// carry the name from the initial match. This test checks that scope transaction.
test('attributes errors to the matched route for the whole page lifetime', async ({ page }) => {
const transactionPromise = waitForTransaction('tanstack-router', async transactionEvent => {
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
});
const errorPromise = waitForError('tanstack-router', async errorEvent => {
return errorEvent.exception?.values?.[0]?.value === 'Error thrown after pageload';
});

await page.goto(`${BASE}/posts/456`);
await transactionPromise;

await page.evaluate(() => {
setTimeout(() => {
throw new Error('Error thrown after pageload');
}, 0);
});

const errorEvent = await errorPromise;

expect(errorEvent.transaction).toBe('/posts/$postId');
});
});
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

const BASE = process.env.E2E_TEST_BASEPATH || '';

test('sends a pageload transaction with a parameterized URL', async ({ page }) => {
const transactionPromise = waitForTransaction('tanstack-router', async transactionEvent => {
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
});

await page.goto(`/posts/456`);
await page.goto(`${BASE}/posts/456`);

const rootSpan = await transactionPromise;

Expand Down Expand Up @@ -43,7 +45,7 @@ test('sends pageload transaction with web vitals measurements', async ({ page })
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
});

await page.goto(`/`);
await page.goto(`${BASE}/`);

const transaction = await transactionPromise;

Expand Down Expand Up @@ -94,7 +96,7 @@ test('sends a navigation transaction with a parameterized URL', async ({ page })
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
});

await page.goto(`/`);
await page.goto(`${BASE}/`);
await pageloadTxnPromise;

await page.waitForTimeout(5000);
Expand Down Expand Up @@ -138,7 +140,7 @@ test('sends a pageload transaction with resolved URL attrs after same-route redi
});

// `/posts/999` matches `/posts/$postId` initially, then `beforeLoad` redirects to `/posts/2`.
await page.goto(`/posts/999`);
await page.goto(`${BASE}/posts/999`);

const pageloadTxn = await pageloadTxnPromise;

Expand Down Expand Up @@ -174,7 +176,7 @@ test('sends a pageload transaction named after the resolved route when a redirec

// Visiting `/redirect` directly throws `redirect({ to: '/posts/$postId', params: { postId: '1' } })`
// in `beforeLoad` during the initial pageload, so the pageload span must be renamed to the target route.
await page.goto(`/redirect`);
await page.goto(`${BASE}/redirect`);

const pageloadTxn = await pageloadTxnPromise;

Expand Down Expand Up @@ -210,7 +212,7 @@ test('sends a navigation transaction when a redirect is thrown in beforeLoad', a
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
});

await page.goto(`/`);
await page.goto(`${BASE}/`);
await pageloadTxnPromise;

await page.locator('#redirect-link').click();
Expand Down Expand Up @@ -247,7 +249,7 @@ test('sends a navigation transaction for a normal navigation that happens after
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
});

await page.goto(`/`);
await page.goto(`${BASE}/`);
await pageloadTxnPromise;

// First trigger a redirect-driven navigation. Upstream (TanStack/router#3920) this leaves the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';

const basepath = process.env.E2E_TEST_BASEPATH || '';

// https://vitejs.dev/config/
export default defineConfig({
base: basepath ? `${basepath}/` : '/',
plugins: [react()],
define: {
__APP_DSN__: JSON.stringify(process.env.E2E_TEST_DSN),
__APP_BASEPATH__: JSON.stringify(basepath),
},
preview: {
port: 3030,
Expand Down
11 changes: 7 additions & 4 deletions packages/react/src/tanstackrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,13 @@ export function tanstackRouterBrowserTracingIntegration(

const initialWindowLocation = WINDOW.location;
if (instrumentPageLoad && initialWindowLocation) {
const routeMatch = resolveRouteMatch(
initialWindowLocation.pathname,
castRouterInstance.options.parseSearch(initialWindowLocation.search),
);
const initialRouterLocation = castRouterInstance.state?.location;
const routeMatch = initialRouterLocation
? resolveRouteMatch(initialRouterLocation.pathname, initialRouterLocation.search)
: resolveRouteMatch(
initialWindowLocation.pathname,
castRouterInstance.options.parseSearch(initialWindowLocation.search),
);

const pageloadSpan = startBrowserTracingPageLoadSpan(client, {
name: routeMatch ? routeMatch.routeId : initialWindowLocation.pathname,
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/vendor/tanstackrouter-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ interface VendoredTanstackRouterHistory {
interface VendoredTanstackRouterState {
matches: Array<VendoredTanstackRouterRouteMatch>;
pendingMatches?: Array<VendoredTanstackRouterRouteMatch>;
location?: VendoredTanstackRouterLocation;
}

export interface VendoredTanstackRouterRouteMatch {
Expand Down
34 changes: 34 additions & 0 deletions packages/react/test/tanstackrouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe('tanstackRouterBrowserTracingIntegration', () => {
beforeEach(() => {
vi.clearAllMocks();
startBrowserTracingPageLoadSpanSpy.mockReturnValue(mockPageloadSpan as any);
(SentryBrowser.WINDOW as any).location = { pathname: '/posts/999', search: '' };

vi.stubGlobal('window', {
location: {
Expand Down Expand Up @@ -91,6 +92,39 @@ describe('tanstackRouterBrowserTracingIntegration', () => {
});
});

describe('pageload route matching', () => {
// `window.location.pathname` carries the router basepath, but whether `matchRoutes` wants it is
// version-dependent (newer routers strip it in `parseLocation`, older ones inside `matchRoutes`).
// `state.location` is always in the form the router itself expects, so we match against that.
it('matches against the router location, not window.location', () => {
(SentryBrowser.WINDOW as any).location = { pathname: '/app/posts/999', search: '?q=1' };

const integration = tanstackRouterBrowserTracingIntegration(
{ ...mockRouter, state: { location: { pathname: '/posts/999', search: { q: 1 } } } },
{ instrumentPageLoad: true, instrumentNavigation: false },
);

integration.afterAllSetup!(mockClient as any);

expect(mockRouter.matchRoutes).toHaveBeenCalledWith('/posts/999', { q: 1 }, expect.any(Object));
expect(mockRouter.options.parseSearch).not.toHaveBeenCalled();
});

it('falls back to window.location when the router exposes no location', () => {
(SentryBrowser.WINDOW as any).location = { pathname: '/posts/999', search: '?q=1' };

const integration = tanstackRouterBrowserTracingIntegration(mockRouter, {
instrumentPageLoad: true,
instrumentNavigation: false,
});

integration.afterAllSetup!(mockClient as any);

expect(mockRouter.options.parseSearch).toHaveBeenCalledWith('?q=1');
expect(mockRouter.matchRoutes).toHaveBeenCalledWith('/posts/999', {}, expect.any(Object));
});
});

it('updates pageload span URL attributes on redirect to the same route template', () => {
const integration = tanstackRouterBrowserTracingIntegration(mockRouter, {
instrumentPageLoad: true,
Expand Down
Loading