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
5 changes: 5 additions & 0 deletions .changeset/fresh-expo-token-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/expo": patch
---

Fix persisted session restoration when the native Clerk singleton is created before `ClerkProvider` receives the app's token cache.
Original file line number Diff line number Diff line change
Expand Up @@ -280,4 +280,108 @@ describe('createClerkInstance', () => {

await expect(tokenCache.getToken(CLERK_CLIENT_JWT_KEY)).resolves.toBe('fresh-token');
});

test('uses the latest explicit tokenCache for request authorization when the singleton is reused', async () => {
const initialTokenCache: TokenCache = {
getToken: vi.fn(() => Promise.resolve(null)),
saveToken: vi.fn(() => Promise.resolve()),
};
const latestTokenCache: TokenCache = {
getToken: vi.fn(() => Promise.resolve('cached-token')),
saveToken: vi.fn(() => Promise.resolve()),
};

const createClerkInstance = await loadCreateClerkInstance();
const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk);
const clerk = getClerkInstance({
publishableKey: 'pk_test_123',
tokenCache: initialTokenCache,
}) as unknown as MockClerk;

getClerkInstance({
publishableKey: 'pk_test_123',
tokenCache: latestTokenCache,
});
getClerkInstance();

const beforeRequest = clerk.__internal_onBeforeRequest.mock.calls[0][0];
const requestInit = {
headers: new Headers(),
url: new URL('https://clerk.example.com/v1/client'),
};
await beforeRequest(requestInit);

expect(requestInit.headers.get('authorization')).toBe('cached-token');
});

test('preserves the latest tokenCache when the singleton is reused without one', async () => {
const initialTokenCache: TokenCache = {
getToken: vi.fn(() => Promise.resolve(null)),
saveToken: vi.fn(() => Promise.resolve()),
};
const latestTokenCache: TokenCache = {
getToken: vi.fn(() => Promise.resolve('cached-token')),
saveToken: vi.fn(() => Promise.resolve()),
};

const createClerkInstance = await loadCreateClerkInstance();
const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk);
const clerk = getClerkInstance({
publishableKey: 'pk_test_123',
tokenCache: initialTokenCache,
}) as unknown as MockClerk;

getClerkInstance({
publishableKey: 'pk_test_123',
tokenCache: latestTokenCache,
});
getClerkInstance({ publishableKey: 'pk_test_123' });

const beforeRequest = clerk.__internal_onBeforeRequest.mock.calls[0][0];
const requestInit = {
headers: new Headers(),
url: new URL('https://clerk.example.com/v1/client'),
};
await beforeRequest(requestInit);

expect(requestInit.headers.get('authorization')).toBe('cached-token');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

test('uses the latest explicit tokenCache for response authorization when the singleton is reused', async () => {
const initialTokenCache: TokenCache = {
getToken: vi.fn(() => Promise.resolve(null)),
saveToken: vi.fn(() => Promise.resolve()),
};
const latestTokenCache: TokenCache = {
getToken: vi.fn(() => Promise.resolve(null)),
saveToken: vi.fn(() => Promise.resolve()),
};

const createClerkInstance = await loadCreateClerkInstance();
const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk);
const clerk = getClerkInstance({
publishableKey: 'pk_test_123',
tokenCache: initialTokenCache,
}) as unknown as MockClerk;

getClerkInstance({
publishableKey: 'pk_test_123',
tokenCache: latestTokenCache,
});

const afterResponse = clerk.__internal_onAfterResponse.mock.calls[0][0];
await afterResponse(
{
headers: new Headers(),
url: new URL('https://clerk.example.com/v1/client'),
},
{
headers: new Headers({ authorization: 'fresh-token' }),
payload: null,
},
);

expect(initialTokenCache.saveToken).not.toHaveBeenCalled();
expect(latestTokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'fresh-token');
});
});
22 changes: 15 additions & 7 deletions packages/expo/src/provider/singleton/createClerkInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
SessionJWTCache,
} from '../../cache';
import { MemoryTokenCache } from '../../cache/MemoryTokenCache';
import type { TokenCache } from '../../cache/types';
import { CLERK_CLIENT_JWT_KEY } from '../../constants';
import { errorThrower } from '../../errorThrower';
import { assertValidProxyUrl } from '../../utils/errors';
Expand All @@ -43,15 +44,17 @@ type ResolvedClerkRuntimeOptions = Omit<ClerkRuntimeOptions, 'publishableKey'> &
publishableKey: string;
};

function hasOwnOption<Key extends keyof ClerkRuntimeOptions>(
options: ClerkRuntimeOptions | undefined,
function hasOwnOption<Key extends keyof BuildClerkOptions>(
options: BuildClerkOptions | undefined,
key: Key,
): options is ClerkRuntimeOptions & Required<Pick<ClerkRuntimeOptions, Key>> {
): options is BuildClerkOptions & Required<Pick<BuildClerkOptions, Key>> {
return !!options && Object.prototype.hasOwnProperty.call(options, key);
}

let __internal_clerk: HeadlessBrowserClerk | BrowserClerk | undefined;
let __internal_clerkOptions: ClerkRuntimeOptions | undefined;
// Token IO can change without recreating the native singleton.
let __internal_tokenCache: TokenCache = MemoryTokenCache;

/**
* Resolves the next native singleton config while preserving existing values for omitted options.
Expand Down Expand Up @@ -90,7 +93,7 @@ function getUpdatedClerkOptions(

export function createClerkInstance(ClerkClass: typeof Clerk) {
return (options?: BuildClerkOptions): HeadlessBrowserClerk | BrowserClerk => {
const { tokenCache = MemoryTokenCache, __experimental_resourceCache: createResourceCache } = options || {};
const { __experimental_resourceCache: createResourceCache } = options || {};
const {
hasConfigChanged,
options: { publishableKey, proxyUrl, domain },
Expand All @@ -100,15 +103,19 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
errorThrower.throwMissingPublishableKeyError();
}

if (hasOwnOption(options, 'tokenCache')) {
__internal_tokenCache = options.tokenCache ?? MemoryTokenCache;
}

if (!__internal_clerk || hasConfigChanged) {
assertValidProxyUrl(proxyUrl);

if (hasConfigChanged) {
tokenCache.clearToken?.(CLERK_CLIENT_JWT_KEY);
void __internal_tokenCache.clearToken?.(CLERK_CLIENT_JWT_KEY);
}

const getToken = (key: string) => tokenCache.getToken(key);
const saveToken = (key: string, token: string) => tokenCache.saveToken(key, token);
const getToken = (key: string) => __internal_tokenCache.getToken(key);
const saveToken = (key: string, token: string) => __internal_tokenCache.saveToken(key, token);

__internal_clerkOptions = { publishableKey, proxyUrl, domain };
__internal_clerk = new ClerkClass(publishableKey, { proxyUrl, domain }) as unknown as BrowserClerk;
Expand Down Expand Up @@ -245,6 +252,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
}
});
}

// At this point __internal_clerk is guaranteed to be defined
return __internal_clerk;
};
Expand Down
Loading