Skip to content

Commit a2fa72e

Browse files
authored
fix(tanstack-react-start): Parse URL query params for TanStack Router navigation
* fix(tanstack-react-start): parse URL query params for TanStack Router navigation TanStack Router doesn't parse query strings from the `to` parameter, expecting them in a separate `search` option. This was causing "Not Found" errors when Clerk navigated with URLs containing query parameters (e.g., `/sign-in?redirect_url=...`). The fix parses the URL string and separates pathname, search params, and hash before passing to TanStack Router's navigate function. * fix(tanstack-react-start,react-router): stabilize base path to prevent intermittent redirect on navigation When navigating away from a page with SignIn/SignUp, useLocation() returns the new pathname before the component unmounts. This race condition causes the base path to change, which breaks internal route matching and fires the catch-all RedirectToSignIn, bouncing the user back to sign-in. Stabilize the computed base path with useRef so it's set once at mount time and doesn't change during navigation transitions. This is the same pattern already used in @clerk/nextjs usePathnameWithoutCatchAll.
1 parent fade95c commit a2fa72e

File tree

6 files changed

+176
-13
lines changed

6 files changed

+176
-13
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/tanstack-react-start': patch
3+
---
4+
5+
Fix navigation with query parameters in TanStack Start apps. Previously, URLs with query parameters (e.g., `/sign-in?redirect_url=...`) would cause "Not Found" errors because TanStack Router doesn't parse query strings from the `to` parameter. The fix properly separates pathname, search params, and hash when calling TanStack Router's navigate function.

packages/react-router/src/client/usePathnameWithoutSplatRouteParams.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useRef } from 'react';
12
import { useLocation, useParams } from 'react-router';
23

