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 8 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: |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
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 (2)
📝 WalkthroughWalkthroughiOS: Added JS: Android: Added reflection-based 🚥 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
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.
…n-sync-for-expo-components
| * but the SDK was already initialized (so Clerk.initialize() is a no-op). | ||
| * | ||
| * Uses reflection to find the ConfigurationManager instance by type (field name | ||
| * may vary across SDK versions), then sets _isInitialized to false so |
There was a problem hiding this comment.
I'm curious about the use of reflection here. Generally considered an anti pattern in Java world
There was a problem hiding this comment.
Clerk.initialize() is a no-op if the SDK is already initialized, and the Clerk Android SDK doesn't expose any public API to force a re-initialization or swap the device token post-init. We need to trigger a fresh client/environment fetch after writing a new bearer token to SharedPreferences, and there's no way to do that (as far as I can tell) from the android SDK.
The reflection approach finds ConfigurationManager by type (to be resilient to field name changes across SDK versions), then finds the _isInitialized MutableStateFlow and flips it to false so reinitialize() will proceed.
Let me know if you can think of a cleaner way to do it, and I'll put it in!
There was a problem hiding this comment.
We can add those hooks for you in the Android SDK to avoid it if you'd like?
| } | ||
|
|
||
| @MainActor | ||
| public func configure(publishableKey: String, bearerToken: String? = nil) async throws { |
There was a problem hiding this comment.
If this function doesn't take in the keychain service as a parameter, I'm not sure how it would ever match the service used by the iOS SDK if they wanted to use a custom service. It looks like the Clerk.configure is always just called with a pub key, and never keychain config.
There was a problem hiding this comment.
I think for the v1 of this its fine to not support custom keychain services, but you might get asked to eventually implement many of the configuration options offered by the ios sdk.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bdd15bdd3f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!bearerToken) { | ||
| return; |
There was a problem hiding this comment.
Initialize native SDK even when no cached bearer token exists
This early return skips ClerkExpo.configure() whenever the token cache is empty (the normal signed-out cold-start case), and initStartedRef has already been set, so the provider never retries initialization later in the same mount. That leaves the native SDK uninitialized for signed-out users, which breaks native auth entry points that require initialization (for example Android presentAuth/presentUserProfile reject with E_NOT_INITIALIZED).
Useful? React with 👍 / 👎.
| // MARK: - signOut | ||
|
|
||
| @objc func signOut(_ resolve: @escaping RCTPromiseResolveBlock, |
There was a problem hiding this comment.
Restore iOS getClientToken bridge method or remove all callers
getClientToken was removed from the Swift module, but it is still part of the exported bridge contract (ios/ClerkExpoModule.m) and is still used by JS native-auth sync paths (AuthView/InlineAuthView). On iOS this creates a runtime contract mismatch, so calling ClerkExpo.getClientToken() can fail at runtime and prevents JS from receiving the native client token after native sign-in.
Useful? React with 👍 / 👎.
…fig to Clerk.configure Restore the getClientToken bridge method on iOS so native-to-JS session sync works after native sign-in (AuthView/InlineAuthView). Pass the resolved keychainService to Clerk.configure() via options so the native SDK uses the same keychain service as our helpers, supporting custom keychain services via ClerkKeychainService in Info.plist.
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