Skip to content

feat(expo): re-introduce two-way JS/native session sync#8088

Open
chriscanin wants to merge 2 commits intomainfrom
chris/native-session-sync-v2
Open

feat(expo): re-introduce two-way JS/native session sync#8088
chriscanin wants to merge 2 commits intomainfrom
chris/native-session-sync-v2

Conversation

@chriscanin
Copy link
Member

@chriscanin chriscanin commented Mar 16, 2026

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:

  • Two-way JS/native token sync for expo native components
  • Native session reliably cleared on sign-out, avoids stale session/state after closing native UI
  • Improved initialization error handling with clearer timeout and failure messages
  • Additional debug logging in development

Context

Test plan

  • Review changes thoroughly before merging
  • Test native sign-in/sign-out flow on iOS
  • Test native sign-in/sign-out flow on Android
  • Verify session state stays in sync between JS and native layers
  • Verify no regressions in existing expo functionality

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Re-introduced two-way session synchronization between JavaScript and native components for Expo applications.
  • Improvements

    • Enhanced authentication token synchronization during native user profile interactions.
    • Refined sign-out behavior to ensure consistent session state across native and JavaScript layers.
    • Updated native dependencies to latest versions.

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-bot
Copy link

changeset-bot bot commented Mar 16, 2026

🦋 Changeset detected

Latest commit: 4685f92

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@clerk/expo Patch

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

@vercel
Copy link

vercel bot commented Mar 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Mar 16, 2026 4:43pm

Request Review

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 16, 2026

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@8088

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@8088

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@8088

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@8088

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@8088

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@8088

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@8088

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@8088

@clerk/express

npm i https://pkg.pr.new/@clerk/express@8088

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@8088

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@8088

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@8088

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@8088

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@8088

@clerk/react

npm i https://pkg.pr.new/@clerk/react@8088

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@8088

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@8088

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@8088

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@8088

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@8088

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@8088

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@8088

commit: 4685f92

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 16, 2026

📝 Walkthrough

Walkthrough

This 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)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(expo): re-introduce two-way JS/native session sync' accurately summarizes the main change—reinstating two-way session synchronization between JavaScript and native layers in the Expo package.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📝 Coding Plan
  • Generate coding plan for human review comments

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can customize the high-level summary generated by CodeRabbit.

Configure the reviews.high_level_summary_instructions setting to provide custom instructions for generating the high-level summary.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Add regression coverage before reintroducing this flow.

This hook now depends on a multi-step contract across JS, the native module, and native UI (getSessionconfigure → 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

📥 Commits

Reviewing files that changed from the base of the PR and between da2b239 and 4685f92.

📒 Files selected for processing (9)
  • .changeset/native-session-sync-v2.md
  • packages/expo/android/build.gradle
  • packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
  • packages/expo/ios/ClerkExpoModule.swift
  • packages/expo/ios/ClerkViewFactory.swift
  • packages/expo/ios/templates/ClerkViewFactory.swift
  • packages/expo/src/hooks/useUserProfileModal.ts
  • packages/expo/src/native/UserButton.tsx
  • packages/expo/src/provider/ClerkProvider.tsx

Comment on lines +71 to 106
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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) {

Comment on lines 253 to 257
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
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).

Comment on lines 23 to 26
func configure(publishableKey: String, bearerToken: String?) async throws
func getSession() async -> [String: Any]?
func getClientToken() -> String?
func signOut() async throws
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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.swift

Repository: 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.

Comment on lines 323 to +330
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant