From 199ff30fa61b2de24b79700829eb0f335deaf53c Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 21 May 2026 16:09:26 -0700 Subject: [PATCH 1/3] fix(react-router): Forward redirect URL options from clerkMiddleware to client state --- .../server/__tests__/clerkMiddleware.test.ts | 53 ++++++++++++++++++- .../src/server/clerkMiddleware.ts | 26 ++++++--- .../react-router/src/server/rootAuthLoader.ts | 22 +++++--- packages/react-router/src/server/types.ts | 9 ++++ packages/react-router/src/server/utils.ts | 25 +++++---- 5 files changed, 111 insertions(+), 24 deletions(-) diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts index d067a2cb948..bb187ca902c 100644 --- a/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts @@ -79,7 +79,10 @@ describe('clerkMiddleware', () => { }); expect(mockContext.set).toHaveBeenCalledWith(authFnContext, expect.any(Function)); - expect(mockContext.set).toHaveBeenCalledWith(requestStateContext, mockRequestState); + expect(mockContext.set).toHaveBeenCalledWith( + requestStateContext, + expect.objectContaining({ requestState: mockRequestState }), + ); expect(mockNext).toHaveBeenCalled(); @@ -119,6 +122,54 @@ describe('clerkMiddleware', () => { expect(mockLoadOptions).toHaveBeenCalledWith(args, options); }); + it('should set redirect URL options from loadOptions in additionalStateContext', async () => { + mockLoadOptions.mockReturnValue({ + audience: '', + authorizedParties: [], + signInUrl: '', + signUpUrl: '', + secretKey: 'sk_test_...', + publishableKey: 'pk_test_...', + signInForceRedirectUrl: '/dashboard', + signUpForceRedirectUrl: '/welcome', + signInFallbackRedirectUrl: '/home', + signUpFallbackRedirectUrl: '/home', + } as unknown as ReturnType); + + const mockRequestState = { + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx', tokenType: TokenType.SessionToken }), + }; + + mockClerkClient.mockReturnValue({ + authenticateRequest: vi.fn().mockResolvedValue(mockRequestState), + } as unknown as ClerkClient); + + const middleware = clerkMiddleware(); + const args = { + request: new Request('http://clerk.com'), + context: mockContext, + } as LoaderFunctionArgs; + + mockNext.mockResolvedValue(new Response('OK')); + + await middleware(args, mockNext); + + expect(mockContext.set).toHaveBeenCalledWith( + requestStateContext, + expect.objectContaining({ + requestState: mockRequestState, + additionalState: expect.objectContaining({ + signInForceRedirectUrl: '/dashboard', + signUpForceRedirectUrl: '/welcome', + signInFallbackRedirectUrl: '/home', + signUpFallbackRedirectUrl: '/home', + }), + }), + ); + }); + it('should append request state headers to response', async () => { const mockRequestState = { status: AuthStatus.SignedIn, diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index b78f38e05c9..1f49251408a 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -9,11 +9,16 @@ import { createContext } from 'react-router'; import { clerkClient } from './clerkClient'; import { resolveKeysWithKeylessFallback } from './keyless/utils'; import { loadOptions } from './loadOptions'; -import type { ClerkMiddlewareOptions } from './types'; +import type { AdditionalStateOptions, ClerkMiddlewareOptions } from './types'; import { patchRequest } from './utils'; +type RequestStateContextValue = { + requestState: RequestState; + additionalState: AdditionalStateOptions; +}; + export const authFnContext = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); -export const requestStateContext = createContext | null>(null); +export const requestStateContext = createContext(null); /** * Middleware that integrates Clerk authentication into your React Router application. @@ -83,11 +88,6 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun acceptsToken: 'any', }); - Object.assign(requestState, { - __keylessClaimUrl, - __keylessApiKeysUrl, - }); - const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { handleNetlifyCacheInDevInstance({ @@ -104,7 +104,17 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun } args.context.set(authFnContext, (opts?: PendingSessionOptions) => requestState.toAuth(opts)); - args.context.set(requestStateContext, requestState); + args.context.set(requestStateContext, { + requestState, + additionalState: { + __keylessClaimUrl, + __keylessApiKeysUrl, + signInForceRedirectUrl: loadedOptions.signInForceRedirectUrl, + signUpForceRedirectUrl: loadedOptions.signUpForceRedirectUrl, + signInFallbackRedirectUrl: loadedOptions.signInFallbackRedirectUrl, + signUpFallbackRedirectUrl: loadedOptions.signUpFallbackRedirectUrl, + }, + }); const response = await next(); diff --git a/packages/react-router/src/server/rootAuthLoader.ts b/packages/react-router/src/server/rootAuthLoader.ts index ecf4c1f6fff..5a1d625e604 100644 --- a/packages/react-router/src/server/rootAuthLoader.ts +++ b/packages/react-router/src/server/rootAuthLoader.ts @@ -4,6 +4,7 @@ import type { LoaderFunctionArgs } from 'react-router'; import { invalidRootLoaderCallbackReturn } from '../utils/errors'; import { authFnContext, requestStateContext } from './clerkMiddleware'; import type { + AdditionalStateOptions, LoaderFunctionArgsWithAuth, LoaderFunctionReturn, RootAuthLoaderCallback, @@ -40,6 +41,7 @@ interface RootAuthLoader { async function processRootAuthLoader( args: LoaderFunctionArgs, requestState: RequestState, + additionalState: AdditionalStateOptions, handler?: RootAuthLoaderCallback, ): Promise { const hasMiddleware = IsOptIntoMiddleware(args.context) && !!args.context.get(authFnContext); @@ -47,7 +49,7 @@ async function processRootAuthLoader( if (!handler) { // if the user did not provide a handler, simply inject requestState into an empty response - const { clerkState } = getResponseClerkState(requestState, args.context); + const { clerkState } = getResponseClerkState(requestState, args.context, additionalState); return { ...clerkState, }; @@ -69,7 +71,13 @@ async function processRootAuthLoader( } // clone and try to inject requestState into all json-like responses // if this fails, the user probably didn't return a json object or a valid json string - return injectRequestStateIntoResponse(handlerResult, requestState, args.context, includeClerkHeaders); + return injectRequestStateIntoResponse( + handlerResult, + requestState, + args.context, + additionalState, + includeClerkHeaders, + ); } catch { throw new Error(invalidRootLoaderCallbackReturn); } @@ -83,6 +91,7 @@ async function processRootAuthLoader( new Response(JSON.stringify(handlerResult.data), handlerResult.init ?? undefined), requestState, args.context, + additionalState, includeClerkHeaders, ); } catch { @@ -91,7 +100,7 @@ async function processRootAuthLoader( } // If the return value of the user's handler is null or a plain object, return plain object with streaming support - const { clerkState } = getResponseClerkState(requestState, args.context); + const { clerkState } = getResponseClerkState(requestState, args.context, additionalState); return { ...(handlerResult ?? {}), @@ -111,13 +120,14 @@ export const rootAuthLoader: RootAuthLoader = async ( const handler = typeof handlerOrOptions === 'function' ? handlerOrOptions : undefined; const hasMiddlewareFlag = IsOptIntoMiddleware(args.context); - const requestState = hasMiddlewareFlag && args.context.get(requestStateContext); + const contextValue = hasMiddlewareFlag && args.context.get(requestStateContext); - if (!requestState) { + if (!contextValue) { throw new Error( 'Clerk: clerkMiddleware() not detected. Make sure you have installed the clerkMiddleware in your root route.', ); } - return processRootAuthLoader(args, requestState, handler); + const { requestState, additionalState } = contextValue; + return processRootAuthLoader(args, requestState, additionalState, handler); }; diff --git a/packages/react-router/src/server/types.ts b/packages/react-router/src/server/types.ts index 662d100fd58..9342f6b39af 100644 --- a/packages/react-router/src/server/types.ts +++ b/packages/react-router/src/server/types.ts @@ -68,6 +68,15 @@ export interface KeylessUrls { __keylessApiKeysUrl?: string; } +export type AdditionalStateOptions = SignInFallbackRedirectUrl & + SignUpFallbackRedirectUrl & + SignInForceRedirectUrl & + SignUpForceRedirectUrl & + KeylessUrls; + +/** + * @deprecated This type is no longer used internally. Use `AdditionalStateOptions` instead. + */ export type RequestStateWithRedirectUrls = RequestState & SignInForceRedirectUrl & SignInFallbackRedirectUrl & diff --git a/packages/react-router/src/server/utils.ts b/packages/react-router/src/server/utils.ts index 633ad667723..9609d0d73af 100644 --- a/packages/react-router/src/server/utils.ts +++ b/packages/react-router/src/server/utils.ts @@ -1,10 +1,11 @@ +import type { RequestState } from '@clerk/backend/internal'; import { constants, debugRequestState } from '@clerk/backend/internal'; import { parse as parseCookie } from 'cookie'; import type { AppLoadContext, UNSAFE_DataWithResponseInit } from 'react-router'; import { getPublicEnvVariables } from '../utils/env'; import { canUseKeyless } from '../utils/feature-flags'; -import type { RequestStateWithRedirectUrls } from './types'; +import type { AdditionalStateOptions } from './types'; export function isResponse(value: any): value is Response { return ( @@ -51,14 +52,15 @@ export const IsOptIntoMiddleware = (context: AppLoadContext) => { export const injectRequestStateIntoResponse = async ( response: Response, - requestState: RequestStateWithRedirectUrls, + requestState: RequestState, context: AppLoadContext, + additionalStateOptions: AdditionalStateOptions = {}, includeClerkHeaders = false, ) => { const clone = new Response(response.body, response); const data = await clone.json(); - const { clerkState, headers } = getResponseClerkState(requestState, context); + const { clerkState, headers } = getResponseClerkState(requestState, context, additionalStateOptions); // set the correct content-type header in case the user returned a `Response` directly clone.headers.set(constants.Headers.ContentType, constants.ContentTypes.Json); @@ -78,9 +80,14 @@ export const injectRequestStateIntoResponse = async ( * * @internal */ -export function getResponseClerkState(requestState: RequestStateWithRedirectUrls, context: AppLoadContext) { - const { reason, message, isSignedIn, __keylessClaimUrl, __keylessApiKeysUrl, ...rest } = requestState; +export function getResponseClerkState( + requestState: RequestState, + context: AppLoadContext, + additionalStateOptions: AdditionalStateOptions = {}, +) { + const { reason, message, isSignedIn, ...rest } = requestState; const envVars = getPublicEnvVariables(context); + const { __keylessClaimUrl, __keylessApiKeysUrl, ...redirectUrlOptions } = additionalStateOptions; const baseState: Record = { __clerk_ssr_state: rest.toAuth(), @@ -90,10 +97,10 @@ export function getResponseClerkState(requestState: RequestStateWithRedirectUrls __isSatellite: requestState.isSatellite, __signInUrl: requestState.signInUrl, __signUpUrl: requestState.signUpUrl, - __signInForceRedirectUrl: requestState.signInForceRedirectUrl, - __signUpForceRedirectUrl: requestState.signUpForceRedirectUrl, - __signInFallbackRedirectUrl: requestState.signInFallbackRedirectUrl, - __signUpFallbackRedirectUrl: requestState.signUpFallbackRedirectUrl, + __signInForceRedirectUrl: redirectUrlOptions.signInForceRedirectUrl, + __signUpForceRedirectUrl: redirectUrlOptions.signUpForceRedirectUrl, + __signInFallbackRedirectUrl: redirectUrlOptions.signInFallbackRedirectUrl, + __signUpFallbackRedirectUrl: redirectUrlOptions.signUpFallbackRedirectUrl, __clerk_debug: debugRequestState(requestState), __clerkJSUrl: envVars.clerkJsUrl, __clerkJSVersion: envVars.clerkJsVersion, From f0d309c612881e0ec8aaa2150337882a8afdcd00 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Thu, 21 May 2026 16:12:42 -0700 Subject: [PATCH 2/3] chore: add changeset --- .changeset/plain-apes-sneeze.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/plain-apes-sneeze.md diff --git a/.changeset/plain-apes-sneeze.md b/.changeset/plain-apes-sneeze.md new file mode 100644 index 00000000000..1780851fb84 --- /dev/null +++ b/.changeset/plain-apes-sneeze.md @@ -0,0 +1,5 @@ +--- +"@clerk/react-router": patch +--- + +Forward redirect URL options from middleware to client state. From c8a291fba3857659bb8e9cbdd10f615bf87065b2 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 21 May 2026 16:23:34 -0700 Subject: [PATCH 3/3] chore: update unit test --- .../server/__tests__/rootAuthLoader.test.ts | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts index 1076198d203..a1da41dae47 100644 --- a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts +++ b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts @@ -12,16 +12,21 @@ describe('rootAuthLoader', () => { }); describe('with middleware context', () => { + const mockRequestState = { + toAuth: vi.fn().mockImplementation(() => ({ + userId: 'user_xxx', + tokenType: TokenType.SessionToken, + })), + headers: new Headers(), + status: 'signed-in', + }; + const mockContext = { get: vi.fn().mockImplementation(contextKey => { if (contextKey === requestStateContext) { return { - toAuth: vi.fn().mockImplementation(() => ({ - userId: 'user_xxx', - tokenType: TokenType.SessionToken, - })), - headers: new Headers(), - status: 'signed-in', + requestState: mockRequestState, + additionalState: {}, }; } if (contextKey === authFnContext) { @@ -101,5 +106,39 @@ describe('rootAuthLoader', () => { expect(result).toHaveProperty('clerkState'); }); + + it('should forward redirect URL options from additionalState into clerkState', async () => { + const mockContext2 = { + get: vi.fn().mockImplementation(contextKey => { + if (contextKey === requestStateContext) { + return { + requestState: mockRequestState, + additionalState: { + signInForceRedirectUrl: '/dashboard', + signUpForceRedirectUrl: '/welcome', + signInFallbackRedirectUrl: '/home', + signUpFallbackRedirectUrl: '/home', + }, + }; + } + if (contextKey === authFnContext) { + return vi.fn().mockReturnValue({ userId: 'user_xxx', tokenType: TokenType.SessionToken }); + } + return null; + }), + set: vi.fn(), + }; + + const result = (await rootAuthLoader({ + context: mockContext2, + request: new Request('http://clerk.com'), + } as LoaderFunctionArgs)) as any; + + const internalState = result.clerkState.__internal_clerk_state; + expect(internalState.__signInForceRedirectUrl).toBe('/dashboard'); + expect(internalState.__signUpForceRedirectUrl).toBe('/welcome'); + expect(internalState.__signInFallbackRedirectUrl).toBe('/home'); + expect(internalState.__signUpFallbackRedirectUrl).toBe('/home'); + }); }); });