Skip to content

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

Open
chriscanin wants to merge 6 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 6 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.
  • Chores

    • Removed a previously exported method for fetching a stored client token.

…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: 78b41dd

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 1:01am

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

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 10, 2026

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: c04014a5-b0b1-48c0-a088-dab9efeb62c7

📥 Commits

Reviewing files that changed from the base of the PR and between 3b1d3ee and 78b41dd.

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

📝 Walkthrough

Walkthrough

The PR removes the iOS exported getClientToken API. ClerkViewFactory gains clerkConfigured, keychain helpers (readNativeDeviceToken, writeNativeDeviceToken, clearCachedClerkData) and a fast-path to refresh the native client when the device token changes. JS side: ClerkProvider adds NativeSessionSync; UserButton and useUserProfileModal synchronize a JS bearer token to native when needed and conditionally sign out JS after native modal interactions. Android adds a reflection-based forceClientRefresh() and now resolves getSession/signOut as null when not initialized.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% 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 clearly and specifically summarizes the main change: adding two-way JS/native session synchronization for Expo native components, which is the core objective across multiple files in this changeset.

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

Comment on lines +72 to +89
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);
}
}
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 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.
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.
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