diff --git a/.changeset/fix-tanstack-navigation-query-params.md b/.changeset/fix-tanstack-navigation-query-params.md new file mode 100644 index 00000000000..983b9c140a3 --- /dev/null +++ b/.changeset/fix-tanstack-navigation-query-params.md @@ -0,0 +1,5 @@ +--- +'@clerk/tanstack-react-start': patch +--- + +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. diff --git a/packages/tanstack-react-start/src/__tests__/parseUrlForNavigation.test.ts b/packages/tanstack-react-start/src/__tests__/parseUrlForNavigation.test.ts new file mode 100644 index 00000000000..fe4eabf24e7 --- /dev/null +++ b/packages/tanstack-react-start/src/__tests__/parseUrlForNavigation.test.ts @@ -0,0 +1,113 @@ +import { parseUrlForNavigation } from '../client/utils'; + +const BASE_URL = 'https://example.com'; + +describe('parseUrlForNavigation', () => { + it('parses pathname only', () => { + const result = parseUrlForNavigation('/sign-in', BASE_URL); + expect(result).toEqual({ + to: '/sign-in', + search: undefined, + hash: undefined, + }); + }); + + it('parses pathname with query parameters', () => { + const result = parseUrlForNavigation('/sign-in?redirect_url=https://example.com', BASE_URL); + expect(result).toEqual({ + to: '/sign-in', + search: { redirect_url: 'https://example.com' }, + hash: undefined, + }); + }); + + it('parses pathname with multiple query parameters', () => { + const result = parseUrlForNavigation('/sign-in?redirect_url=https://example.com&foo=bar', BASE_URL); + expect(result).toEqual({ + to: '/sign-in', + search: { redirect_url: 'https://example.com', foo: 'bar' }, + hash: undefined, + }); + }); + + it('parses pathname with hash', () => { + const result = parseUrlForNavigation('/sign-in#section', BASE_URL); + expect(result).toEqual({ + to: '/sign-in', + search: undefined, + hash: 'section', + }); + }); + + it('parses pathname with query parameters and hash', () => { + const result = parseUrlForNavigation('/sign-in?redirect_url=https://example.com#section', BASE_URL); + expect(result).toEqual({ + to: '/sign-in', + search: { redirect_url: 'https://example.com' }, + hash: 'section', + }); + }); + + it('handles encoded query parameters', () => { + const result = parseUrlForNavigation('/sign-in?redirect_url=https%3A%2F%2Fexample.com%2Fpath', BASE_URL); + expect(result).toEqual({ + to: '/sign-in', + search: { redirect_url: 'https://example.com/path' }, + hash: undefined, + }); + }); + + it('handles root path', () => { + const result = parseUrlForNavigation('/', BASE_URL); + expect(result).toEqual({ + to: '/', + search: undefined, + hash: undefined, + }); + }); + + it('handles nested paths', () => { + const result = parseUrlForNavigation('/auth/sign-in?foo=bar', BASE_URL); + expect(result).toEqual({ + to: '/auth/sign-in', + search: { foo: 'bar' }, + hash: undefined, + }); + }); + + it('handles empty hash', () => { + const result = parseUrlForNavigation('/sign-in#', BASE_URL); + expect(result).toEqual({ + to: '/sign-in', + search: undefined, + hash: undefined, + }); + }); + + it('handles complex satellite redirect URL', () => { + const result = parseUrlForNavigation( + '/sign-in?redirect_url=https%3A%2F%2Fsatellite.example.com%2Fdashboard&sign_in_force_redirect_url=https%3A%2F%2Fmain.example.com', + BASE_URL, + ); + expect(result).toEqual({ + to: '/sign-in', + search: { + redirect_url: 'https://satellite.example.com/dashboard', + sign_in_force_redirect_url: 'https://main.example.com', + }, + hash: undefined, + }); + }); + + it('handles hash that looks like a path with query params (PathRouter format)', () => { + // This is what PathRouter converts from: /sign-in#/?redirect_url=... + // After mergeFragmentIntoUrl, it becomes: /sign-in?redirect_url=... + // We should correctly handle both formats + const result = parseUrlForNavigation('/sign-in?redirect_url=https://satellite.com', BASE_URL); + expect(result).toEqual({ + to: '/sign-in', + search: { redirect_url: 'https://satellite.com' }, + hash: undefined, + }); + }); +}); diff --git a/packages/tanstack-react-start/src/client/ClerkProvider.tsx b/packages/tanstack-react-start/src/client/ClerkProvider.tsx index 5843d3d6183..8247efbac29 100644 --- a/packages/tanstack-react-start/src/client/ClerkProvider.tsx +++ b/packages/tanstack-react-start/src/client/ClerkProvider.tsx @@ -8,7 +8,7 @@ import { isClient } from '../utils'; import { ClerkOptionsProvider } from './OptionsContext'; import type { TanstackStartClerkProviderProps } from './types'; import { useAwaitableNavigate } from './useAwaitableNavigate'; -import { mergeWithPublicEnvs, pickFromClerkInitState } from './utils'; +import { mergeWithPublicEnvs, parseUrlForNavigation, pickFromClerkInitState } from './utils'; export * from '@clerk/react'; @@ -57,18 +57,24 @@ export function ClerkProvider({ - awaitableNavigateRef.current?.({ - to, + routerPush={(to: string) => { + const { search, hash, ...rest } = parseUrlForNavigation(to, window.location.origin); + return awaitableNavigateRef.current?.({ + ...rest, + search: search as any, + hash, replace: false, - }) - } - routerReplace={(to: string) => - awaitableNavigateRef.current?.({ - to, + }); + }} + routerReplace={(to: string) => { + const { search, hash, ...rest } = parseUrlForNavigation(to, window.location.origin); + return awaitableNavigateRef.current?.({ + ...rest, + search: search as any, + hash, replace: true, - }) - } + }); + }} {...mergedProps} {...keylessProps} > diff --git a/packages/tanstack-react-start/src/client/utils.ts b/packages/tanstack-react-start/src/client/utils.ts index c281a0437b2..b782bb17147 100644 --- a/packages/tanstack-react-start/src/client/utils.ts +++ b/packages/tanstack-react-start/src/client/utils.ts @@ -70,3 +70,24 @@ export const mergeWithPublicEnvs = (restInitState: any) => { prefetchUI: restInitState.prefetchUI ?? envVars.prefetchUI, }; }; + +export type ParsedNavigationUrl = { + to: string; + search?: Record; + hash?: string; +}; + +/** + * Parses a URL string into TanStack Router navigation options. + * TanStack Router doesn't parse query strings from the `to` parameter, + * so we need to extract pathname, search params, and hash separately. + */ +export function parseUrlForNavigation(to: string, baseUrl: string): ParsedNavigationUrl { + const url = new URL(to, baseUrl); + const searchParams = Object.fromEntries(url.searchParams); + return { + to: url.pathname, + search: Object.keys(searchParams).length > 0 ? searchParams : undefined, + hash: url.hash ? url.hash.slice(1) : undefined, + }; +}