Skip to content

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

Merged
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components
Mar 13, 2026
Merged

feat(expo): add two-way JS/native session sync for expo native components#8032
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components

Conversation

@chriscanin

@chriscaninchriscanin commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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

changeset-botBot commented Mar 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bc0d928

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

This PR includes changesets to release 1 package
NameType
@clerk/expoPatch

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

vercelBot commented Mar 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentMar 13, 2026 3:22pm

Request Review

@pkg-pr-new

pkg-pr-newBot commented Mar 10, 2026

Copy link
Copy Markdown

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

@coderabbitai

coderabbitaiBot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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
📝 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 nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring 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 nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/expo/src/provider/ClerkProvider.tsx Outdated
…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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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 threadpackages/expo/src/hooks/useUserProfileModal.ts
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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/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

Copy link
Copy Markdown
Contributor

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
Copy Markdown
ContributorAuthor

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!

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've pushed a commit that drops the entire reflection hack and uses updateDeviceToken() instead. It saves the token via StorageHelper and triggers refreshClientAndEnvironment() internally.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Things are working well. I am going to approve the PR here: clerk/clerk-android#564 (review)
If you can publish to maven, that would be amazing!

}

@MainActor
public func configure(publishableKey: String, bearerToken: String? = nil) async throws {

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

ClerkViewFactory now resolves the keychain service internally via a computed property that checks Info.plist for a ClerkKeychainService key first, then falls back to Bundle.main.bundleIdentifier. That resolved service gets passed to Clerk.configure(publishableKey:options:) as Clerk.Options(keychainConfig: .init(service: service)). So it matches whatever the iOS SDK uses without needing to pipe it through the bridge method signature.

I can start working towards including configuration options as a followup after the theming (and possibly testing, but can discuss the testing later in the week / monday). It will be important to include these.

@mikepitre

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

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;

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this. The current code in ClerkProvider.tsx calls ClerkExpo.configure(pk, bearerToken) unconditionally on mount regardless of whether bearerToken is null. There's no early return based on token presence.

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

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

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this now as well. getClientToken exists in both ClerkExpoModule.swift (as @objc func getClientToken) and the bridge file ClerkExpoModule.m (as RCT_EXTERN_METHOD). It's also defined in ClerkViewFactoryProtocol and implemented in ClerkViewFactory where it delegates to readNativeDeviceToken().

@chriscaninchriscanin 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.
Co-authored-by: Christopher Canin <chris@clerk.dev>
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

const effectiveTokenCache = tokenCache ?? defaultTokenCache;

useEffect(() => {
if (!isSignedIn) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Clear the native session when JS auth signs out

When isSignedIn becomes false this effect only resets hasSyncedRef and returns, so the native Clerk SDK keeps the previously mirrored session/device token. Any JS-side sign-out path (clerk.signOut(), session expiry, custom auth UI) will therefore leave ClerkExpo.getSession() non-null; components like UserButton immediately read that native session back and continue showing/opening the signed-in profile even though the JS SDK is signed out.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

This has been fixed. The NativeSessionSync effect now calls ClerkExpo.signOut() when isSignedIn becomes false (lines 84-91 in ClerkProvider.tsx). This clears the native SDK's session so getSession() returns null and native components like UserButton won't show stale signed-in state.

Note: in the typical quickstart pattern where UserButton is only rendered inside an if (isSignedIn) guard, the stale native session was invisible since the component unmounts. But this fix makes it correct regardless of how the consumer structures their component tree.

Call ClerkExpo.signOut() in NativeSessionSync when isSignedIn becomes
false so native components don't retain a stale session after JS-side
sign out.
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

…n iOS
The iOS inline AuthView relied on Clerk.shared.auth.events async sequence
which may not emit for inline sign-ins. Switch to direct session observation
via .onChange(of: Clerk.shared.session?.id) matching Android's sessionFlow
approach. Also emit onAuthStateChange module events from inline views and
sync the native client token in ClerkProvider's syncNativeAuthToJs handler.
@chriscanin
chriscanin merged commit 4c2f188 into mainMar 13, 2026
41 checks passed
@chriscanin
chriscanin deleted the chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components branch March 13, 2026 15:24
chriscanin added a commit that referenced this pull request Mar 13, 2026
chriscanin added a commit that referenced this pull request Mar 16, 2026
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
Sign up for freeto 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

@chriscanin@mikepitre@swolfand
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(expo): add two-way JS/native session sync for expo native components by chriscanin · Pull Request #8032 · clerk/javascript · GitHub
Skip to content

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

Merged
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components
Mar 13, 2026
Merged

feat(expo): add two-way JS/native session sync for expo native components#8032
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components

Conversation

@chriscanin

@chriscaninchriscanin commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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

changeset-botBot commented Mar 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bc0d928

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

This PR includes changesets to release 1 package
NameType
@clerk/expoPatch

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

vercelBot commented Mar 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentMar 13, 2026 3:22pm

Request Review

@pkg-pr-new

pkg-pr-newBot commented Mar 10, 2026

Copy link
Copy Markdown

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

@coderabbitai

coderabbitaiBot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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
📝 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 nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring 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 nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/expo/src/provider/ClerkProvider.tsx Outdated
…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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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 threadpackages/expo/src/hooks/useUserProfileModal.ts
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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/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

Copy link
Copy Markdown
Contributor

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
Copy Markdown
ContributorAuthor

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!

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've pushed a commit that drops the entire reflection hack and uses updateDeviceToken() instead. It saves the token via StorageHelper and triggers refreshClientAndEnvironment() internally.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Things are working well. I am going to approve the PR here: clerk/clerk-android#564 (review)
If you can publish to maven, that would be amazing!

}

@MainActor
public func configure(publishableKey: String, bearerToken: String? = nil) async throws {

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

ClerkViewFactory now resolves the keychain service internally via a computed property that checks Info.plist for a ClerkKeychainService key first, then falls back to Bundle.main.bundleIdentifier. That resolved service gets passed to Clerk.configure(publishableKey:options:) as Clerk.Options(keychainConfig: .init(service: service)). So it matches whatever the iOS SDK uses without needing to pipe it through the bridge method signature.

I can start working towards including configuration options as a followup after the theming (and possibly testing, but can discuss the testing later in the week / monday). It will be important to include these.

@mikepitre

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

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;

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this. The current code in ClerkProvider.tsx calls ClerkExpo.configure(pk, bearerToken) unconditionally on mount regardless of whether bearerToken is null. There's no early return based on token presence.

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

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

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this now as well. getClientToken exists in both ClerkExpoModule.swift (as @objc func getClientToken) and the bridge file ClerkExpoModule.m (as RCT_EXTERN_METHOD). It's also defined in ClerkViewFactoryProtocol and implemented in ClerkViewFactory where it delegates to readNativeDeviceToken().

@chriscaninchriscanin 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.
Co-authored-by: Christopher Canin <chris@clerk.dev>
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

const effectiveTokenCache = tokenCache ?? defaultTokenCache;

useEffect(() => {
if (!isSignedIn) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Clear the native session when JS auth signs out

When isSignedIn becomes false this effect only resets hasSyncedRef and returns, so the native Clerk SDK keeps the previously mirrored session/device token. Any JS-side sign-out path (clerk.signOut(), session expiry, custom auth UI) will therefore leave ClerkExpo.getSession() non-null; components like UserButton immediately read that native session back and continue showing/opening the signed-in profile even though the JS SDK is signed out.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

This has been fixed. The NativeSessionSync effect now calls ClerkExpo.signOut() when isSignedIn becomes false (lines 84-91 in ClerkProvider.tsx). This clears the native SDK's session so getSession() returns null and native components like UserButton won't show stale signed-in state.

Note: in the typical quickstart pattern where UserButton is only rendered inside an if (isSignedIn) guard, the stale native session was invisible since the component unmounts. But this fix makes it correct regardless of how the consumer structures their component tree.

Call ClerkExpo.signOut() in NativeSessionSync when isSignedIn becomes
false so native components don't retain a stale session after JS-side
sign out.
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

…n iOS
The iOS inline AuthView relied on Clerk.shared.auth.events async sequence
which may not emit for inline sign-ins. Switch to direct session observation
via .onChange(of: Clerk.shared.session?.id) matching Android's sessionFlow
approach. Also emit onAuthStateChange module events from inline views and
sync the native client token in ClerkProvider's syncNativeAuthToJs handler.
@chriscanin
chriscanin merged commit 4c2f188 into mainMar 13, 2026
41 checks passed
@chriscanin
chriscanin deleted the chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components branch March 13, 2026 15:24
chriscanin added a commit that referenced this pull request Mar 13, 2026
chriscanin added a commit that referenced this pull request Mar 16, 2026
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
Sign up for freeto 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

@chriscanin@mikepitre@swolfand
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(expo): add two-way JS/native session sync for expo native components by chriscanin · Pull Request #8032 · clerk/javascript · GitHub
Skip to content

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

Merged
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components
Mar 13, 2026
Merged

feat(expo): add two-way JS/native session sync for expo native components#8032
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components

Conversation

@chriscanin

@chriscaninchriscanin commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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

changeset-botBot commented Mar 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bc0d928

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

This PR includes changesets to release 1 package
NameType
@clerk/expoPatch

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

vercelBot commented Mar 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentMar 13, 2026 3:22pm

Request Review

@pkg-pr-new

pkg-pr-newBot commented Mar 10, 2026

Copy link
Copy Markdown

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

@coderabbitai

coderabbitaiBot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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
📝 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 nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring 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 nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/expo/src/provider/ClerkProvider.tsx Outdated
…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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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 threadpackages/expo/src/hooks/useUserProfileModal.ts
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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/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

Copy link
Copy Markdown
Contributor

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
Copy Markdown
ContributorAuthor

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!

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've pushed a commit that drops the entire reflection hack and uses updateDeviceToken() instead. It saves the token via StorageHelper and triggers refreshClientAndEnvironment() internally.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Things are working well. I am going to approve the PR here: clerk/clerk-android#564 (review)
If you can publish to maven, that would be amazing!

}

@MainActor
public func configure(publishableKey: String, bearerToken: String? = nil) async throws {

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

ClerkViewFactory now resolves the keychain service internally via a computed property that checks Info.plist for a ClerkKeychainService key first, then falls back to Bundle.main.bundleIdentifier. That resolved service gets passed to Clerk.configure(publishableKey:options:) as Clerk.Options(keychainConfig: .init(service: service)). So it matches whatever the iOS SDK uses without needing to pipe it through the bridge method signature.

I can start working towards including configuration options as a followup after the theming (and possibly testing, but can discuss the testing later in the week / monday). It will be important to include these.

@mikepitre

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

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;

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this. The current code in ClerkProvider.tsx calls ClerkExpo.configure(pk, bearerToken) unconditionally on mount regardless of whether bearerToken is null. There's no early return based on token presence.

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

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

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this now as well. getClientToken exists in both ClerkExpoModule.swift (as @objc func getClientToken) and the bridge file ClerkExpoModule.m (as RCT_EXTERN_METHOD). It's also defined in ClerkViewFactoryProtocol and implemented in ClerkViewFactory where it delegates to readNativeDeviceToken().

@chriscaninchriscanin 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.
Co-authored-by: Christopher Canin <chris@clerk.dev>
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

const effectiveTokenCache = tokenCache ?? defaultTokenCache;

useEffect(() => {
if (!isSignedIn) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Clear the native session when JS auth signs out

When isSignedIn becomes false this effect only resets hasSyncedRef and returns, so the native Clerk SDK keeps the previously mirrored session/device token. Any JS-side sign-out path (clerk.signOut(), session expiry, custom auth UI) will therefore leave ClerkExpo.getSession() non-null; components like UserButton immediately read that native session back and continue showing/opening the signed-in profile even though the JS SDK is signed out.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

This has been fixed. The NativeSessionSync effect now calls ClerkExpo.signOut() when isSignedIn becomes false (lines 84-91 in ClerkProvider.tsx). This clears the native SDK's session so getSession() returns null and native components like UserButton won't show stale signed-in state.

Note: in the typical quickstart pattern where UserButton is only rendered inside an if (isSignedIn) guard, the stale native session was invisible since the component unmounts. But this fix makes it correct regardless of how the consumer structures their component tree.

Call ClerkExpo.signOut() in NativeSessionSync when isSignedIn becomes
false so native components don't retain a stale session after JS-side
sign out.
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

…n iOS
The iOS inline AuthView relied on Clerk.shared.auth.events async sequence
which may not emit for inline sign-ins. Switch to direct session observation
via .onChange(of: Clerk.shared.session?.id) matching Android's sessionFlow
approach. Also emit onAuthStateChange module events from inline views and
sync the native client token in ClerkProvider's syncNativeAuthToJs handler.
@chriscanin
chriscanin merged commit 4c2f188 into mainMar 13, 2026
41 checks passed
@chriscanin
chriscanin deleted the chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components branch March 13, 2026 15:24
chriscanin added a commit that referenced this pull request Mar 13, 2026
chriscanin added a commit that referenced this pull request Mar 16, 2026
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
Sign up for freeto 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

@chriscanin@mikepitre@swolfand
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(expo): add two-way JS/native session sync for expo native components by chriscanin · Pull Request #8032 · clerk/javascript · GitHub
Skip to content

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

Merged
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components
Mar 13, 2026
Merged

feat(expo): add two-way JS/native session sync for expo native components#8032
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components

Conversation

@chriscanin

@chriscaninchriscanin commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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

changeset-botBot commented Mar 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bc0d928

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

This PR includes changesets to release 1 package
NameType
@clerk/expoPatch

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

vercelBot commented Mar 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentMar 13, 2026 3:22pm

Request Review

@pkg-pr-new

pkg-pr-newBot commented Mar 10, 2026

Copy link
Copy Markdown

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

@coderabbitai

coderabbitaiBot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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
📝 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 nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring 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 nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/expo/src/provider/ClerkProvider.tsx Outdated
…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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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 threadpackages/expo/src/hooks/useUserProfileModal.ts
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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/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

Copy link
Copy Markdown
Contributor

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
Copy Markdown
ContributorAuthor

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!

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've pushed a commit that drops the entire reflection hack and uses updateDeviceToken() instead. It saves the token via StorageHelper and triggers refreshClientAndEnvironment() internally.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Things are working well. I am going to approve the PR here: clerk/clerk-android#564 (review)
If you can publish to maven, that would be amazing!

}

@MainActor
public func configure(publishableKey: String, bearerToken: String? = nil) async throws {

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

ClerkViewFactory now resolves the keychain service internally via a computed property that checks Info.plist for a ClerkKeychainService key first, then falls back to Bundle.main.bundleIdentifier. That resolved service gets passed to Clerk.configure(publishableKey:options:) as Clerk.Options(keychainConfig: .init(service: service)). So it matches whatever the iOS SDK uses without needing to pipe it through the bridge method signature.

I can start working towards including configuration options as a followup after the theming (and possibly testing, but can discuss the testing later in the week / monday). It will be important to include these.

@mikepitre

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

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;

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this. The current code in ClerkProvider.tsx calls ClerkExpo.configure(pk, bearerToken) unconditionally on mount regardless of whether bearerToken is null. There's no early return based on token presence.

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

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

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this now as well. getClientToken exists in both ClerkExpoModule.swift (as @objc func getClientToken) and the bridge file ClerkExpoModule.m (as RCT_EXTERN_METHOD). It's also defined in ClerkViewFactoryProtocol and implemented in ClerkViewFactory where it delegates to readNativeDeviceToken().

@chriscaninchriscanin 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.
Co-authored-by: Christopher Canin <chris@clerk.dev>
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

const effectiveTokenCache = tokenCache ?? defaultTokenCache;

useEffect(() => {
if (!isSignedIn) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Clear the native session when JS auth signs out

When isSignedIn becomes false this effect only resets hasSyncedRef and returns, so the native Clerk SDK keeps the previously mirrored session/device token. Any JS-side sign-out path (clerk.signOut(), session expiry, custom auth UI) will therefore leave ClerkExpo.getSession() non-null; components like UserButton immediately read that native session back and continue showing/opening the signed-in profile even though the JS SDK is signed out.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

This has been fixed. The NativeSessionSync effect now calls ClerkExpo.signOut() when isSignedIn becomes false (lines 84-91 in ClerkProvider.tsx). This clears the native SDK's session so getSession() returns null and native components like UserButton won't show stale signed-in state.

Note: in the typical quickstart pattern where UserButton is only rendered inside an if (isSignedIn) guard, the stale native session was invisible since the component unmounts. But this fix makes it correct regardless of how the consumer structures their component tree.

Call ClerkExpo.signOut() in NativeSessionSync when isSignedIn becomes
false so native components don't retain a stale session after JS-side
sign out.
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

…n iOS
The iOS inline AuthView relied on Clerk.shared.auth.events async sequence
which may not emit for inline sign-ins. Switch to direct session observation
via .onChange(of: Clerk.shared.session?.id) matching Android's sessionFlow
approach. Also emit onAuthStateChange module events from inline views and
sync the native client token in ClerkProvider's syncNativeAuthToJs handler.
@chriscanin
chriscanin merged commit 4c2f188 into mainMar 13, 2026
41 checks passed
@chriscanin
chriscanin deleted the chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components branch March 13, 2026 15:24
chriscanin added a commit that referenced this pull request Mar 13, 2026
chriscanin added a commit that referenced this pull request Mar 16, 2026
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
Sign up for freeto 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

@chriscanin@mikepitre@swolfand
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(expo): add two-way JS/native session sync for expo native components by chriscanin · Pull Request #8032 · clerk/javascript · GitHub
Skip to content

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

Merged
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components
Mar 13, 2026
Merged

feat(expo): add two-way JS/native session sync for expo native components#8032
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components

Conversation

@chriscanin

@chriscaninchriscanin commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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

changeset-botBot commented Mar 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bc0d928

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

This PR includes changesets to release 1 package
NameType
@clerk/expoPatch

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

vercelBot commented Mar 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentMar 13, 2026 3:22pm

Request Review

@pkg-pr-new

pkg-pr-newBot commented Mar 10, 2026

Copy link
Copy Markdown

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

@coderabbitai

coderabbitaiBot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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
📝 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 nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring 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 nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/expo/src/provider/ClerkProvider.tsx Outdated
…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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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 threadpackages/expo/src/hooks/useUserProfileModal.ts
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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/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

Copy link
Copy Markdown
Contributor

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
Copy Markdown
ContributorAuthor

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!

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've pushed a commit that drops the entire reflection hack and uses updateDeviceToken() instead. It saves the token via StorageHelper and triggers refreshClientAndEnvironment() internally.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Things are working well. I am going to approve the PR here: clerk/clerk-android#564 (review)
If you can publish to maven, that would be amazing!

}

@MainActor
public func configure(publishableKey: String, bearerToken: String? = nil) async throws {

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

ClerkViewFactory now resolves the keychain service internally via a computed property that checks Info.plist for a ClerkKeychainService key first, then falls back to Bundle.main.bundleIdentifier. That resolved service gets passed to Clerk.configure(publishableKey:options:) as Clerk.Options(keychainConfig: .init(service: service)). So it matches whatever the iOS SDK uses without needing to pipe it through the bridge method signature.

I can start working towards including configuration options as a followup after the theming (and possibly testing, but can discuss the testing later in the week / monday). It will be important to include these.

@mikepitre

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

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;

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this. The current code in ClerkProvider.tsx calls ClerkExpo.configure(pk, bearerToken) unconditionally on mount regardless of whether bearerToken is null. There's no early return based on token presence.

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

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

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this now as well. getClientToken exists in both ClerkExpoModule.swift (as @objc func getClientToken) and the bridge file ClerkExpoModule.m (as RCT_EXTERN_METHOD). It's also defined in ClerkViewFactoryProtocol and implemented in ClerkViewFactory where it delegates to readNativeDeviceToken().

@chriscaninchriscanin 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.
Co-authored-by: Christopher Canin <chris@clerk.dev>
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

const effectiveTokenCache = tokenCache ?? defaultTokenCache;

useEffect(() => {
if (!isSignedIn) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Clear the native session when JS auth signs out

When isSignedIn becomes false this effect only resets hasSyncedRef and returns, so the native Clerk SDK keeps the previously mirrored session/device token. Any JS-side sign-out path (clerk.signOut(), session expiry, custom auth UI) will therefore leave ClerkExpo.getSession() non-null; components like UserButton immediately read that native session back and continue showing/opening the signed-in profile even though the JS SDK is signed out.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

This has been fixed. The NativeSessionSync effect now calls ClerkExpo.signOut() when isSignedIn becomes false (lines 84-91 in ClerkProvider.tsx). This clears the native SDK's session so getSession() returns null and native components like UserButton won't show stale signed-in state.

Note: in the typical quickstart pattern where UserButton is only rendered inside an if (isSignedIn) guard, the stale native session was invisible since the component unmounts. But this fix makes it correct regardless of how the consumer structures their component tree.

Call ClerkExpo.signOut() in NativeSessionSync when isSignedIn becomes
false so native components don't retain a stale session after JS-side
sign out.
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

…n iOS
The iOS inline AuthView relied on Clerk.shared.auth.events async sequence
which may not emit for inline sign-ins. Switch to direct session observation
via .onChange(of: Clerk.shared.session?.id) matching Android's sessionFlow
approach. Also emit onAuthStateChange module events from inline views and
sync the native client token in ClerkProvider's syncNativeAuthToJs handler.
@chriscanin
chriscanin merged commit 4c2f188 into mainMar 13, 2026
41 checks passed
@chriscanin
chriscanin deleted the chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components branch March 13, 2026 15:24
chriscanin added a commit that referenced this pull request Mar 13, 2026
chriscanin added a commit that referenced this pull request Mar 16, 2026
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
Sign up for freeto 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

@chriscanin@mikepitre@swolfand
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(expo): add two-way JS/native session sync for expo native components by chriscanin · Pull Request #8032 · clerk/javascript · GitHub
Skip to content

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

Merged
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components
Mar 13, 2026
Merged

feat(expo): add two-way JS/native session sync for expo native components#8032
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components

Conversation

@chriscanin

@chriscaninchriscanin commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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

changeset-botBot commented Mar 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bc0d928

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

This PR includes changesets to release 1 package
NameType
@clerk/expoPatch

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

vercelBot commented Mar 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentMar 13, 2026 3:22pm

Request Review

@pkg-pr-new

pkg-pr-newBot commented Mar 10, 2026

Copy link
Copy Markdown

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

@coderabbitai

coderabbitaiBot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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
📝 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 nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring 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 nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/expo/src/provider/ClerkProvider.tsx Outdated
…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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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 threadpackages/expo/src/hooks/useUserProfileModal.ts
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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/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

Copy link
Copy Markdown
Contributor

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
Copy Markdown
ContributorAuthor

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!

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've pushed a commit that drops the entire reflection hack and uses updateDeviceToken() instead. It saves the token via StorageHelper and triggers refreshClientAndEnvironment() internally.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Things are working well. I am going to approve the PR here: clerk/clerk-android#564 (review)
If you can publish to maven, that would be amazing!

}

@MainActor
public func configure(publishableKey: String, bearerToken: String? = nil) async throws {

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

ClerkViewFactory now resolves the keychain service internally via a computed property that checks Info.plist for a ClerkKeychainService key first, then falls back to Bundle.main.bundleIdentifier. That resolved service gets passed to Clerk.configure(publishableKey:options:) as Clerk.Options(keychainConfig: .init(service: service)). So it matches whatever the iOS SDK uses without needing to pipe it through the bridge method signature.

I can start working towards including configuration options as a followup after the theming (and possibly testing, but can discuss the testing later in the week / monday). It will be important to include these.

@mikepitre

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

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;

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this. The current code in ClerkProvider.tsx calls ClerkExpo.configure(pk, bearerToken) unconditionally on mount regardless of whether bearerToken is null. There's no early return based on token presence.

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

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

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this now as well. getClientToken exists in both ClerkExpoModule.swift (as @objc func getClientToken) and the bridge file ClerkExpoModule.m (as RCT_EXTERN_METHOD). It's also defined in ClerkViewFactoryProtocol and implemented in ClerkViewFactory where it delegates to readNativeDeviceToken().

@chriscaninchriscanin 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.
Co-authored-by: Christopher Canin <chris@clerk.dev>
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

const effectiveTokenCache = tokenCache ?? defaultTokenCache;

useEffect(() => {
if (!isSignedIn) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Clear the native session when JS auth signs out

When isSignedIn becomes false this effect only resets hasSyncedRef and returns, so the native Clerk SDK keeps the previously mirrored session/device token. Any JS-side sign-out path (clerk.signOut(), session expiry, custom auth UI) will therefore leave ClerkExpo.getSession() non-null; components like UserButton immediately read that native session back and continue showing/opening the signed-in profile even though the JS SDK is signed out.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

This has been fixed. The NativeSessionSync effect now calls ClerkExpo.signOut() when isSignedIn becomes false (lines 84-91 in ClerkProvider.tsx). This clears the native SDK's session so getSession() returns null and native components like UserButton won't show stale signed-in state.

Note: in the typical quickstart pattern where UserButton is only rendered inside an if (isSignedIn) guard, the stale native session was invisible since the component unmounts. But this fix makes it correct regardless of how the consumer structures their component tree.

Call ClerkExpo.signOut() in NativeSessionSync when isSignedIn becomes
false so native components don't retain a stale session after JS-side
sign out.
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

…n iOS
The iOS inline AuthView relied on Clerk.shared.auth.events async sequence
which may not emit for inline sign-ins. Switch to direct session observation
via .onChange(of: Clerk.shared.session?.id) matching Android's sessionFlow
approach. Also emit onAuthStateChange module events from inline views and
sync the native client token in ClerkProvider's syncNativeAuthToJs handler.
@chriscanin
chriscanin merged commit 4c2f188 into mainMar 13, 2026
41 checks passed
@chriscanin
chriscanin deleted the chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components branch March 13, 2026 15:24
chriscanin added a commit that referenced this pull request Mar 13, 2026
chriscanin added a commit that referenced this pull request Mar 16, 2026
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
Sign up for freeto 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

@chriscanin@mikepitre@swolfand
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(expo): add two-way JS/native session sync for expo native components by chriscanin · Pull Request #8032 · clerk/javascript · GitHub
Skip to content

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

Merged
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components
Mar 13, 2026
Merged

feat(expo): add two-way JS/native session sync for expo native components#8032
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components

Conversation

@chriscanin

@chriscaninchriscanin commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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

changeset-botBot commented Mar 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bc0d928

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

This PR includes changesets to release 1 package
NameType
@clerk/expoPatch

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

vercelBot commented Mar 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentMar 13, 2026 3:22pm

Request Review

@pkg-pr-new

pkg-pr-newBot commented Mar 10, 2026

Copy link
Copy Markdown

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

@coderabbitai

coderabbitaiBot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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
📝 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 nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring 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 nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/expo/src/provider/ClerkProvider.tsx Outdated
…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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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 threadpackages/expo/src/hooks/useUserProfileModal.ts
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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/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

Copy link
Copy Markdown
Contributor

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
Copy Markdown
ContributorAuthor

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!

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've pushed a commit that drops the entire reflection hack and uses updateDeviceToken() instead. It saves the token via StorageHelper and triggers refreshClientAndEnvironment() internally.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Things are working well. I am going to approve the PR here: clerk/clerk-android#564 (review)
If you can publish to maven, that would be amazing!

}

@MainActor
public func configure(publishableKey: String, bearerToken: String? = nil) async throws {

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

ClerkViewFactory now resolves the keychain service internally via a computed property that checks Info.plist for a ClerkKeychainService key first, then falls back to Bundle.main.bundleIdentifier. That resolved service gets passed to Clerk.configure(publishableKey:options:) as Clerk.Options(keychainConfig: .init(service: service)). So it matches whatever the iOS SDK uses without needing to pipe it through the bridge method signature.

I can start working towards including configuration options as a followup after the theming (and possibly testing, but can discuss the testing later in the week / monday). It will be important to include these.

@mikepitre

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

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;

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this. The current code in ClerkProvider.tsx calls ClerkExpo.configure(pk, bearerToken) unconditionally on mount regardless of whether bearerToken is null. There's no early return based on token presence.

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

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

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this now as well. getClientToken exists in both ClerkExpoModule.swift (as @objc func getClientToken) and the bridge file ClerkExpoModule.m (as RCT_EXTERN_METHOD). It's also defined in ClerkViewFactoryProtocol and implemented in ClerkViewFactory where it delegates to readNativeDeviceToken().

@chriscaninchriscanin 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.
Co-authored-by: Christopher Canin <chris@clerk.dev>
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

const effectiveTokenCache = tokenCache ?? defaultTokenCache;

useEffect(() => {
if (!isSignedIn) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Clear the native session when JS auth signs out

When isSignedIn becomes false this effect only resets hasSyncedRef and returns, so the native Clerk SDK keeps the previously mirrored session/device token. Any JS-side sign-out path (clerk.signOut(), session expiry, custom auth UI) will therefore leave ClerkExpo.getSession() non-null; components like UserButton immediately read that native session back and continue showing/opening the signed-in profile even though the JS SDK is signed out.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

This has been fixed. The NativeSessionSync effect now calls ClerkExpo.signOut() when isSignedIn becomes false (lines 84-91 in ClerkProvider.tsx). This clears the native SDK's session so getSession() returns null and native components like UserButton won't show stale signed-in state.

Note: in the typical quickstart pattern where UserButton is only rendered inside an if (isSignedIn) guard, the stale native session was invisible since the component unmounts. But this fix makes it correct regardless of how the consumer structures their component tree.

Call ClerkExpo.signOut() in NativeSessionSync when isSignedIn becomes
false so native components don't retain a stale session after JS-side
sign out.
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

…n iOS
The iOS inline AuthView relied on Clerk.shared.auth.events async sequence
which may not emit for inline sign-ins. Switch to direct session observation
via .onChange(of: Clerk.shared.session?.id) matching Android's sessionFlow
approach. Also emit onAuthStateChange module events from inline views and
sync the native client token in ClerkProvider's syncNativeAuthToJs handler.
@chriscanin
chriscanin merged commit 4c2f188 into mainMar 13, 2026
41 checks passed
@chriscanin
chriscanin deleted the chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components branch March 13, 2026 15:24
chriscanin added a commit that referenced this pull request Mar 13, 2026
chriscanin added a commit that referenced this pull request Mar 16, 2026
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
Sign up for freeto 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

@chriscanin@mikepitre@swolfand
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(expo): add two-way JS/native session sync for expo native components by chriscanin · Pull Request #8032 · clerk/javascript · GitHub
Skip to content

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

Merged
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components
Mar 13, 2026
Merged

feat(expo): add two-way JS/native session sync for expo native components#8032
chriscanin merged 16 commits into
mainfrom
chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components

Conversation

@chriscanin

@chriscaninchriscanin commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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

changeset-botBot commented Mar 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bc0d928

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

This PR includes changesets to release 1 package
NameType
@clerk/expoPatch

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

vercelBot commented Mar 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentMar 13, 2026 3:22pm

Request Review

@pkg-pr-new

pkg-pr-newBot commented Mar 10, 2026

Copy link
Copy Markdown

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

@coderabbitai

coderabbitaiBot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

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
📝 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 nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring 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 nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/expo/src/provider/ClerkProvider.tsx Outdated
…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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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 threadpackages/expo/src/hooks/useUserProfileModal.ts
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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadpackages/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

Copy link
Copy Markdown
Contributor

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
Copy Markdown
ContributorAuthor

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!

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I've pushed a commit that drops the entire reflection hack and uses updateDeviceToken() instead. It saves the token via StorageHelper and triggers refreshClientAndEnvironment() internally.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Things are working well. I am going to approve the PR here: clerk/clerk-android#564 (review)
If you can publish to maven, that would be amazing!

}

@MainActor
public func configure(publishableKey: String, bearerToken: String? = nil) async throws {

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

@mikepitremikepitreMar 11, 2026

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

ClerkViewFactory now resolves the keychain service internally via a computed property that checks Info.plist for a ClerkKeychainService key first, then falls back to Bundle.main.bundleIdentifier. That resolved service gets passed to Clerk.configure(publishableKey:options:) as Clerk.Options(keychainConfig: .init(service: service)). So it matches whatever the iOS SDK uses without needing to pipe it through the bridge method signature.

I can start working towards including configuration options as a followup after the theming (and possibly testing, but can discuss the testing later in the week / monday). It will be important to include these.

@mikepitre

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

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;

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this. The current code in ClerkProvider.tsx calls ClerkExpo.configure(pk, bearerToken) unconditionally on mount regardless of whether bearerToken is null. There's no early return based on token presence.

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

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

Copy link
Copy Markdown

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

👍

A fix has been provided for this now as well. getClientToken exists in both ClerkExpoModule.swift (as @objc func getClientToken) and the bridge file ClerkExpoModule.m (as RCT_EXTERN_METHOD). It's also defined in ClerkViewFactoryProtocol and implemented in ClerkViewFactory where it delegates to readNativeDeviceToken().

@chriscaninchriscanin 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.
Co-authored-by: Christopher Canin <chris@clerk.dev>
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

const effectiveTokenCache = tokenCache ?? defaultTokenCache;

useEffect(() => {
if (!isSignedIn) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Clear the native session when JS auth signs out

When isSignedIn becomes false this effect only resets hasSyncedRef and returns, so the native Clerk SDK keeps the previously mirrored session/device token. Any JS-side sign-out path (clerk.signOut(), session expiry, custom auth UI) will therefore leave ClerkExpo.getSession() non-null; components like UserButton immediately read that native session back and continue showing/opening the signed-in profile even though the JS SDK is signed out.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

This has been fixed. The NativeSessionSync effect now calls ClerkExpo.signOut() when isSignedIn becomes false (lines 84-91 in ClerkProvider.tsx). This clears the native SDK's session so getSession() returns null and native components like UserButton won't show stale signed-in state.

Note: in the typical quickstart pattern where UserButton is only rendered inside an if (isSignedIn) guard, the stale native session was invisible since the component unmounts. But this fix makes it correct regardless of how the consumer structures their component tree.

Call ClerkExpo.signOut() in NativeSessionSync when isSignedIn becomes
false so native components don't retain a stale session after JS-side
sign out.
@chriscanin

Copy link
Copy Markdown
ContributorAuthor

!snapshot

…n iOS
The iOS inline AuthView relied on Clerk.shared.auth.events async sequence
which may not emit for inline sign-ins. Switch to direct session observation
via .onChange(of: Clerk.shared.session?.id) matching Android's sessionFlow
approach. Also emit onAuthStateChange module events from inline views and
sync the native client token in ClerkProvider's syncNativeAuthToJs handler.
@chriscanin
chriscanin merged commit 4c2f188 into mainMar 13, 2026
41 checks passed
@chriscanin
chriscanin deleted the chris/mobile-460-add-two-way-jsnative-session-sync-for-expo-components branch March 13, 2026 15:24
chriscanin added a commit that referenced this pull request Mar 13, 2026
chriscanin added a commit that referenced this pull request Mar 16, 2026
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
Sign up for freeto 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

@chriscanin@mikepitre@swolfand