feat(expo): add two-way JS/native session sync for expo native components#8032
feat(expo): add two-way JS/native session sync for expo native components#8032chriscanin wants to merge 6 commits intomainfrom
Conversation
…ents When users authenticate via the JS SDK (custom sign-in forms, useSignIn, etc.) instead of through native AuthView, the native SDK doesn't know about the session. This causes native components like UserButton and UserProfileView to show empty/error states. Changes: - ClerkProvider: skip native configure when no bearer token to prevent creating anonymous native clients that conflict with later token sync - ClerkProvider: add NativeSessionSync component that pushes JS SDK bearer token to native when user signs in via JS - ClerkViewFactory (iOS): clear stale cached client/environment from keychain when device token changes, preventing 400 API errors from mismatched client IDs - ClerkViewFactory (iOS): add readNativeDeviceToken and clearCachedClerkData helpers for safe keychain management - ClerkViewFactory (iOS): track configure state with static flag to avoid accessing Clerk.shared before SDK initialization - UserButton: sync JS bearer token to native before presenting profile modal - useUserProfileModal: sync JS bearer token to native before presenting
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
@clerk/agent-toolkit
@clerk/astro
@clerk/backend
@clerk/chrome-extension
@clerk/clerk-js
@clerk/dev-cli
@clerk/expo
@clerk/expo-passkeys
@clerk/express
@clerk/fastify
@clerk/hono
@clerk/localizations
@clerk/nextjs
@clerk/nuxt
@clerk/react
@clerk/react-router
@clerk/shared
@clerk/tanstack-react-start
@clerk/testing
@clerk/ui
@clerk/upgrade
@clerk/vue
commit: |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository YAML (base), Organization UI (inherited) Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThe PR removes the iOS exported 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/expo/src/provider/ClerkProvider.tsx`:
- Around line 66-67: NativeSessionSync reads from defaultTokenCache instead of
using the tokenCache configured on ClerkProvider, causing native configure to be
skipped when a custom cache is used; change NativeSessionSync (and the other
occurrences around the 100-103 and 367 spots) to obtain the tokenCache provided
by ClerkProvider (via props or context) and use that tokenCache for
getCachedSessionToken, subscriptions, and when deciding to call native
configure; ensure all calls referencing defaultTokenCache (in NativeSessionSync,
its subscription/cleanup logic, and the similar blocks at the other mentioned
locations) are replaced to reference the injected tokenCache so custom caches
are honored.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro
Run ID: c0ae8b83-a7fb-422c-8cac-f6acdae7f86c
📒 Files selected for processing (5)
packages/expo/ios/ClerkExpoModule.swiftpackages/expo/ios/ClerkViewFactory.swiftpackages/expo/src/hooks/useUserProfileModal.tspackages/expo/src/native/UserButton.tsxpackages/expo/src/provider/ClerkProvider.tsx
💤 Files with no reviewable changes (1)
- packages/expo/ios/ClerkExpoModule.swift
…nents When users authenticate via the JS SDK (custom sign-in forms) instead of native <AuthView />, the native Android SDK doesn't have the session. This causes <UserButton /> and useUserProfileModal to show broken/empty profile modals. Changes: - ClerkExpoModule.kt: getSession() and signOut() now resolve gracefully when SDK is not initialized (matches iOS behavior), enabling NativeSessionSync to detect the missing session and call configure() - ClerkExpoModule.kt: configure() handles re-initialization when SDK is already initialized by writing bearer token to SharedPreferences and using type-based reflection to trigger a client refresh via reinitialize() - UserButton.tsx & useUserProfileModal.ts: Track whether native had a session before the profile modal opens, only sign out JS SDK if the session was actually lost during the modal (prevents false sign-out when native never had a session) - ClerkProvider.tsx (NativeSessionSync): Added debug logging for sync flow
Strip excessive console.log/debugLog statements added during Android session sync development. Keep only essential error/warn logs for production debugging. No behavioral changes.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/expo/src/hooks/useUserProfileModal.ts`:
- Around line 72-89: The post-configure re-check can still see no native session
on iOS; update useUserProfileModal so that after calling ClerkExpo.configure
(inside the branch that sets bearerToken) you poll ClerkExpo.getSession with a
short delay (e.g. 200-500ms) and a timeout (matching the existing ClerkProvider
3s behavior) until a sessionId/session is present before setting
hadNativeSessionBefore, ensuring the modal won't open prematurely; apply the
same polling change in the native UserButton component (UserButton.tsx) where
you currently only do a single post-configure getSession, and add a regression
test that performs a JS sign-in then asserts the native session becomes
available before the profile modal opens (covering the JS-sign-in →
native-profile flow).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro
Run ID: f601df34-6ef3-4412-9f28-d4d2ccb116cb
📒 Files selected for processing (3)
packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.ktpackages/expo/src/hooks/useUserProfileModal.tspackages/expo/src/native/UserButton.tsx
| let hadNativeSessionBefore = false; | ||
|
|
||
| // If native doesn't have a session but JS does (e.g. user signed in via custom form), | ||
| // sync the JS SDK's bearer token to native and wait for it before presenting. | ||
| if (user && ClerkExpo?.getSession && ClerkExpo?.configure) { | ||
| const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; | ||
| hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); | ||
|
|
||
| if (!hadNativeSessionBefore) { | ||
| const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; | ||
| if (bearerToken) { | ||
| await ClerkExpo.configure(clerk.publishableKey, bearerToken); | ||
|
|
||
| // Re-check if configure produced a session | ||
| const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; | ||
| hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); | ||
| } | ||
| } |
There was a problem hiding this comment.
Wait for the native session after configure() before opening the modal.
This block assumes the session is observable immediately after ClerkExpo.configure(). That isn’t true on iOS: packages/expo/ios/ClerkViewFactory.swift:38-77 can resolve before the native session is populated, and packages/expo/src/provider/ClerkProvider.tsx:161-210 already works around that with a 3s poll. Here the single re-check leaves hadNativeSessionBefore false, so the modal can open before the JS-authenticated native session is ready, and the later native→JS sign-out path can also be skipped because it keys off the same flag. Please add the same post-configure() wait loop here and in packages/expo/src/native/UserButton.tsx, and add a regression test for the JS-sign-in → native-profile flow.
🐛 Suggested fix
if (!hadNativeSessionBefore) {
const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null;
if (bearerToken) {
await ClerkExpo.configure(clerk.publishableKey, bearerToken);
-
- // Re-check if configure produced a session
- const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null;
- hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id);
+ const MAX_WAIT_MS = 3000;
+ const POLL_INTERVAL_MS = 100;
+
+ for (let elapsed = 0; elapsed < MAX_WAIT_MS; elapsed += POLL_INTERVAL_MS) {
+ const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null;
+ hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id);
+ if (hadNativeSessionBefore) {
+ break;
+ }
+
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
+ }
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/expo/src/hooks/useUserProfileModal.ts` around lines 72 - 89, The
post-configure re-check can still see no native session on iOS; update
useUserProfileModal so that after calling ClerkExpo.configure (inside the branch
that sets bearerToken) you poll ClerkExpo.getSession with a short delay (e.g.
200-500ms) and a timeout (matching the existing ClerkProvider 3s behavior) until
a sessionId/session is present before setting hadNativeSessionBefore, ensuring
the modal won't open prematurely; apply the same polling change in the native
UserButton component (UserButton.tsx) where you currently only do a single
post-configure getSession, and add a regression test that performs a JS sign-in
then asserts the native session becomes available before the profile modal opens
(covering the JS-sign-in → native-profile flow).
NativeSessionSync and configureNativeClerk were reading from the default SecureStore-based tokenCache rather than honoring the custom tokenCache prop passed to ClerkProvider. This caused native configure to be skipped when a custom cache was used, since the bearer token would be stored in the custom cache but read from the default one. Pass the tokenCache prop through to NativeSessionSync and use the effective cache (user-provided or default) in both sync paths.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/expo/src/provider/ClerkProvider.tsx`:
- Around line 84-112: hasSyncedRef.current is set before the async token read so
the effect can mark sync complete even when no token was pushed; move the
assignment so the flag is only set after we've confirmed native already has a
session or after a successful push to native. Concretely: remove the early
hasSyncedRef.current = true, run syncToNative() async, and inside syncToNative
set hasSyncedRef.current = true only after detecting hasNativeSession is true OR
after await ClerkExpo.configure(publishableKey, bearerToken) completes (or after
confirming there's no bearerToken and native already has a session). Update
references: hasSyncedRef, syncToNative, NativeClerkModule/ClerkExpo,
effectiveTokenCache.getToken, CLERK_CLIENT_JWT_KEY, and ClerkExpo.configure.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro
Run ID: 09c4623a-5762-45bd-941f-67d46a077feb
📒 Files selected for processing (1)
packages/expo/src/provider/ClerkProvider.tsx
…n-sync-for-expo-components
Move hasSyncedRef assignment into syncToNative so the guard flag is only set after confirming native already has a session or after successfully pushing the bearer token. Previously the flag was set synchronously before the async work, preventing retries on failure.
Summary
configure()when no bearer token exists, preventing creation of anonymous native clients that conflict with later JS→native token syncNativeSessionSynccomponent that automatically pushes JS SDK bearer token to native when user signs in via JS custom formsrefreshClient()Root cause
When users sign in via JS SDK (custom forms,
useSignIn, etc.) rather than native<AuthView />, the native Clerk SDK has no session. The native SDK'sCacheManagermay load a stale anonymous client from keychain (created by a previous launch), and whenrefreshClient()runs, it sends both the new device token (Authorizationheader) and the stale client ID (x-clerk-client-idheader) — causing a 400 API error.Test plan
useAuth()updates reactively🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
New Features
Chores