Skip to content

feat(expo): add two-way JS/native session sync for expo native components#8032

Open
chriscanin wants to merge 8 commits intomainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components
Open

feat(expo): add two-way JS/native session sync for expo native components#8032
chriscanin wants to merge 8 commits intomainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components

Conversation

@chriscanin
Copy link
Member

@chriscanin chriscanin commented Mar 10, 2026

Summary

  • ClerkProvider: Skip native configure() when no bearer token exists, preventing creation of anonymous native clients that conflict with later JS→native token sync
  • ClerkProvider: Add NativeSessionSync component that automatically pushes JS SDK bearer token to native when user signs in via JS custom forms
  • ClerkViewFactory (iOS): Clear stale cached client/environment from keychain when device token changes, preventing 400 API errors from mismatched client IDs during refreshClient()
  • UserButton / useUserProfileModal: Sync JS bearer token to native before presenting the profile modal

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's CacheManager may load a stale anonymous client from keychain (created by a previous launch), and when refreshClient() runs, it sends both the new device token (Authorization header) and the stale client ID (x-clerk-client-id header) — causing a 400 API error.

Test plan

  • Sign in via JS custom form → tap UserButton → native profile modal shows user profile
  • Sign in via native AuthView → tap UserButton → native profile modal shows user profile
  • Sign out from native profile modal → JS SDK useAuth() updates reactively
  • App restart after JS sign-in → native components still work
  • Fresh install (no cached data) → no crash on first configure

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Clears cached auth data when device tokens change to prevent stale client IDs.
    • Improved sign-out coordination so JS and native sessions stay in sync and avoid unnecessary sign-outs.
    • getSession and signOut now return null (instead of error) when the native client is not initialized.
  • New Features

    • Automatic synchronization of JS and native authentication on sign-in and before showing native profile UI.
    • Faster client refresh when a new bearer token is provided.
    • Public API to read the native client token.
  • Chores

    • Improved native initialization and token handling flows.

…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
@changeset-bot
Copy link

changeset-bot bot commented Mar 10, 2026

⚠️ No Changeset found

Latest commit: 39d3e70

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link

vercel bot commented Mar 10, 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 11, 2026 6:25pm

Request Review

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 10, 2026

Open in StackBlitz

@clerk/agent-toolkit

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

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/dev-cli

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 39d3e70

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 10, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 2a8297a8-eee6-4be7-9628-1d6e2930c524

📥 Commits

Reviewing files that changed from the base of the PR and between 78b41dd and 39d3e70.

📒 Files selected for processing (2)
  • packages/expo/ios/ClerkExpoModule.swift
  • packages/expo/ios/ClerkViewFactory.swift

📝 Walkthrough

Walkthrough

iOS: Added getClientToken() to ClerkViewFactoryProtocol; ClerkExpoModule.getClientToken now delegates to the view factory instead of reading keychain. ClerkViewFactory added clerkConfigured, keychain helpers (readNativeDeviceToken, writeNativeDeviceToken, clearCachedClerkData), a per-instance refresh fast-path when bearer token changes, and a public getClientToken().

JS: ClerkProvider adds a NativeSessionSync component; UserButton and useUserProfileModal synchronize JS bearer tokens to native before presenting and conditionally sign out JS after native modal interactions.

Android: Added reflection-based forceClientRefresh() to reinitialize client; getSession() and signOut() now resolve null when not initialized.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main objective of the PR: adding two-way JS/native session synchronization for expo native components, which is reflected in all major changes across ClerkProvider, iOS, Android, UserButton, and useUserProfileModal.

✏️ 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.

❤️ Share

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 121da74 and 9168040.

📒 Files selected for processing (5)
  • packages/expo/ios/ClerkExpoModule.swift
  • packages/expo/ios/ClerkViewFactory.swift
  • packages/expo/src/hooks/useUserProfileModal.ts
  • packages/expo/src/native/UserButton.tsx
  • packages/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.
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: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9168040 and 96ca76d.

📒 Files selected for processing (3)
  • packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
  • packages/expo/src/hooks/useUserProfileModal.ts
  • packages/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.
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: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 96ca76d and 3b1d3ee.

📒 Files selected for processing (1)
  • packages/expo/src/provider/ClerkProvider.tsx

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.
* 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

Choose a reason for hiding this comment

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

I'm curious about the use of reflection here. Generally considered an anti pattern in Java world

Copy link
Member Author

Choose a reason for hiding this comment

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

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!

Choose a reason for hiding this comment

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

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

@mikepitre mikepitre Mar 11, 2026

Choose a reason for hiding this comment

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

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.

Copy link

@mikepitre mikepitre Mar 11, 2026

Choose a reason for hiding this comment

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

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.

@mikepitre
Copy link

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +189 to +190
if (!bearerToken) {
return;

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines 173 to 175
// MARK: - signOut

@objc func signOut(_ resolve: @escaping RCTPromiseResolveBlock,

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

@chriscanin chriscanin self-assigned this Mar 11, 2026
…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.
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.

3 participants