feat(expo): re-introduce two-way JS/native session sync#8088
feat(expo): re-introduce two-way JS/native session sync#8088chriscanin wants to merge 2 commits intomainfrom
Conversation
Re-applies the changes from #8032 which was reverted in #8065. This PR exists for visibility and review before re-merging. Original changes: - Two-way JS/native token sync for expo native components - Native session cleared on sign-out - Improved initialization error handling with timeout/failure messages - Additional debug logging in development
🦋 Changeset detectedLatest commit: 4685f92 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
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: |
📝 WalkthroughWalkthroughThis PR reintroduces two-way JS/native session synchronization for Expo. Changes include: updated Android API version to 1.0.9, refactored ClerkExpoModule with improved initialization flow and timeout handling, added getClientToken and signOut public methods to ClerkViewFactory, implemented keychain-backed token and session management on iOS, added NativeSessionSync component to ClerkProvider for JS/native auth state alignment, and updated UserButton and useUserProfileModal hooks to synchronize tokens before presenting native modals and conditionally handle sign-out. Possibly related PRs
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. 📝 Coding Plan
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 Tip You can customize the high-level summary generated by CodeRabbit.Configure the |
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/expo/src/hooks/useUserProfileModal.ts (1)
61-127:⚠️ Potential issue | 🟠 MajorAdd regression coverage before reintroducing this flow.
This hook now depends on a multi-step contract across JS, the native module, and native UI (
getSession→configure→ modal presentation → post-dismiss sign-out reconciliation), but the PR adds no automated coverage for it. Since this exact auth-sync work was already reverted once, we need regression tests around token sync and native-driven sign-out before merge.As per coding guidelines, "If there are no tests added or modified as part of the PR, please suggest that tests be added to cover the changes."
🤖 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 61 - 127, The new presentUserProfile flow (presentUserProfile hook) requires regression tests to cover the JS↔native token sync and native-driven sign-out reconciliation; add tests that mock ClerkExpo.getSession, ClerkExpo.configure, ClerkExpo.presentUserProfile, ClerkExpo.signOut, tokenCache.getToken, and the clerk.signOut method to assert: (1) when native has no session but tokenCache.getToken returns a bearer token, ClerkExpo.configure is called with clerk.publishableKey and that token and presentUserProfile is invoked; (2) when post-modal ClerkExpo.getSession returns null but hadNativeSessionBefore was true, clerk.signOut (and ClerkExpo.signOut if present) are called; and (3) the inverse case where configure produces a native session avoids JS signOut; target tests at the unit/integration layer exercising presentUserProfile (the useUserProfileModal hook) and use Jest mocks/spies for ClerkExpo.getSession, configure, presentUserProfile, signOut and tokenCache.getToken to verify the exact call sequence and side effects.
🤖 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/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt`:
- Around line 253-257: The signOut(promise: Promise) branch that returns when
!Clerk.isInitialized currently resolves without clearing the stored device
token; update signOut to always remove the stored DEVICE_TOKEN from
SharedPreferences (the same key written by configure()) before resolving, even
when Clerk.isInitialized is false. Locate the signOut method in
ClerkExpoModule.kt and add logic to obtain the module's SharedPreferences and
remove the "DEVICE_TOKEN" entry (or the constant used for that key), then
proceed to promise.resolve(null).
In `@packages/expo/ios/ClerkExpoModule.swift`:
- Around line 23-26: The template is missing the required protocol method
getClientToken() which breaks conformance to ClerkViewFactoryProtocol; add a
concrete implementation of getClientToken() inside the ClerkViewFactory class
(the type that implements ClerkViewFactoryProtocol) that returns the correct
client token string or nil as appropriate, ensuring the method signature exactly
matches getClientToken() -> String? and is public/internal consistent with other
protocol methods; update any backing storage or token retrieval logic used by
configure/getSession to return the same token value.
In `@packages/expo/ios/templates/ClerkViewFactory.swift`:
- Around line 323-330: The signOut() method currently returns early when there
is no live native session, skipping Clerk.clearAllKeychainItems() and
Self.clerkConfigured = false; change the control flow in signOut() so that
clearing native keychain state and resetting Self.clerkConfigured always runs
regardless of whether Clerk.shared.session?.id is present—attempt the signOut
call only if sessionId exists (try await Clerk.shared.auth.signOut(sessionId:
sessionId)), but move Clerk.clearAllKeychainItems() and Self.clerkConfigured =
false outside/after that guard/conditional so they execute unconditionally;
update the signOut() function in ClerkViewFactory.swift accordingly.
---
Outside diff comments:
In `@packages/expo/src/hooks/useUserProfileModal.ts`:
- Around line 61-127: The new presentUserProfile flow (presentUserProfile hook)
requires regression tests to cover the JS↔native token sync and native-driven
sign-out reconciliation; add tests that mock ClerkExpo.getSession,
ClerkExpo.configure, ClerkExpo.presentUserProfile, ClerkExpo.signOut,
tokenCache.getToken, and the clerk.signOut method to assert: (1) when native has
no session but tokenCache.getToken returns a bearer token, ClerkExpo.configure
is called with clerk.publishableKey and that token and presentUserProfile is
invoked; (2) when post-modal ClerkExpo.getSession returns null but
hadNativeSessionBefore was true, clerk.signOut (and ClerkExpo.signOut if
present) are called; and (3) the inverse case where configure produces a native
session avoids JS signOut; target tests at the unit/integration layer exercising
presentUserProfile (the useUserProfileModal hook) and use Jest mocks/spies for
ClerkExpo.getSession, configure, presentUserProfile, signOut and
tokenCache.getToken to verify the exact call sequence and side effects.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro
Run ID: e2579af5-b567-4b39-af9d-c538c6937b51
📒 Files selected for processing (9)
.changeset/native-session-sync-v2.mdpackages/expo/android/build.gradlepackages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.ktpackages/expo/ios/ClerkExpoModule.swiftpackages/expo/ios/ClerkViewFactory.swiftpackages/expo/ios/templates/ClerkViewFactory.swiftpackages/expo/src/hooks/useUserProfileModal.tspackages/expo/src/native/UserButton.tsxpackages/expo/src/provider/ClerkProvider.tsx
| if (!Clerk.isInitialized.value) { | ||
| // First-time initialization — write the bearer token to SharedPreferences | ||
| // before initializing so the SDK boots with the correct client. | ||
| if (!bearerToken.isNullOrEmpty()) { | ||
| reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) | ||
| .edit() | ||
| .putString("DEVICE_TOKEN", bearerToken) | ||
| .apply() | ||
| } | ||
|
|
||
| // Wait for initialization to complete with timeout | ||
| try { | ||
| withTimeout(10_000L) { | ||
| Clerk.isInitialized.first { it } | ||
| Clerk.initialize(reactApplicationContext, pubKey) | ||
|
|
||
| // Wait for initialization to complete with timeout | ||
| try { | ||
| withTimeout(10_000L) { | ||
| Clerk.isInitialized.first { it } | ||
| } | ||
| } catch (e: TimeoutCancellationException) { | ||
| val initError = Clerk.initializationError.value | ||
| val message = if (initError != null) { | ||
| "Clerk initialization timed out: ${initError.message}" | ||
| } else { | ||
| "Clerk initialization timed out after 10 seconds" | ||
| } | ||
| promise.reject("E_TIMEOUT", message) | ||
| return@launch | ||
| } | ||
| } catch (e: TimeoutCancellationException) { | ||
| val initError = Clerk.initializationError.value | ||
| val message = if (initError != null) { | ||
| "Clerk initialization timed out: ${initError.message}" | ||
|
|
||
| // Check for initialization errors | ||
| val error = Clerk.initializationError.value | ||
| if (error != null) { | ||
| promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}") | ||
| } else { | ||
| "Clerk initialization timed out after 10 seconds" | ||
| promise.resolve(null) | ||
| } | ||
| promise.reject("E_TIMEOUT", message) | ||
| return@launch |
There was a problem hiding this comment.
Wait for the first token-backed session before resolving configure().
This cold-start branch resolves as soon as Clerk.isInitialized flips. Both packages/expo/src/hooks/useUserProfileModal.ts and packages/expo/src/native/UserButton.tsx immediately call getSession() after configure(), so a first launch with a JS bearer token can still observe null and treat native as unsigned-in. That breaks the sync/sign-out reconciliation this PR is reintroducing.
Suggested fix
try {
withTimeout(10_000L) {
Clerk.isInitialized.first { it }
}
+ if (!bearerToken.isNullOrEmpty()) {
+ withTimeout(5_000L) {
+ Clerk.sessionFlow.first { it != null }
+ }
+ }
} catch (e: TimeoutCancellationException) {| override fun signOut(promise: Promise) { | ||
| if (!Clerk.isInitialized.value) { | ||
| promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") | ||
| // Resolve gracefully when not initialized (matches iOS behavior) | ||
| promise.resolve(null) | ||
| return |
There was a problem hiding this comment.
Clear DEVICE_TOKEN even when native was never initialized.
ClerkProvider calls ClerkExpo.signOut() on JS-side sign-out. In this branch we resolve without removing the SharedPreferences token that configure() wrote, so the next native init can still boot back into the old client/session.
Suggested fix
override fun signOut(promise: Promise) {
+ val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
if (!Clerk.isInitialized.value) {
- // Resolve gracefully when not initialized (matches iOS behavior)
+ prefs.edit().remove("DEVICE_TOKEN").apply()
promise.resolve(null)
return
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| override fun signOut(promise: Promise) { | |
| if (!Clerk.isInitialized.value) { | |
| promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") | |
| // Resolve gracefully when not initialized (matches iOS behavior) | |
| promise.resolve(null) | |
| return | |
| override fun signOut(promise: Promise) { | |
| val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) | |
| if (!Clerk.isInitialized.value) { | |
| prefs.edit().remove("DEVICE_TOKEN").apply() | |
| promise.resolve(null) | |
| return | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt`
around lines 253 - 257, The signOut(promise: Promise) branch that returns when
!Clerk.isInitialized currently resolves without clearing the stored device
token; update signOut to always remove the stored DEVICE_TOKEN from
SharedPreferences (the same key written by configure()) before resolving, even
when Clerk.isInitialized is false. Locate the signOut method in
ClerkExpoModule.kt and add logic to obtain the module's SharedPreferences and
remove the "DEVICE_TOKEN" entry (or the constant used for that key), then
proceed to promise.resolve(null).
| func configure(publishableKey: String, bearerToken: String?) async throws | ||
| func getSession() async -> [String: Any]? | ||
| func getClientToken() -> String? | ||
| func signOut() async throws |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Protocol requirements:"
sed -n '13,27p' packages/expo/ios/ClerkExpoModule.swift
echo
echo "Runtime factory methods:"
rg -n 'func (getClientToken|getSession|signOut)\b' packages/expo/ios/ClerkViewFactory.swift
echo
echo "Template factory methods:"
rg -n 'func (getClientToken|getSession|signOut)\b' packages/expo/ios/templates/ClerkViewFactory.swiftRepository: clerk/javascript
Length of output: 1287
The injected template is missing a required protocol method.
ClerkViewFactoryProtocol requires getClientToken(), but packages/expo/ios/templates/ClerkViewFactory.swift does not implement it. Any app built with this template will fail Swift protocol conformance at compile time.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/expo/ios/ClerkExpoModule.swift` around lines 23 - 26, The template
is missing the required protocol method getClientToken() which breaks
conformance to ClerkViewFactoryProtocol; add a concrete implementation of
getClientToken() inside the ClerkViewFactory class (the type that implements
ClerkViewFactoryProtocol) that returns the correct client token string or nil as
appropriate, ensuring the method signature exactly matches getClientToken() ->
String? and is public/internal consistent with other protocol methods; update
any backing storage or token retrieval logic used by configure/getSession to
return the same token value.
| public func signOut() async throws { | ||
| guard let sessionId = Clerk.shared.session?.id else { return } | ||
| guard Self.clerkConfigured, let sessionId = Clerk.shared.session?.id else { return } | ||
| try await Clerk.shared.auth.signOut(sessionId: sessionId) | ||
|
|
||
| // Clear all keychain data (device token, cached client/environment, etc.) | ||
| // so the native SDK doesn't boot with a stale token on next launch. | ||
| Clerk.clearAllKeychainItems() | ||
| Self.clerkConfigured = false |
There was a problem hiding this comment.
Do not return before clearing native keychain state.
When JS signs out before the native SDK has a live session, this guard exits without deleting the stored device token/cached Clerk state. That leaves stale native auth able to rehydrate on the next open, which is exactly the regression this PR is trying to prevent. packages/expo/ios/ClerkViewFactory.swift has the same guard and needs the same fix.
Suggested fix
`@MainActor`
public func signOut() async throws {
- guard Self.clerkConfigured, let sessionId = Clerk.shared.session?.id else { return }
- try await Clerk.shared.auth.signOut(sessionId: sessionId)
-
- // Clear all keychain data (device token, cached client/environment, etc.)
- // so the native SDK doesn't boot with a stale token on next launch.
- Clerk.clearAllKeychainItems()
- Self.clerkConfigured = false
+ guard Self.clerkConfigured else { return }
+ defer {
+ Clerk.clearAllKeychainItems()
+ Self.clerkConfigured = false
+ }
+
+ if let sessionId = Clerk.shared.session?.id {
+ try await Clerk.shared.auth.signOut(sessionId: sessionId)
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public func signOut() async throws { | |
| guard let sessionId = Clerk.shared.session?.id else { return } | |
| guard Self.clerkConfigured, let sessionId = Clerk.shared.session?.id else { return } | |
| try await Clerk.shared.auth.signOut(sessionId: sessionId) | |
| // Clear all keychain data (device token, cached client/environment, etc.) | |
| // so the native SDK doesn't boot with a stale token on next launch. | |
| Clerk.clearAllKeychainItems() | |
| Self.clerkConfigured = false | |
| public func signOut() async throws { | |
| guard Self.clerkConfigured else { return } | |
| defer { | |
| Clerk.clearAllKeychainItems() | |
| Self.clerkConfigured = false | |
| } | |
| if let sessionId = Clerk.shared.session?.id { | |
| try await Clerk.shared.auth.signOut(sessionId: sessionId) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/expo/ios/templates/ClerkViewFactory.swift` around lines 323 - 330,
The signOut() method currently returns early when there is no live native
session, skipping Clerk.clearAllKeychainItems() and Self.clerkConfigured =
false; change the control flow in signOut() so that clearing native keychain
state and resetting Self.clerkConfigured always runs regardless of whether
Clerk.shared.session?.id is present—attempt the signOut call only if sessionId
exists (try await Clerk.shared.auth.signOut(sessionId: sessionId)), but move
Clerk.clearAllKeychainItems() and Self.clerkConfigured = false outside/after
that guard/conditional so they execute unconditionally; update the signOut()
function in ClerkViewFactory.swift accordingly.
Summary
Re-applies the changes from #8032 which was reverted in #8065 due to premature merge.
This PR exists for visibility and review before re-merging. The code is identical to the original #8032:
Context
Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Improvements