Skip to content

Commit d2a3d36

Browse files
fix: nested DataWithResponseInit causes invalid responses on HMR and client navigations (#44)
1 parent 9d5a368 commit d2a3d36

File tree

2 files changed

+37
-13
lines changed

2 files changed

+37
-13
lines changed

src/interfaces.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { OauthTokens, User } from '@workos-inc/node';
33

44
export type DataWithResponseInit<T> = ReturnType<typeof data<T>>;
55

6+
export type UnwrapData<T> = T extends DataWithResponseInit<infer U> ? U : T;
7+
68
export type HandleAuthOptions = {
79
returnPathname?: string;
810
onSuccess?: (data: AuthLoaderSuccessData) => void | Promise<void>;

src/session.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ import type {
77
DataWithResponseInit,
88
Session,
99
UnauthorizedData,
10+
UnwrapData,
1011
} from './interfaces.js';
1112
import { getWorkOS } from './workos.js';
1213

1314
import { sealData, unsealData } from 'iron-session';
1415
import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
1516
import { getConfig } from './config.js';
1617
import { configureSessionStorage, getSessionStorage } from './sessionStorage.js';
17-
import { isJsonResponse, isRedirect, isResponse } from './utils.js';
18+
import { isDataWithResponseInit, isJsonResponse, isRedirect, isResponse } from './utils.js';
1819

1920
// must be a type since this is a subtype of response
2021
// interfaces must conform to the types they extend
@@ -168,11 +169,17 @@ type LoaderValue<Data> = Response | TypedResponse<Data> | NonNullable<Data> | nu
168169
type LoaderReturnValue<Data> = Promise<LoaderValue<Data>> | LoaderValue<Data>;
169170

170171
type AuthLoader<Data> = (
171-
args: LoaderFunctionArgs & { auth: AuthorizedData | UnauthorizedData; getAccessToken: () => string | null },
172+
args: LoaderFunctionArgs & {
173+
auth: AuthorizedData | UnauthorizedData;
174+
getAccessToken: () => string | null;
175+
},
172176
) => LoaderReturnValue<Data>;
173177

174178
type AuthorizedAuthLoader<Data> = (
175-
args: LoaderFunctionArgs & { auth: AuthorizedData; getAccessToken: () => string },
179+
args: LoaderFunctionArgs & {
180+
auth: AuthorizedData;
181+
getAccessToken: () => string;
182+
},
176183
) => LoaderReturnValue<Data>;
177184

178185
/**
@@ -181,9 +188,6 @@ type AuthorizedAuthLoader<Data> = (
181188
*
182189
* Creates an authentication-aware loader function for React Router.
183190
*
184-
* This loader handles authentication state, session management, and access token refreshing
185-
* automatically, making it easier to build authenticated routes.
186-
*
187191
* @overload
188192
* Basic usage with enforced authentication that redirects unauthenticated users to sign in.
189193
*
@@ -252,7 +256,7 @@ export async function authkitLoader<Data = unknown>(
252256
loaderArgs: LoaderFunctionArgs,
253257
loader: AuthorizedAuthLoader<Data>,
254258
options: AuthKitLoaderOptions & { ensureSignedIn: true },
255-
): Promise<DataWithResponseInit<Data & AuthorizedData>>;
259+
): Promise<DataWithResponseInit<UnwrapData<Data> & AuthorizedData>>;
256260

257261
/**
258262
* This loader handles authentication state, session management, and access token refreshing
@@ -287,7 +291,7 @@ export async function authkitLoader<Data = unknown>(
287291
loaderArgs: LoaderFunctionArgs,
288292
loader: AuthLoader<Data>,
289293
options?: AuthKitLoaderOptions,
290-
): Promise<DataWithResponseInit<Data & (AuthorizedData | UnauthorizedData)>>;
294+
): Promise<DataWithResponseInit<UnwrapData<Data> & (AuthorizedData | UnauthorizedData)>>;
291295

292296
export async function authkitLoader<Data = unknown>(
293297
loaderArgs: LoaderFunctionArgs,
@@ -305,7 +309,10 @@ export async function authkitLoader<Data = unknown>(
305309
} = typeof loaderOrOptions === 'object' ? loaderOrOptions : options;
306310

307311
const cookieName = cookie?.name ?? getConfig('cookieName');
308-
const { getSession, destroySession } = await configureSessionStorage({ storage, cookieName });
312+
const { getSession, destroySession } = await configureSessionStorage({
313+
storage,
314+
cookieName,
315+
});
309316

310317
const { request } = loaderArgs;
311318

@@ -443,7 +450,11 @@ async function handleAuthLoader(
443450
} else {
444451
// Unauthorized case
445452
const getAccessToken = () => null;
446-
loaderResult = await (loader as AuthLoader<unknown>)({ ...args, auth, getAccessToken });
453+
loaderResult = await (loader as AuthLoader<unknown>)({
454+
...args,
455+
auth,
456+
getAccessToken,
457+
});
447458
}
448459

449460
if (isResponse(loaderResult)) {
@@ -467,9 +478,20 @@ async function handleAuthLoader(
467478
return data({ ...responseData, ...auth }, newResponse);
468479
}
469480

470-
// If the loader returns a non-Response, assume it's a data object
471-
// istanbul ignore next
472-
return data({ ...loaderResult, ...auth }, session ? { headers: { ...session.headers } } : undefined);
481+
const actualData = isDataWithResponseInit(loaderResult) ? loaderResult.data : loaderResult;
482+
483+
const mergedHeaders = isDataWithResponseInit(loaderResult) ? new Headers(loaderResult.init?.headers) : new Headers();
484+
485+
if (session?.headers) {
486+
Object.entries(session.headers).forEach(([key, value]) => {
487+
mergedHeaders.set(key, value);
488+
});
489+
}
490+
491+
const mergedData = actualData && typeof actualData === 'object' ? { ...actualData, ...auth } : { ...auth };
492+
493+
// Always pass headers (empty headers object is valid)
494+
return data(mergedData, { headers: mergedHeaders });
473495
}
474496

475497
export async function terminateSession(request: Request, { returnTo }: { returnTo?: string } = {}) {

0 commit comments

Comments
 (0)