34
export const usePathnameWithoutSplatRouteParams = () => {
@@ -14,5 +15,13 @@ export const usePathnameWithoutSplatRouteParams = () => {
1415
// eg /user/123/profile/security will return /user/123/profile as the path
1516
const path = pathname.replace(splatRouteParam, '').replace(/\/$/, '').replace(/^\//, '').trim();
1617

17-
return `/${path}`;
18+
const computedPath = `/${path}`;
19+
20+
// Stabilize the base path to prevent race conditions during navigation away.
21+
// When the router navigates to a different route, useLocation() returns the
22+
// new pathname before this component unmounts. This causes the basePath to change,
23+
// which makes the SignIn/SignUp catch-all route fire RedirectToSignIn incorrectly.
24+
// Matches the pattern used in @clerk/nextjs usePathnameWithoutCatchAll.
25+
const stablePath = useRef(computedPath);
26+
return stablePath.current;
1827
};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { parseUrlForNavigation } from '../client/utils';
2+
3+
const BASE_URL = 'https://example.com';
4+
5+
describe('parseUrlForNavigation', () => {
6+
it('parses pathname only', () => {
7+
const result = parseUrlForNavigation('/sign-in', BASE_URL);
8+
expect(result).toEqual({
9+
to: '/sign-in',
10+
search: undefined,
11+
hash: undefined,
12+
});
13+
});
14+
15+
it('parses pathname with query parameters', () => {
16+
const result = parseUrlForNavigation('/sign-in?redirect_url=https://example.com', BASE_URL);
17+
expect(result).toEqual({
18+
to: '/sign-in',
19+
search: { redirect_url: 'https://example.com' },
20+
hash: undefined,
21+
});
22+
});
23+
24+
it('parses pathname with multiple query parameters', () => {
25+
const result = parseUrlForNavigation('/sign-in?redirect_url=https://example.com&foo=bar', BASE_URL);
26+
expect(result).toEqual({
27+
to: '/sign-in',
28+
search: { redirect_url: 'https://example.com', foo: 'bar' },
29+
hash: undefined,
30+
});
31+
});
32+
33+
it('parses pathname with hash', () => {
34+
const result = parseUrlForNavigation('/sign-in#section', BASE_URL);
35+
expect(result).toEqual({
36+
to: '/sign-in',
37+
search: undefined,
38+
hash: 'section',
39+
});
40+
});
41+
42+
it('parses pathname with query parameters and hash', () => {
43+
const result = parseUrlForNavigation('/sign-in?redirect_url=https://example.com#section', BASE_URL);
44+
expect(result).toEqual({
45+
to: '/sign-in',
46+
search: { redirect_url: 'https://example.com' },
47+
hash: 'section',
48+
});
49+
});
50+
51+
it('handles encoded query parameters', () => {
52+
const result = parseUrlForNavigation('/sign-in?redirect_url=https%3A%2F%2Fexample.com%2Fpath', BASE_URL);
53+
expect(result).toEqual({
54+
to: '/sign-in',
55+
search: { redirect_url: 'https://example.com/path' },
56+
hash: undefined,
57+
});
58+
});
59+
60+
it('handles root path', () => {
61+
const result = parseUrlForNavigation('/', BASE_URL);
62+
expect(result).toEqual({
63+
to: '/',
64+
search: undefined,
65+
hash: undefined,
66+
});
67+
});
68+
69+
it('handles nested paths', () => {
70+
const result = parseUrlForNavigation('/auth/sign-in?foo=bar', BASE_URL);
71+
expect(result).toEqual({
72+
to: '/auth/sign-in',
73+
search: { foo: 'bar' },
74+
hash: undefined,
75+
});
76+
});
77+
78+
it('handles empty hash', () => {
79+
const result = parseUrlForNavigation('/sign-in#', BASE_URL);
80+
expect(result).toEqual({
81+
to: '/sign-in',
82+
search: undefined,
83+
hash: undefined,
84+
});
85+
});
86+
87+
it('handles complex satellite redirect URL', () => {
88+
const result = parseUrlForNavigation(
89+
'/sign-in?redirect_url=https%3A%2F%2Fsatellite.example.com%2Fdashboard&sign_in_force_redirect_url=https%3A%2F%2Fmain.example.com',
90+
BASE_URL,
91+
);
92+
expect(result).toEqual({
93+
to: '/sign-in',
94+
search: {
95+
redirect_url: 'https://satellite.example.com/dashboard',
96+
sign_in_force_redirect_url: 'https://main.example.com',
97+
},
98+
hash: undefined,
99+
});
100+
});
101+
102+
it('handles hash that looks like a path with query params (PathRouter format)', () => {
103+
// This is what PathRouter converts from: /sign-in#/?redirect_url=...
104+
// After mergeFragmentIntoUrl, it becomes: /sign-in?redirect_url=...
105+
// We should correctly handle both formats
106+
const result = parseUrlForNavigation('/sign-in?redirect_url=https://satellite.com', BASE_URL);
107+
expect(result).toEqual({
108+
to: '/sign-in',
109+
search: { redirect_url: 'https://satellite.com' },
110+
hash: undefined,
111+
});
112+
});
113+
});

packages/tanstack-react-start/src/client/ClerkProvider.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { isClient } from '../utils';
88
import { ClerkOptionsProvider } from './OptionsContext';
99
import type { TanstackStartClerkProviderProps } from './types';
1010
import { useAwaitableNavigate } from './useAwaitableNavigate';
11-
import { mergeWithPublicEnvs, pickFromClerkInitState } from './utils';
11+
import { mergeWithPublicEnvs, parseUrlForNavigation, pickFromClerkInitState } from './utils';
1212

1313
export * from '@clerk/react';
1414

@@ -57,18 +57,24 @@ export function ClerkProvider<TUi extends Ui = Ui>({
5757
<ReactClerkProvider
5858
initialState={clerkSsrState}
5959
sdkMetadata={SDK_METADATA}
60-
routerPush={(to: string) =>
61-
awaitableNavigateRef.current?.({
62-
to,
60+
routerPush={(to: string) => {
61+
const { search, hash, ...rest } = parseUrlForNavigation(to, window.location.origin);
62+
return awaitableNavigateRef.current?.({
63+
...rest,
64+
search: search as any,
65+
hash,
6366
replace: false,
64-
})
65-
}
66-
routerReplace={(to: string) =>
67-
awaitableNavigateRef.current?.({
68-
to,
67+
});
68+
}}
69+
routerReplace={(to: string) => {
70+
const { search, hash, ...rest } = parseUrlForNavigation(to, window.location.origin);
71+
return awaitableNavigateRef.current?.({
72+
...rest,
73+
search: search as any,
74+
hash,
6975
replace: true,
70-
})
71-
}
76+
});
77+
}}
7278
{...mergedProps}
7379
{...keylessProps}
7480
>

packages/tanstack-react-start/src/client/uiComponents.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { useRoutingProps } from '@clerk/react/internal';
99
import type { OrganizationProfileProps, SignInProps, SignUpProps, UserProfileProps } from '@clerk/shared/types';
1010
import { useLocation, useParams } from '@tanstack/react-router';
11+
import { useRef } from 'react';
1112

1213
const usePathnameWithoutSplatRouteParams = () => {
1314
const { _splat } = useParams({
@@ -24,7 +25,15 @@ const usePathnameWithoutSplatRouteParams = () => {
2425
// eg /user/123/profile/security will return /user/123/profile as the path
2526
const path = pathname.replace(splatRouteParam, '').replace(/\/$/, '').replace(/^\//, '').trim();
2627

27-
return `/${path}`;
28+
const computedPath = `/${path}`;
29+
30+
// Stabilize the base path to prevent race conditions during navigation away.
31+
// When TanStack Router navigates to a different route, useLocation() returns the
32+
// new pathname before this component unmounts. This causes the basePath to change,
33+
// which makes the SignIn/SignUp catch-all route fire RedirectToSignIn incorrectly.
34+
// Matches the pattern used in @clerk/nextjs usePathnameWithoutCatchAll.
35+
const stablePath = useRef(computedPath);
36+
return stablePath.current;
2837
};
2938

3039
// The assignment of UserProfile with BaseUserProfile props is used

packages/tanstack-react-start/src/client/utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,24 @@ export const mergeWithPublicEnvs = (restInitState: any) => {
7676
prefetchUI: restInitState.prefetchUI ?? envVars.prefetchUI,
7777
};
7878
};
79+
80+
export type ParsedNavigationUrl = {
81+
to: string;
82+
search?: Record<string, string>;
83+
hash?: string;
84+
};
85+
86+
/**
87+
* Parses a URL string into TanStack Router navigation options.
88+
* TanStack Router doesn't parse query strings from the `to` parameter,
89+
* so we need to extract pathname, search params, and hash separately.
90+
*/
91+
export function parseUrlForNavigation(to: string, baseUrl: string): ParsedNavigationUrl {
92+
const url = new URL(to, baseUrl);
93+
const searchParams = Object.fromEntries(url.searchParams);
94+
return {
95+
to: url.pathname,
96+
search: Object.keys(searchParams).length > 0 ? searchParams : undefined,
97+
hash: url.hash ? url.hash.slice(1) : undefined,
98+
};
99+
}

0 commit comments

Comments
 (0)